Skip to content
---
name: siwe
description: "Add Sign-In with Ethereum (SIWE) authentication to a Scaffold-ETH 2 project. Use when the user wants to: add wallet-based login, implement SIWE, authenticate users with their Ethereum wallet, add session management with wallet signing, build sign-in with Ethereum, or add Web3 authentication."
---

Sign-In with Ethereum (SIWE) for Scaffold-ETH 2

Prerequisites

Check if ./packages/nextjs/scaffold.config.ts exists directly in the current working directory (do not search subdirectories). If it doesn't exist, this is not a Scaffold-ETH 2 project. Follow the instructions at https://docs.scaffoldeth.io/SKILL.md to scaffold it first. If it exists, continue directly with building.

Critical: Use viem's Native SIWE โ€” NOT the siwe npm Package

Viem provides all SIWE utilities natively via viem/siwe. Do not install the siwe npm package. It pulls in ethers as a peer dependency, which is unnecessary since SE-2 already uses viem.

Here are some commonly useful imports (but check official docs for any updates or alternatives):

import {
  createSiweMessage,
  parseSiweMessage,
  verifySiweMessage,
  generateSiweNonce,
} from "viem/siwe";

Dependencies

yarn workspace @se-2/nextjs add iron-session

Gotchas

1. Domain Validation in the Verify Route

The verify route must validate the SIWE message's domain against the request's Host header. Without this, an attacker can replay a signature from a different domain. This is a critical security check that's easy to skip:

// In your verify API route
const expectedDomain = req.headers.get("host");
if (!expectedDomain) {
  return NextResponse.json({ error: "Missing Host header" }, { status: 400 });
}
 
// Pass domain to verifySiweMessage
const isValid = await verifySiweMessage(client, {
  message,
  signature,
  nonce: storedNonce,
  domain: expectedDomain, // CRITICAL โ€” validates domain match
});

2. Session Options Must Use a Lazy Getter

sessionOptions must NOT be a module-level constant that calls getSessionPassword() at import time. During next build, the code runs in production mode, and the env var won't be set, causing a build failure. Use a lazy factory:

// packages/nextjs/utils/siwe.ts
import { SessionOptions } from "iron-session";
 
export type SiweSessionData = {
  nonce?: string;
  address?: string;
  chainId?: number;
  isLoggedIn: boolean;
  signedInAt?: string;
};
 
export const defaultSession: SiweSessionData = { isLoggedIn: false };
 
// Lazy getter โ€” defers env var evaluation to request time
export function getSessionOptions(): SessionOptions {
  const secret = process.env.IRON_SESSION_SECRET;
  const password =
    secret && secret.length >= 32
      ? secret
      : process.env.NODE_ENV === "production"
        ? (() => {
            throw new Error(
              "IRON_SESSION_SECRET must be set in production (32+ chars)",
            );
          })()
        : "complex_password_at_least_32_characters_long_for_dev";
 
  return {
    password,
    cookieName: "siwe-session",
    cookieOptions: {
      httpOnly: true,
      sameSite: "lax" as const,
      secure: process.env.NODE_ENV === "production",
      maxAge: 7 * 24 * 60 * 60,
    },
  };
}

3. hasSeenWalletConnected Ref to Prevent False Auto-Logout

On page refresh, the wallet reconnects asynchronously, causing a brief isConnected: false state. Without tracking whether the wallet was ever connected, this triggers a false logout. Use a ref:

const hasSeenWalletConnected = useRef(false);
 
useEffect(() => {
  if (isConnected) {
    hasSeenWalletConnected.current = true;
  }
  if (!isConnected && hasSeenWalletConnected.current && state.isSignedIn) {
    // Wallet actually disconnected โ€” sign out
    fetch("/api/siwe/session", { method: "DELETE" }).then(() => {
      setState((prev) => ({ ...prev, isSignedIn: false, address: undefined }));
    });
  }
}, [isConnected, state.isSignedIn]);

ERC-6492 Smart Wallet Support

The verify route should create a publicClient per chain to support smart contract wallet (Safe, Argent) signature verification. Maintain a SUPPORTED_CHAINS map and reject unknown chains:

import { createPublicClient, http, type Chain } from "viem";
import { mainnet, sepolia, hardhat /* ... */ } from "viem/chains";
 
const SUPPORTED_CHAINS: Record<number, Chain> = {
  [mainnet.id]: mainnet,
  [sepolia.id]: sepolia,
  [hardhat.id]: hardhat,
};
 
// In verify route:
const chain = SUPPORTED_CHAINS[parsedMessage.chainId!];
if (!chain)
  return NextResponse.json({ error: "Unsupported chain" }, { status: 400 });
const client = createPublicClient({ chain, transport: http() });