Solana

Solana Auth (SIWS)

Sign-In-With-Solana for Next.js: server-side Ed25519 signature verification, single-use nonces, domain binding, RBAC, and wallet↔email account linking. Devnet playground available on the block detail page — this guide is for mainnet-beta production use.

Read the Getting Access guideif you haven't yet.

1. Get the file

Sign in at marrowstack.dev, open the Solana Auth block, and click Copy all files. Paste lib/solana-auth.tsx (~860 lines) into your project. Exports:

  • useSolanaAuth() — React hook: connect → sign → verify in one call
  • SignInWithSolanaButton — drop-in component (Phantom, Solflare, Backpack)
  • Two API route handlers: solanaAuthNonceHandler and solanaAuthVerifyHandler
  • WalletProviders — wallet-adapter context wrapper
  • buildSolanaCredentialsProvider() — NextAuth Credentials stub
  • SQL migration: auth_nonces and wallets tables

The file contains @ts-nocheck at the top so it type-checks cleanly in this repo without Solana peer dependencies installed. Remove it in your own project after installing peers.

The playground on the block detail page uses devnet with simulated wallets. Your production integration targets mainnet-beta. Set NEXT_PUBLIC_SOLANA_CLUSTER=mainnet-beta and use a mainnet RPC endpoint. Never use a devnet wallet address for production sign-in.

2. Prerequisites

  • Next.js 14 or 15, App Router
  • Supabase project
  • NextAuth already set up (the block adds a Credentials provider to your existing config)
  • Users with Phantom, Solflare, or Backpack wallet browser extension installed (for the sign-in button)

3. Install

  1. Clone the repo and copy solana-auth.tsx into your project at lib/solana-auth.tsx.
  2. Remove @ts-nocheck from line 1.
  3. Install peer dependencies:
bash
npm install @solana/web3.js bs58 tweetnacl
npm install @solana/wallet-adapter-base @solana/wallet-adapter-react \
    @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets

4. Environment

VariableRequiredDefaultPurpose
NEXT_PUBLIC_SUPABASE_URLyesYour Supabase project URL
SUPABASE_SERVICE_ROLE_KEYyesService role key — server-side only
NEXT_PUBLIC_SOLANA_CLUSTERyesmainnet-betaMust be mainnet-beta in production. devnet is for the playground only.
SOLANA_RPC_URLnoCustom mainnet RPC endpoint. Falls back to the public endpoint (rate-limited).
NEXT_PUBLIC_APP_URLyesYour app's canonical URL, used for domain binding in the SIWS message.
NEXTAUTH_SECRETyesRequired by NextAuth. Generate: openssl rand -base64 32

5. Database

Run the MIGRATION constant from the top of solana-auth.tsx in Supabase SQL Editor.

Creates two tables:

  • auth_nonces — single-use SIWS challenges with wallet address, domain, TTL, and consumed_at
  • wallets — links wallet addresses to user accounts, with RLS

RLS on both tables: service role has full access; anon is blocked.

6. Wire it in

Step 1 — Mount WalletProviders in layout:

app/layout.tsx
import { WalletProviders } from '@/lib/solana-auth'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <WalletProviders cluster="mainnet-beta">
          {children}
        </WalletProviders>
      </body>
    </html>
  )
}

Step 2 — Mount the two API routes:

app/api/solana-auth/nonce/route.ts
import { solanaAuthNonceHandler } from '@/lib/solana-auth'
export const GET = solanaAuthNonceHandler
app/api/solana-auth/verify/route.ts
import { solanaAuthVerifyHandler } from '@/lib/solana-auth'
export const POST = solanaAuthVerifyHandler

Step 3 — Add the Credentials provider to NextAuth:

lib/auth.ts
import { buildSolanaCredentialsProvider } from '@/lib/solana-auth'
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'

export const authOptions = {
  providers: [
    GitHub({ ... }),
    buildSolanaCredentialsProvider(),   // add this
  ],
}

Step 4 — Drop the button into your sign-in page:

app/auth/signin/page.tsx
'use client'
import { SignInWithSolanaButton } from '@/lib/solana-auth'

export default function SignInPage() {
  return (
    <div>
      <SignInWithSolanaButton
        onSuccess={(session) => console.log('Signed in:', session)}
        onError={(err) => console.error(err)}
      />
    </div>
  )
}

7. Verify it works

  1. Open your sign-in page. The SignInWithSolanaButton should render. Click it — your wallet extension should open.
  2. Approve the connection. The button should transition to "Sign message" state.
  3. Sign the message in your wallet. The button transitions to "Verifying…" and then to "Signed in".
  4. Confirm a row in auth_nonces has consumed_at set (replay protection).
  5. Confirm a row in wallets links the wallet address to a user account.
  6. Try the devnet playground on the block detail page to see the full flow animated step-by-step.

8. Failure modes & fixes

WalletNotConnectedError

Cause: The wallet adapter is not connected before calling useSolanaAuth().

Fix: Wrap your app in WalletProviders and ensure the user clicks connect before sign-in.

Nonce not found or already consumed

Cause: The nonce expired (5-minute TTL), was already used (replay protection), or the wallet address doesn't match.

Fix: Restart the flow to generate a fresh nonce. Ensure wallet address sent to /api/solana-auth/nonce matches the address used to sign.

Ed25519 verification failed

Cause: The signature is invalid — either tampered in transit or the message was modified between nonce fetch and signing.

Fix: Ensure the message passed to wallet.signMessage() is exactly the string returned from the nonce endpoint, with no modifications.

Domain binding error: expected marrowstack.dev, got localhost

Cause: NEXT_PUBLIC_APP_URL is set to the production domain but you're testing locally.

Fix: Set NEXT_PUBLIC_APP_URL=http://localhost:3000 in .env.local for local development.

TypeError: Cannot read properties of undefined (reading 'signMessage')

Cause: The wallet adapter context is not available — WalletProviders is not wrapping the component.

Fix: Confirm WalletProviders is mounted in app/layout.tsx above all components that use useSolanaAuth or SignInWithSolanaButton.

relation 'auth_nonces' does not exist

Cause: The SQL migration has not been run.

Fix: Run the MIGRATION constant from solana-auth.tsx in Supabase SQL Editor.

9. Security responsibilities

What this block guarantees:

  • Ed25519 signature verified server-side using tweetnacl — the client cannot forge a signature.
  • Nonces are single-use (consumed atomically in a single DB update). Replay attacks are prevented.
  • Domain binding: the SIWS message embeds your app domain. A signature obtained from a different domain is rejected.
  • Nonces expire after 5 minutes. An attacker cannot use a nonce from an old session.
  • All verification runs server-side in the API route — the client cannot skip it.

What you must ensure:

  • Run on HTTPS. SIWS messages sent over plaintext HTTP can be intercepted and replayed.
  • Set NEXT_PUBLIC_SOLANA_CLUSTER=mainnet-beta in production. devnet is for the playground only; these clusters must never share config.
  • Keep SUPABASE_SERVICE_ROLE_KEY server-side. It is only needed in the API routes, never in the client.
  • The SIWS message includes your app domain. If you change your domain, update NEXT_PUBLIC_APP_URL and re-test.