Next.js & React

Sign-In with Ethereum in Next.js 16: SIWE with viem, iron-session and a Replay-Proof Nonce (Tested)

A tested Sign-In with Ethereum flow for Next.js 16 with viem and iron-session: nonce, wallet signature and session, plus the cookie-nonce replay bug and the 1-second default verify I found while attacking it.

Sign-In with Ethereum in Next.js cover: a route handler that consumes a single-use nonce and calls viem verifyHash with mode eoa, with replay and latency results
On this page
  1. TL;DR
  2. How Sign-In with Ethereum works
  3. Setup
  4. Step 1: issue a nonce (and remember it on the server)
  5. Step 2: sign in the browser
  6. Step 3: verify on the server
  7. The replay bug: a nonce in the cookie isn't single-use
  8. The 1-second login: verify plain wallets locally
  9. Reading the session
  10. Security checklist
  11. Test setup

Sign-In with Ethereum (SIWE) lets a user log in by signing a plain-text message with their wallet, as defined in EIP-4361. In Next.js you need three pieces: a route that issues a nonce, a route that verifies the signed message, and a session cookie. viem ships every SIWE helper you need, so no extra siwe package is required.

I built the whole flow in a Next.js 16.4.3 app with viem 2.57.11 and iron-session 9.0.1, then attacked it. Two things surprised me. The common "keep the nonce in the session cookie" pattern let me replay a captured login with HTTP 200. And with viem's default verification mode, every login waited about 1.06 seconds when the RPC endpoint was down, against 5.9 ms with one option changed. Both fixes are below.

TL;DR#

  • Create the message with createSiweMessage, sign it in the wallet, and verify it on the server with viem's SIWE helpers. Always pass the expected domain and nonce. Without them, verification only checks the signature.
  • Store nonces server-side and delete them on first use (Redis SET NX EX then GETDEL). A nonce that lives only in the cookie can be replayed with the old cookie.
  • Verify plain wallets locally with mode: "eoa". Keep an RPC for smart-contract wallets (ERC-1271 and ERC-6492), but don't make every login wait on it.
  • Hard-code the domain from config, never from the request's Host header.
Login verify, 50 runs on the boxp50p95
verifySiweMessage, default mode, RPC unreachable1,064 ms1,077 ms
verifySiweMessage, default mode, public RPC24.2 ms33.6 ms
verifyHash with mode: "eoa", RPC unreachable5.9 ms8.9 ms
verifyHash with mode: "eoa", public RPC5.1 ms6.5 ms

Times are for the whole POST /api/siwe/verify request against next start, measured from a Node client on the same machine.

How Sign-In with Ethereum works#

  1. The browser asks your server for a nonce, a random single-use value.
  2. The browser builds an EIP-4361 message containing your domain, the wallet address, the nonce, a chain ID and timestamps, and asks the wallet to sign it (personal_sign).
  3. The server checks that the message names your domain, contains the nonce it issued, hasn't expired, and was signed by the address in the message. Then it starts a session.

This is what a signed message looks like, from my test run:

text
app.example.com wants you to sign in with your Ethereum account:
0x13F7713E3A0499BF531879888D8E6dd5f93FDea7

Sign in to Example.

URI: https://app.example.com/login
Version: 1
Chain ID: 1
Nonce: 6a5d8a5d6a69ad7863e7c51413d00d684959afce06879effaa9e1c6e9cc86e84ff0f0ed03cf62b106dab75ac0d37c66f
Issued At: 2026-10-26T05:10:48.993Z
Expiration Time: 2026-10-26T05:15:48.993Z

The domain line is what protects users from phishing. Many wallets warn the user when it doesn't match the site asking for the signature, and your server must reject a message signed for any other domain.

Setup#

bash
npm i viem iron-session
ts
import { cookies } from "next/headers";
import { getIronSession, type SessionOptions } from "iron-session";

export type SiweSession = { nonce?: string; address?: `0x${string}`; chainId?: number };

export const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET!, // at least 32 characters
  cookieName: "siwe_session",
  cookieOptions: { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production" },
};

export async function getSession() {
  return getIronSession<SiweSession>(await cookies(), sessionOptions);
}
ts
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";

// Only needed for smart-contract wallets (ERC-1271 / ERC-6492).
export const publicClient = createPublicClient({ chain: mainnet, transport: http(process.env.RPC_URL) });

// From config, never from the request's Host header.
export const SIWE_DOMAIN = process.env.SIWE_DOMAIN!; // e.g. "app.example.com"

iron-session 9 is ESM-only and needs Node 22.13 or later, according to its README. I ran everything on Node 22.20. The sealed session cookie in my test was 342 bytes.

Step 1: issue a nonce (and remember it on the server)#

ts
// In production use Redis: SET siwe:<nonce> 1 EX 300 NX on issue, GETDEL on use.
// A Map works for one process and for tests.
const issued = new Map<string, number>();

export async function issueNonce(nonce: string, ttlMs = 5 * 60_000) {
  issued.set(nonce, Date.now() + ttlMs);
}

export async function consumeNonce(nonce: string) {
  const exp = issued.get(nonce);
  issued.delete(nonce); // single use, even if verification fails
  return exp !== undefined && exp > Date.now();
}
ts
import { generateSiweNonce } from "viem/siwe";
import { getSession } from "@/lib/session";
import { issueNonce } from "@/lib/nonce-store";

export async function GET() {
  const session = await getSession();
  session.nonce = generateSiweNonce();
  await session.save();
  await issueNonce(session.nonce);
  return Response.json({ nonce: session.nonce }, { headers: { "Cache-Control": "no-store" } });
}

generateSiweNonce() returned a 96-character hex string in my test. EIP-4361 only requires at least 8 alphanumeric characters, so that is plenty.

Step 2: sign in the browser#

With wagmi 3 (3.7.9 when I wrote this) you already have a connected address and chain. useConnection replaces useAccount, and mutation hooks now return mutateAsync. Build the message with viem and ask the wallet to sign it:

tsx
"use client";
import { createSiweMessage } from "viem/siwe";
import { useConnection, useSignMessage } from "wagmi";

export function SignInButton() {
  const { address, chainId } = useConnection(); // useAccount() in wagmi 2
  const { mutateAsync: signMessage } = useSignMessage();

  async function signIn() {
    if (!address || !chainId) return;
    const { nonce } = await fetch("/api/siwe/nonce").then((r) => r.json());
    const message = createSiweMessage({
      domain: window.location.host,
      address,
      statement: "Sign in to Example.",
      uri: window.location.origin,
      version: "1",
      chainId,
      nonce,
      issuedAt: new Date(),
      expirationTime: new Date(Date.now() + 5 * 60_000),
    });
    const signature = await signMessage({ message });
    const res = await fetch("/api/siwe/verify", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ message, signature }),
    });
    if (!res.ok) throw new Error("Sign-in failed");
  }

  return <button onClick={signIn} disabled={!address}>Sign in with Ethereum</button>;
}

The client may put any domain it likes in the message. That is fine, because the server decides which domain it accepts.

Step 3: verify on the server#

The simplest version uses verifySiweMessage:

ts
import { parseSiweMessage, verifySiweMessage } from "viem/siwe";
import { getSession } from "@/lib/session";
import { consumeNonce } from "@/lib/nonce-store";
import { publicClient, SIWE_DOMAIN } from "@/lib/siwe";

export async function POST(req: Request) {
  const { message, signature } = await req.json();
  const session = await getSession();
  const nonce = session.nonce;
  if (!nonce || !(await consumeNonce(nonce))) {
    return Response.json({ ok: false, error: "nonce used or expired" }, { status: 401 });
  }
  session.nonce = undefined;

  const ok = await verifySiweMessage(publicClient, { message, signature, domain: SIWE_DOMAIN, nonce });
  if (!ok) { await session.save(); return Response.json({ ok: false }, { status: 401 }); }

  const { address, chainId } = parseSiweMessage(message);
  session.address = address;
  session.chainId = chainId;
  await session.save();
  return Response.json({ ok: true, address });
}

I tested what verifySiweMessage accepts and rejects with a fresh private key:

CaseResult
Valid message and signaturetrue
Different nonce passed to verifyfalse
Different domain passed to verify (phishing site)false
Checked 6 minutes later, message expired after 5false
Message signed by another keyfalse
One character of the statement changedfalse
Valid signature, no domain or nonce passedtrue

The last row matters. The domain and nonce parameters are optional, and when you leave them out the function only proves that the address signed some SIWE message. That message could have been signed for another site. Always pass both.

Many tutorials store the nonce only in the session cookie and clear it after login. I tested that version (the same route without consumeNonce):

  1. GET /api/siwe/nonce returns a cookie containing the nonce.
  2. POST /api/siwe/verify with that cookie, message and signature returns 200, and the response sets a new cookie without the nonce.
  3. Sending the same request again with the original cookie returned 200 again: a second valid session from one signature.

Clearing the nonce only changes the new cookie. The old cookie is still a valid, sealed copy of the session, and the server has no memory that it was used. Anyone who captured the request (a proxy log, a compromised browser extension, a shared machine) could mint sessions until the message expired. With the server-side store, the same replay returned 401 nonce used or expired.

In production, back issueNonce and consumeNonce with Redis so every instance sees the same state:

ts
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);

export const issueNonce = (nonce: string) => redis.set(`siwe:${nonce}`, "1", "EX", 300, "NX");
export const consumeNonce = async (nonce: string) => (await redis.getdel(`siwe:${nonce}`)) === "1";

GETDEL (Redis 6.2+) reads and deletes in one atomic step, so two parallel requests can't both use the same nonce. A Postgres table with DELETE ... RETURNING works the same way if you don't run Redis.

The 1-second login: verify plain wallets locally#

verifySiweMessage supports smart-contract wallets through ERC-6492, so by default it first verifies on-chain with an eth_call. Only when that call fails does it fall back to recovering the address locally. In my test:

  • With a public RPC, a login took 24.2 ms at p50, mostly the RPC round trip.
  • With the RPC unreachable, each login took 1,064 ms. My offline test client counted 4 RPC attempts per verification, matching viem's transport default of 3 retries, before the local fallback succeeded.

viem's lower-level verifyHash has a mode: "eoa" option that tries local ECDSA recovery first and only goes on-chain if that fails. verifySiweMessage's TypeScript type doesn't accept mode (true in 2.57.11, which I tested, and still true in 2.57.12, the latest release on October 27, 2026), so I rebuilt the same checks from viem's exported helpers:

ts
import { hashMessage, type Hex } from "viem";
import { verifyHash } from "viem/actions";
import { parseSiweMessage, validateSiweMessage } from "viem/siwe";
import { publicClient, SIWE_DOMAIN } from "@/lib/siwe";

export async function verifySiwe(message: string, signature: Hex, nonce: string) {
  const parsed = parseSiweMessage(message);
  if (!parsed.address) return null;
  // checks domain, nonce and the time window (expirationTime / notBefore)
  if (!validateSiweMessage({ message: parsed, domain: SIWE_DOMAIN, nonce })) return null;
  const ok = await verifyHash(publicClient, {
    address: parsed.address,
    hash: hashMessage(message),
    signature,
    mode: "eoa", // local ecrecover first, RPC only for contract wallets
  });
  return ok ? parsed : null;
}

Swap verifySiweMessage for verifySiwe in the route and logins took 5.1 ms (public RPC) and 5.9 ms (RPC down) at p50. Smart-contract wallets still verify on-chain, so keep a reliable RPC for them.

Reading the session#

ts
import { getSession } from "@/lib/session";

export async function GET() {
  const { address } = await getSession();
  return address ? Response.json({ address }) : Response.json({ error: "signed out" }, { status: 401 });
}

For pages, call getSession() in a Server Component or check the cookie in proxy.ts (the Next.js 16 name for middleware). If you also need email or OAuth logins, Better Auth with Next.js 16 covers that side, and SIWE can sit beside it as one more sign-in method.

Security checklist#

  • Domain from config. Compare against a fixed SIWE_DOMAIN. Reading it from the Host header lets a proxy or a forged header decide what you accept.
  • Single-use nonces on the server, deleted on first use, with a short TTL (5 minutes is plenty).
  • Short expiry. Set expirationTime on the message. viem's validation rejects expired messages, as my 6-minute test showed.
  • Rate-limit the nonce and verify routes per IP, like any login endpoint.
  • Treat the address as an identity, not a permission. Look up roles in your database after login.
  • Sign out by destroying the session (session.destroy()). Remember that a stateless cookie stays valid until it expires, so keep the session TTL modest or track session IDs server-side if you need instant revocation.

For the API side of the same idea (input validation, SSRF and auth checks), see SSRF protection in Node.js and the secure NestJS + Prisma API starter.

Test setup#

  • Next.js 16.4.3 (next build + next start), React 19.3.1, viem 2.57.11, iron-session 9.0.1, Node 22.20.0, on an 8 vCPU Linux box.
  • Wallets: fresh keys from generatePrivateKey(), signing with account.signMessage (the same EIP-191 personal_sign a browser wallet uses).
  • "RPC unreachable" pointed RPC_URL at a closed local port. "Public RPC" used a free public Ethereum mainnet endpoint.
  • Each latency row is 50 timed logins after 2 warm-ups. I did not test real smart-contract wallets.

Questions