DEV Community

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

Posted on Originally published at fabwaseem.com

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

Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.

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 box p50 p95
verifySiweMessage, default mode, RPC unreachable 1,064 ms 1,077 ms
verifySiweMessage, default mode, public RPC 24.2 ms 33.6 ms
verifyHash with mode: "eoa", RPC unreachable 5.9 ms 8.9 ms
verifyHash with mode: "eoa", public RPC 5.1 ms 6.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:

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
Enter fullscreen mode Exit fullscreen mode

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

npm i viem iron-session
Enter fullscreen mode Exit fullscreen mode
// lib/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);
}
Enter fullscreen mode Exit fullscreen mode
// lib/siwe.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"
Enter fullscreen mode Exit fullscreen mode

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)

// lib/nonce-store.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();
}
Enter fullscreen mode Exit fullscreen mode
// app/api/siwe/nonce/route.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" } });
}
Enter fullscreen mode Exit fullscreen mode

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:

// components/sign-in-button.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>;
}
Enter fullscreen mode Exit fullscreen mode

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:

// app/api/siwe/verify/route.ts (simple version)
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 });
}
Enter fullscreen mode Exit fullscreen mode

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

Case Result
Valid message and signature true
Different nonce passed to verify false
Different domain passed to verify (phishing site) false
Checked 6 minutes later, message expired after 5 false
Message signed by another key false
One character of the statement changed false
Valid signature, no domain or nonce passed true

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.

The replay bug: a nonce in the cookie isn't single-use

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:

// lib/nonce-store.ts (Redis)
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";
Enter fullscreen mode Exit fullscreen mode

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:

// lib/verify.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;
}
Enter fullscreen mode Exit fullscreen mode

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

// app/api/me/route.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 });
}
Enter fullscreen mode Exit fullscreen mode

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.

Originally published on fabwaseem.com.

Top comments (2)

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

Nice that you actually replayed the login with the old cookie, most SIWE posts stop at the happy path. One thing on the smart wallet side: the ERC-1271 check runs on whatever chain your RPC points to, and a contract wallet can have a different owner at the same address on another chain. That's how the 20M OP sent to Wintermute in 2022 ended up with someone else, their Safe existed on mainnet but not yet on Optimism. So I'd pin the chain ID you expect, reject the rest, and run the 1271 check on that chain. Does your verify route look at Chain ID, or only domain and nonce?

Collapse
 
fabwaseem profile image
Waseem Anjum •

Good catch, Carlos, and no, it doesn't. Both versions of the route check the domain, the nonce and the time window, and the chain ID from the message only gets stored in the session. The public client is pinned to mainnet, so the ERC-1271 check always runs there, but nothing rejects a message that says Chain ID: 10. The fix is a single check after parsing: if parsed.chainId !== publicClient.chain.id, return null. The Wintermute case is a great example of why it matters for contract wallets. Thanks for flagging it.