All articles
solanaauthsiwswalletsidentitysecurityweb3

Sign In With Solana: wallet-based auth from spec to session token

SIWS lets users authenticate with their Solana wallet via a signed plaintext message — no password, no OAuth. Here's the full flow: message format, signIn vs signMessage, server-side signature verification, and JWT issuance.

Share

Every web app that touches a Solana wallet eventually asks the same question: how do I know this user actually controls the wallet they connected? A connected wallet proves nothing by itself — any page can read wallet.publicKey. The proof of control comes from a signature.

Sign In With Solana (SIWS) standardises that proof into a full authentication protocol. The user signs a structured plaintext message with their wallet. The server verifies the Ed25519 signature, confirms the message fields are valid, and issues a session token. No password. No OAuth redirect. No email required. The wallet is the identity.

This article covers the spec, both signing paths (signIn vs legacy signMessage), a complete TypeScript implementation of both sides of the exchange, and the security details that actually matter.

The message format

SIWS defines a structured plaintext message — not a raw hash, not a JSON blob. Plaintext is intentional: the user should be able to read what they're signing in their wallet's signing dialog. The format is modelled on EIP-4361 (Sign In With Ethereum) with adjustments for Solana's address format.

A canonical SIWS message looks like this:

text
myapp.xyz wants you to sign in with your Solana account:
7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU

Sign in to MyApp

URI: https://myapp.xyz/auth
Version: 1
Chain ID: mainnet
Nonce: oBbLoEKWb
Issued At: 2026-06-29T12:00:00.000Z
Expiration Time: 2026-06-29T13:00:00.000Z

The fields, in order:

  • Domain — the hostname of the app requesting sign-in. The server must verify this matches the Origin header. This is the primary defence against phishing: a rogue site cannot produce a valid message for your domain.
  • Address — the base58-encoded Solana public key of the signing wallet. The server checks the recovered key matches this address.
  • Statement — a human-readable description shown to the user. Optional but strongly recommended. Keep it honest and specific.
  • URI — the specific endpoint involved. Binds the signature to a path, not just the origin.
  • Version — always 1 currently.
  • Chain IDmainnet, devnet, ortestnet. Prevents replay across networks.
  • Nonce — a random server-generated string, at least 8 alphanumeric characters. Consumed on use. This is the core replay-attack defence.
  • Issued At — ISO 8601 timestamp of message creation.
  • Expiration Time — optional but recommended. Limits the window during which a captured signature can be replayed.

Two signing paths: signIn vs signMessage

The Wallet Standard defines a SolanaSignIn feature that wallets can implement. When available, use it. When not, fall back to SolanaSignMessage.

signIn (preferred)

The signIn feature takes a structured input object rather than a raw message string. The wallet constructs the canonical message text internally, shows it to the user, signs it, and returns both the message bytes and the signature. The server reconstructs the message from the input and verifies it matches what was signed.

typescript
import type { SolanaSignInInput, SolanaSignInOutput } from "@solana/wallet-standard-features";

// Input — sent from your server (as JSON), received on the frontend
const input: SolanaSignInInput = {
  domain: "myapp.xyz",
  address: wallet.publicKey.toBase58(),
  statement: "Sign in to MyApp",
  uri: "https://myapp.xyz/auth",
  version: "1",
  chainId: "mainnet",
  nonce: await fetchNonce(),        // from your server
  issuedAt: new Date().toISOString(),
  expirationTime: new Date(Date.now() + 3_600_000).toISOString(),
};

// Call signIn — wallet builds the message, prompts user, returns output
const output: SolanaSignInOutput = await wallet.features["solana:signIn"].signIn(input);

// output.signedMessage — the exact bytes the user signed (Uint8Array)
// output.signature     — the Ed25519 signature (Uint8Array)
// output.account       — the signing account (address, publicKey bytes)

// Send all three to your backend
await fetch("/api/auth/verify", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    input,
    output: {
      signedMessage: Array.from(output.signedMessage),
      signature: Array.from(output.signature),
      address: output.account.address,
    },
  }),
});

signMessage (fallback)

Older wallets and wallets that haven't implemented the signIn feature use signMessage. You construct the message string yourself, encode it to UTF-8 bytes, and pass it to the wallet.

typescript
import { createSolanaSignInMessage } from "@solana/wallet-standard-util";
import { useWallet } from "@solana/wallet-adapter-react";

const { signMessage, publicKey } = useWallet();

async function signInLegacy(nonce: string) {
  if (!publicKey || !signMessage) throw new Error("Wallet not connected");

  const input: SolanaSignInInput = {
    domain: window.location.host,
    address: publicKey.toBase58(),
    statement: "Sign in to MyApp",
    uri: window.location.origin + "/auth",
    version: "1",
    chainId: "mainnet",
    nonce,
    issuedAt: new Date().toISOString(),
    expirationTime: new Date(Date.now() + 3_600_000).toISOString(),
  };

  // createSolanaSignInMessage builds the canonical plaintext from the input object
  const messageText = createSolanaSignInMessage(input);
  const messageBytes = new TextEncoder().encode(messageText);
  const signature = await signMessage(messageBytes);

  return { input, signedMessage: Array.from(messageBytes), signature: Array.from(signature) };
}

Frontend: detecting signIn support

Check whether the connected wallet advertises the solana:signIn feature, and branch accordingly. Most major wallets (Phantom, Backpack, Solflare) support it as of 2026.

typescript
import { useWallet } from "@solana/wallet-adapter-react";
import { createSolanaSignInMessage } from "@solana/wallet-standard-util";
import type { SolanaSignInInput } from "@solana/wallet-standard-features";

export function useSignIn() {
  const { wallet, signMessage, publicKey } = useWallet();

  async function signIn(): Promise<{ input: SolanaSignInInput; signedMessage: number[]; signature: number[]; address: string }> {
    if (!publicKey) throw new Error("Wallet not connected");

    // 1. Fetch nonce from server — creates and stores it server-side
    const { nonce } = await fetch("/api/auth/nonce", { method: "POST" }).then(r => r.json());

    const input: SolanaSignInInput = {
      domain: window.location.host,
      address: publicKey.toBase58(),
      statement: "Sign in to MyApp. This request will not trigger a blockchain transaction or cost any gas fees.",
      uri: window.location.origin,
      version: "1",
      chainId: "mainnet",
      nonce,
      issuedAt: new Date().toISOString(),
      expirationTime: new Date(Date.now() + 3_600_000).toISOString(),
    };

    // 2. Prefer signIn feature if available
    const solanaSignIn = wallet?.adapter && "standard" in wallet.adapter
      ? (wallet.adapter as any).wallet?.features?.["solana:signIn"]
      : null;

    if (solanaSignIn) {
      const output = await solanaSignIn.signIn(input);
      return {
        input,
        signedMessage: Array.from(output.signedMessage),
        signature: Array.from(output.signature),
        address: output.account.address,
      };
    }

    // 3. Fallback: construct message manually and use signMessage
    if (!signMessage) throw new Error("Wallet does not support signing");
    const messageText = createSolanaSignInMessage(input);
    const messageBytes = new TextEncoder().encode(messageText);
    const sig = await signMessage(messageBytes);

    return {
      input,
      signedMessage: Array.from(messageBytes),
      signature: Array.from(sig),
      address: publicKey.toBase58(),
    };
  }

  return { signIn };
}

Backend: nonce management

The nonce is the core replay-attack defence. Generate it server-side, store it with a short TTL, and delete it immediately upon use. A nonce must never be accepted twice.

typescript
// app/api/auth/nonce/route.ts  (Next.js App Router)
import { randomBytes } from "crypto";
import { NextResponse } from "next/server";

// In production: use Redis or a DB with TTL. This is in-memory for illustration.
const nonceStore = new Map<string, { expiresAt: number }>();

export async function POST() {
  // 12 random bytes → 16-char base64url nonce
  const nonce = randomBytes(12).toString("base64url");
  const expiresAt = Date.now() + 5 * 60 * 1000; // 5-minute TTL to submit the signature

  nonceStore.set(nonce, { expiresAt });

  return NextResponse.json({ nonce });
}

// Called by the verify route — consume and validate
export function consumeNonce(nonce: string): boolean {
  const entry = nonceStore.get(nonce);
  if (!entry) return false;
  nonceStore.delete(nonce); // consumed — cannot be reused
  return Date.now() < entry.expiresAt;
}

Backend: signature verification

Verifying a SIWS signature requires three checks:

  1. The Ed25519 signature is valid for the signed bytes under the claimed public key.
  2. The signed message text matches what you expect (reconstruct it from input and compare).
  3. The message fields are valid: nonce exists and is fresh, domain matches your host, expiration has not passed.
typescript
// app/api/auth/verify/route.ts
import { NextRequest, NextResponse } from "next/server";
import { PublicKey } from "@solana/web3.js";
import { createSolanaSignInMessage } from "@solana/wallet-standard-util";
import nacl from "tweetnacl";
import jwt from "jsonwebtoken";
import { consumeNonce } from "../nonce/route";

const JWT_SECRET = process.env.JWT_SECRET!;
const APP_DOMAIN = process.env.NEXT_PUBLIC_APP_DOMAIN!; // e.g. "myapp.xyz"

export async function POST(req: NextRequest) {
  const { input, output } = await req.json();

  // 1. Validate nonce — must exist and not be expired; consumed here
  if (!consumeNonce(input.nonce)) {
    return NextResponse.json({ error: "Invalid or expired nonce" }, { status: 401 });
  }

  // 2. Validate domain binding
  if (input.domain !== APP_DOMAIN) {
    return NextResponse.json({ error: "Domain mismatch" }, { status: 401 });
  }

  // 3. Validate expiration
  if (input.expirationTime && new Date(input.expirationTime) < new Date()) {
    return NextResponse.json({ error: "Message expired" }, { status: 401 });
  }

  // 4. Reconstruct the expected message and compare to what was signed
  const expectedMessage = createSolanaSignInMessage(input);
  const expectedBytes = new TextEncoder().encode(expectedMessage);
  const signedBytes = new Uint8Array(output.signedMessage);

  // Byte-for-byte comparison — the wallet must have signed exactly this message
  if (expectedBytes.length !== signedBytes.length ||
      !expectedBytes.every((b, i) => b === signedBytes[i])) {
    return NextResponse.json({ error: "Message mismatch" }, { status: 401 });
  }

  // 5. Verify the Ed25519 signature
  let pubkeyBytes: Uint8Array;
  try {
    pubkeyBytes = new PublicKey(output.address).toBytes();
  } catch {
    return NextResponse.json({ error: "Invalid public key" }, { status: 401 });
  }

  const signatureBytes = new Uint8Array(output.signature);
  const valid = nacl.sign.detached.verify(signedBytes, signatureBytes, pubkeyBytes);

  if (!valid) {
    return NextResponse.json({ error: "Signature verification failed" }, { status: 401 });
  }

  // 6. All checks passed — issue a JWT session token
  const token = jwt.sign(
    {
      sub: output.address,      // wallet address as the user identifier
      iat: Math.floor(Date.now() / 1000),
    },
    JWT_SECRET,
    { expiresIn: "7d" }
  );

  const response = NextResponse.json({ success: true, address: output.address });
  response.cookies.set("session", token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 7 * 24 * 60 * 60,
    path: "/",
  });
  return response;
}

Verifying the session on subsequent requests

Once the session cookie is set, subsequent API calls just verify the JWT.

typescript
// lib/auth.ts
import { cookies } from "next/headers";
import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.JWT_SECRET!;

export function getSession(): { address: string } | null {
  const token = cookies().get("session")?.value;
  if (!token) return null;
  try {
    const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
    return { address: payload.sub };
  } catch {
    return null;
  }
}

// In a Server Component or API route:
// const session = getSession();
// if (!session) redirect("/");
// const { address } = session; // base58 wallet address

React component: putting it together

tsx
"use client";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { useSignIn } from "@/hooks/use-sign-in";
import { useRouter } from "next/navigation";
import { useState } from "react";

export function SignInButton() {
  const { connected } = useWallet();
  const { signIn } = useSignIn();
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSignIn() {
    setLoading(true);
    setError(null);
    try {
      const { input, signedMessage, signature, address } = await signIn();
      const res = await fetch("/api/auth/verify", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ input, output: { signedMessage, signature, address } }),
      });
      if (!res.ok) {
        const { error } = await res.json();
        throw new Error(error || "Verification failed");
      }
      router.push("/dashboard");
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  if (!connected) return <WalletMultiButton />;

  return (
    <div>
      <button onClick={handleSignIn} disabled={loading}>
        {loading ? "Signing in..." : "Sign In With Solana"}
      </button>
      {error && <p style={{ color: "red" }}>{error}</p>}
    </div>
  );
}

The wallet_standard signIn input/output types

The @solana/wallet-standard-features package exports the canonical TypeScript types. Worth knowing what you're working with:

typescript
// From @solana/wallet-standard-features

export interface SolanaSignInInput {
  /** The domain requesting the sign-in (window.location.host) */
  readonly domain?: string;
  /** Base58 Solana address — if omitted, the wallet picks one */
  readonly address?: string;
  /** Human-readable statement shown to the user */
  readonly statement?: string;
  /** URI of the resource being signed for */
  readonly uri?: string;
  /** SIWS spec version — always "1" */
  readonly version?: string;
  /** "mainnet" | "devnet" | "testnet" | "localnet" */
  readonly chainId?: string;
  /** Server-generated nonce, min 8 chars */
  readonly nonce?: string;
  /** ISO 8601 — when the message was created */
  readonly issuedAt?: string;
  /** ISO 8601 — after this, the message must be rejected */
  readonly expirationTime?: string;
  /** ISO 8601 — before this, the message must be rejected */
  readonly notBefore?: string;
  /** Opaque app-specific request ID */
  readonly requestId?: string;
  /** EIP-55 Ethereum addresses (for cross-chain identity proofs) */
  readonly resources?: readonly string[];
}

export interface SolanaSignInOutput {
  /** The account that signed — contains address and publicKey bytes */
  readonly account: WalletAccount;
  /** The exact bytes that were signed — reconstruct & compare server-side */
  readonly signedMessage: Uint8Array;
  /** The Ed25519 signature */
  readonly signature: Uint8Array;
  /** Currently always "ed25519" */
  readonly signatureType?: "ed25519";
}

Security considerations

Nonce freshness and one-time use

The nonce is your replay-attack defence. A signed SIWS message is valid as long as the nonce has not been consumed and the expiration has not passed. If an attacker captures a valid signed message during transmission (MITM on HTTP, XSS exfiltration), they can replay it against your server. The nonce destroys that window: consume it on first use and it's worthless. Give nonces a short server-side TTL (5 minutes is typical) so abandoned flows don't leave live nonces in storage indefinitely.

Domain binding

The domain field in the message must exactly match the hostname of your server. Always verify it. This is what prevents phishing sites from presenting a legitimate-looking signing request and using the resulting signature against your app. A user signing malicious.xyz's message cannot be replayed against myapp.xyz because the domain field differs.

Message reconstruction

Always reconstruct the expected message from the input object on your server, then compare byte-for-byte to signedMessage. Do not trust a message string sent from the client. The client could send a fabricated message string while the input fields look valid — byte comparison catches this.

Expiration time

Set expirationTime to 1–2 hours from issuedAt. This limits the window during which a stolen signed message can be used. Note that this is the window to submit the signature — your issued JWT has its own expiry. They serve different purposes.

signMessage vs signIn: the UI difference

When using the legacy signMessage path, wallets typically show a generic "Sign Message" dialog that may not render the message in a human-readable way. Some wallets warn the user that the message could be anything. The signIn feature explicitly signals to the wallet that this is an authentication flow — wallets that implement it can show a purpose-built "Sign In" dialog that makes the intent clear and reduces phishing risk. This is the key UX reason to prefer signIn when available.

SIWS vs SIWE: what's different

SIWS is modelled on EIP-4361 but differs in a few ways:

  • Address format: SIWE uses EIP-55 checksummed hex addresses. SIWS uses base58 Solana addresses. The format is visually distinct enough that wallets can detect which protocol a message targets.
  • Signature algorithm: SIWE verifies ECDSA secp256k1 (Ethereum). SIWS verifies Ed25519 (Solana). Completely different verification code.
  • No EIP process: SIWS is a community specification (see the phantom/sign-in-with-solana repo), not an on-chain governance standard. Implementation varies slightly across wallets.
  • Wallet Standard integration: Ethereum's SIWE predates the Wallet Standard. Solana's implementation was built alongside it and is a first-class SolanaSignIn feature in the standard.

The fragmentation problem

Not every Solana wallet implements SolanaSignIn. Mobile wallets accessed via the Mobile Wallet Adapter (MWA) have a separate signing surface. Hardware wallets (Ledger) support signMessage but the message display on the device may be truncated.

In practice: check for signIn feature support at runtime and fall back to signMessage with a manually-constructed message. Your server should accept both code paths — the verification logic is identical once you have the signed bytes and signature.

For MWA, the @solana-mobile/wallet-adapter-mobile package handles the transport layer. The signing interface is the same signMessage call.

Libraries

Minimal install

bash
npm install @solana/wallet-standard-features @solana/wallet-standard-util tweetnacl
# For JWT session management:
npm install jsonwebtoken
npm install -D @types/jsonwebtoken

What SIWS does not solve

SIWS proves control of a key. It does not prove anything about the key's on-chain history, token balance, or NFT holdings at sign-in time — that requires separate RPC calls. It also does not give you account recovery: if the user loses access to their wallet, they lose access to their account in your app. That's a UX problem worth thinking through before going wallet-only.

For apps that need both: let users link multiple wallets to one account, or accept an email as a backup identifier alongside the wallet. SIWS handles the primary path cleanly; you own the edge cases.

Keep reading

Get new articles in your inbox

Technical deep-dives on Solana tooling, infrastructure, and ecosystem. No noise.

Sign In With Solana: wallet-based auth from spec to session token | devrels.xyz