Sign Messages & Authenticate Users

Signing a message proves that a user controls a wallet, with no transaction, no fee, and no network call. Apps use signed messages to sign users in, record that a user accepted terms, link a wallet to an existing account, or authorize an action that happens off-chain, such as placing an order.

Solana wallets sign messages in two ways:

  • Off-chain messages (solana:signOffchainMessage) wrap your text in a standard format, defined by the off-chain message signing (OCMS) specification. The format can never be mistaken for a transaction, names the accounts that must sign it, and is the format hardware wallets sign.
  • Raw messages (solana:signMessage) sign exactly the bytes your app passes in, with no prefix and no declared signers. Most software wallets support them, but nothing in the bytes marks them as a message rather than a transaction.

Use off-chain messages where the wallet supports them, and fall back to raw messages where it doesn't. The rest of this guide uses sign-in as the worked example, but the same signing and verification steps apply to any message.

Choose a message format

Off-chain messageRaw messageTransaction
Wallet featuresignOffchainMessagesignMessagesignTransaction
Hardware wallet supportDepends on deviceNoYes
Readable to the userYesSoftware onlyDepends on device
Declares required signersYesNoYes
RPC calls and feesNoneNoneBlockhash, send, fee, confirm

Why not a transaction?

Hardware wallets used to have no way to sign a readable message. Many apps fell back to asking these users to sign a no-op transaction instead. That works, but it adds latency, cost, and risk:

  • Network fees. A transaction pays a fee, even when it does nothing.
  • Multiple RPC calls. Your app fetches a blockhash, simulates, sends, and then polls for confirmation.
  • Additional latency. Each of those calls is a round trip before the user is signed in.
  • Nothing readable. The user approves transaction bytes, not a statement of what they are agreeing to.
  • Behavioral Risk. Asking users to sign a transaction just to log in makes them easier to trick into signing a harmful one.

An off-chain message skips all of that. The only round trip is to your own server.

A sign-in transaction fetches a blockhash, signs, simulates, sends, and polls for confirmation before the server can verify it. An off-chain message goes from a server challenge to a wallet signature to a server-side check, with no RPC calls and no fee.

What the wallet signs

An off-chain message starts with a fixed signing domain, \xffsolana offchain. No valid transaction starts with those bytes, so a signed message can never be used as a transaction. Version 1 of the format then lists the required signers and carries the message as UTF-8 text, with no length cap.

The wallet builds these bytes, signs them, and returns them alongside the signature. The specification covers the full format, including the legacy version 0.

A version 1 off-chain message is a 16-byte signing domain, a version byte, a signer count, each signer's 32-byte public key in sorted order, and the UTF-8 content. The envelope prefixes it with a signature count and one 64-byte signature per signer.

Choose a sign-in method

Not every wallet supports off-chain messages yet. Check the connected account's features before you ask for a signature, and fall back when the feature is missing:

  1. If the account supports solana:signOffchainMessage with message version 1, sign an off-chain message.
  2. Otherwise, sign the same challenge with solana:signMessage. Most software wallets support it.
  3. Give hardware wallet users without message support an explicit opt-in, such as an "I'm using a hardware wallet" toggle, that signs a sign-in transaction instead. Don't send them down this path by default.
When a wallet connects, check whether its account supports solana:signOffchainMessage version 1. If it does, sign an off-chain message. If not, sign a raw message with solana:signMessage, and let hardware wallet users opt into signing a sign-in transaction.

Sign a message in the browser

Install the Wallet Standard packages alongside Kit:

Terminal
$
npm install @solana/kit @solana/wallet-standard-features @wallet-standard/ui @wallet-standard/ui-features @wallet-standard/ui-registry

These helpers take a UiWalletAccount, such as the account returned by useConnectedWallet in the React guide:

offchain-message.ts
import {
SolanaSignOffchainMessage,
type SolanaSignOffchainMessageFeature
} from "@solana/wallet-standard-features";
import type { UiWalletAccount } from "@wallet-standard/ui";
import { getWalletAccountFeature } from "@wallet-standard/ui-features";
import { getWalletAccountForUiWalletAccount } from "@wallet-standard/ui-registry";
type OffchainMessageFeature =
SolanaSignOffchainMessageFeature[typeof SolanaSignOffchainMessage];
export function supportsOffchainMessages(account: UiWalletAccount) {
if (!account.features.includes(SolanaSignOffchainMessage)) return false;
const feature = getWalletAccountFeature(
account,
SolanaSignOffchainMessage
) as OffchainMessageFeature;
return feature.supportedMessageVersions.includes(1);
}
export async function signOffchainMessage(
account: UiWalletAccount,
message: string
) {
const feature = getWalletAccountFeature(
account,
SolanaSignOffchainMessage
) as OffchainMessageFeature;
const [output] = await feature.signOffchainMessage({
messageVersion: 1,
account: getWalletAccountForUiWalletAccount(account),
message,
requiredSigners: [account.publicKey]
});
return output;
}

signOffchainMessage returns signedOffchainMessage, the full bytes the wallet signed, and signature. Base64-encode both and send them to your server with the wallet address.

Write the challenge so it reads well on a small screen. Name your app and domain, say what signing does, and include a single-use nonce and an issue time from your server:

swap.app wants you to sign in with your Solana account.
Signing in costs nothing and does not submit a transaction.
Nonce: 8f3a1c9e
Issued at: 2026-09-29T17:04:00Z

Verify the signature on your server

Verification is a local signature check and needs no RPC connection. Decode the bytes the wallet signed, confirm they match the challenge you issued, then check the signature:

verify-sign-in.ts
import {
address,
assertOffchainMessageV1Equal,
getBase64Encoder,
getOffchainMessageDecoder,
verifyOffchainMessageEnvelope,
type OffchainMessageBytes,
type SignatureBytes
} from "@solana/kit";
export async function verifySignIn(input: {
walletAddress: string;
expectedMessage: string;
signedOffchainMessage: string;
signature: string;
}) {
const signer = address(input.walletAddress);
const base64 = getBase64Encoder();
const content = base64.encode(
input.signedOffchainMessage
) as OffchainMessageBytes;
const signature = base64.encode(input.signature) as SignatureBytes;
const received = getOffchainMessageDecoder().decode(content);
if (received.version !== 1) {
throw new Error(`Expected a v1 message, got v${received.version}`);
}
assertOffchainMessageV1Equal(received, {
version: 1,
requiredSignatories: [{ address: signer }],
content: input.expectedMessage
});
await verifyOffchainMessageEnvelope({
content,
signatures: { [signer]: signature }
});
}

assertOffchainMessageV1Equal throws if the content or the signers differ from what you expected. verifyOffchainMessageEnvelope throws if the signature is missing or invalid. Look up expectedMessage by nonce on your server rather than trusting the copy the client sends back. Consume the nonce once it verifies, so the same signature can't sign in twice.

Verify a raw message

A signMessage signature covers the challenge bytes directly, so the client only needs to send the signature and the wallet address. Check it against the challenge you issued:

verify-raw-sign-in.ts
import {
address,
getBase64Encoder,
getPublicKeyFromAddress,
getUtf8Encoder,
signatureBytes,
verifySignature
} from "@solana/kit";
export async function verifyRawSignIn(input: {
walletAddress: string;
expectedMessage: string;
signature: string;
}) {
const publicKey = await getPublicKeyFromAddress(address(input.walletAddress));
const signature = signatureBytes(getBase64Encoder().encode(input.signature));
const message = getUtf8Encoder().encode(input.expectedMessage);
if (!(await verifySignature(publicKey, signature, message))) {
throw new Error("Invalid signature");
}
}

The same nonce rules apply: look up expectedMessage by nonce and consume it once it verifies.

@solana/kit re-exports these helpers from @solana/offchain-messages. Use the standalone package if your server doesn't depend on Kit.

Sign In With Solana

Sign In With Solana (SIWS) standardizes the challenge itself. Your app passes structured fields, such as domain, statement, nonce, and issuedAt, to the wallet's solana:signIn feature. The wallet formats them into a standard message, connects if needed, and signs it in one step.

Version 1.1.0 of solana:signIn adds a useOffchainMessage option. When you pass useOffchainMessage: { messageVersion: 1 }, the wallet wraps the SIWS text in a version 1 off-chain message with the signing account as its only required signer. Hardware wallets can display and sign it, and the output reports signedMessageFormat: { kind: "offchainMessage", messageVersion: 1 }. Wallets that advertise version 1.0.0 don't know the option, so check the feature version first and omit it otherwise.

solana:signIn is a wallet feature, not an account feature, so these helpers take a UiWallet:

sign-in.ts
import {
SolanaSignIn,
type SolanaSignInFeature,
type SolanaSignInInput
} from "@solana/wallet-standard-features";
import type { UiWallet } from "@wallet-standard/ui";
import { getWalletFeature } from "@wallet-standard/ui-features";
type SignInFeature = SolanaSignInFeature[typeof SolanaSignIn];
export function supportsOffchainSignIn(wallet: UiWallet) {
if (!wallet.features.includes(SolanaSignIn)) return false;
const feature = getWalletFeature(wallet, SolanaSignIn) as SignInFeature;
return feature.version === "1.1.0";
}
export async function signIn(wallet: UiWallet, input: SolanaSignInInput) {
const feature = getWalletFeature(wallet, SolanaSignIn) as SignInFeature;
const [output] = await feature.signIn({
...input,
...(supportsOffchainSignIn(wallet) && {
useOffchainMessage: { messageVersion: 1 }
})
});
return output;
}

Build the input on your server, with a single-use nonce and an issuedAt time, and keep a copy keyed by the nonce. Consume the nonce once the sign-in verifies.

Without Wallet Adapter, verifySignIn from @solana/wallet-standard-util checks plain SIWS output only. When signedMessageFormat reports an off-chain message, signedMessage is an off-chain message whose content is the SIWS text. Rebuild the expected text from your stored input and the signing address with createSignInMessageText from @solana/wallet-standard-util, then verify it like any other off-chain message, as in Verify the signature on your server. The @solana/offchain-messages README covers this flow in Kit.

For wallets

Wallets add support by implementing the solana:signOffchainMessage Wallet Standard feature from @solana/wallet-standard-features:

  • Report version: "1.0.0" and list the message versions you can sign in supportedMessageVersions. Today, that is [1].
  • Build the version 1 preamble from requiredSigners, which you sort and check for duplicates. Reject the request if the account isn't one of the required signers.
  • Show the message text to the user in full before signing, including on hardware devices.
  • Return the exact bytes you signed as signedOffchainMessage, along with the Ed25519 signature.
  • To support off-chain Sign In With Solana, report version: "1.1.0" for solana:signIn. When the input includes useOffchainMessage, sign the SIWS text as a version 1 off-chain message, return its bytes as signedMessage, and set signedMessageFormat.
  • Keep supporting solana:signMessage. It is unchanged, and apps still use it as a fallback.

Is this page helpful?

Table des matières

Modifier la page
© 2026 Fondation Solana. Tous droits réservés.
Sign Messages & Authenticate Users | Solana