kavachOS
Authentication

Sign In With Ethereum

Wallet-based authentication using EIP-4361 message signing.

Sign In With Ethereum (SIWE) lets users authenticate by signing a structured message with their Ethereum wallet. No password. No email. The server verifies the signature came from the claimed address, then creates a session. The standard is EIP-4361.

It works with any wallet that supports personal_sign: MetaMask, WalletConnect, Coinbase Wallet, Rainbow, and others.

Setup

Add the plugin

lib/kavach.ts
import { createKavach } from 'kavachos';
import { siwe } from 'kavachos/auth'; 

const kavach = await createKavach({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.KAVACH_SECRET!,
  baseUrl: 'https://auth.example.com',
  plugins: [
    siwe({ 
      domain: 'example.com',             // shown in the wallet prompt
      uri: 'https://example.com',        // must match your app's origin
      statement: 'Sign in to Example App', 
    }), 
  ],
});

Add a signature verifier (production)

Out of the box, the plugin validates message structure and nonce integrity but does not do on-chain secp256k1 recovery. For production, pass a verifySignature function using viem or ethers:

lib/kavach.ts
import { verifyMessage } from 'viem';
import { siwe } from 'kavachos/auth';

siwe({
  domain: 'example.com',
  uri: 'https://example.com',
  verifySignature: async (message, signature) => { 
    const address = await verifyMessage({ message, signature: signature as `0x${string}` }); 
    return address; 
  }, 
})
lib/kavach.ts
import { ethers } from 'ethers';
import { siwe } from 'kavachos/auth';

siwe({
  domain: 'example.com',
  uri: 'https://example.com',
  verifySignature: async (message, signature) => { 
    return ethers.verifyMessage(message, signature); 
  }, 
})

Without verifySignature, the plugin trusts the address in the message body. Anyone can forge a sign-in by submitting a valid-looking message without a real signature. Always add signature recovery before going to production.

Sign-in flow

SIWE requires three steps: get a nonce, sign a message in the wallet, then submit both to the server.

Get a nonce

GET /auth/siwe/nonce

Request a server-generated nonce before building the sign-in message. Nonces are single-use and expire after 5 minutes (configurable).

const res = await fetch('/auth/siwe/nonce');
const { nonce } = await res.json(); 

Build and sign the message

Construct the EIP-4361 message string from the user's address, the nonce, and your app metadata. Pass it to the wallet for signing.

import { createSiweModule } from 'kavachos/auth';

// Build the message client-side using the same config as the server
const siweModule = createSiweModule({
  domain: 'example.com',
  uri: 'https://example.com',
  statement: 'Sign in to Example App',
});

const message = siweModule.buildMessage( 
  address,     // e.g. '0xAbC...' from the connected wallet
  nonce,       // from the previous step
  1,           // chain ID (1 = Ethereum mainnet)
); 

// Request wallet signature (works with any EIP-1193 provider)
const signature = await window.ethereum.request({ 
  method: 'personal_sign', 
  params: [message, address], 
}); 

The message the user sees in their wallet looks like:

example.com wants you to sign in with your Ethereum account:
0xAbC123...

Sign in to Example App

URI: https://example.com
Version: 1
Chain ID: 1
Nonce: a3f9c2e1d8b04f7a
Issued At: 2025-01-01T00:00:00.000Z

Verify and start a session

POST /auth/siwe/verify

Submit the original message and the wallet signature. On success, the server returns the verified Ethereum address and chain ID. Create a session with your session management layer from here.

const res = await fetch('/auth/siwe/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message, signature }), 
});

if (!res.ok) {
  const { error } = await res.json();
  console.error(error); // e.g. "Nonce expired" or "Signature does not match address"
} else {
  const { address, chainId } = await res.json(); 
  // address is the verified Ethereum address — use it to look up or create the user
}

Response200 OK

{
  "address": "0xAbC123...",
  "chainId": 1
}

Nonce lifecycle

Every sign-in attempt must use a fresh nonce from the server. Nonces:

  • Are 32 random hex bytes (256 bits of entropy)
  • Expire after nonceTtlSeconds (default: 300 seconds)
  • Are deleted immediately after a successful or failed verification — they cannot be reused

The nonce is embedded in the signed message, so it cannot be stripped or replaced after signing. This prevents replay attacks: a captured (message, signature) pair from one session cannot be submitted again.

If the nonce expires before the user signs, the verify endpoint returns 400 with "Nonce expired". Request a new nonce and rebuild the message.

Linking wallets to users

SIWE verifies an address — it does not create or look up a user by itself. After a successful /auth/siwe/verify, use the returned address to find an existing user record or create a new one:

const { address, chainId } = await res.json();

// Look up user by wallet address
let user = await db.query.users.findFirst({
  where: eq(users.walletAddress, address.toLowerCase()),
});

if (!user) {
  // First time — create account
  user = await db.insert(users).values({
    walletAddress: address.toLowerCase(),
    createdAt: new Date(),
  }).returning().get();
}

// Create a KavachOS session for this user
const session = await kavach.sessions.create({ userId: user.id });

Endpoints

EndpointMethodAuth requiredDescription
/auth/siwe/nonceGETNoGenerate a single-use nonce
/auth/siwe/verifyPOSTNoVerify message and signature, return address

Configuration reference

OptionTypeDefaultDescription
domainstringrequiredYour app's domain, shown in the wallet prompt (e.g. example.com)
uristringrequiredFull origin URI (e.g. https://example.com). Must match what the wallet signed
statementstringHuman-readable statement shown in the wallet (e.g. Sign in to Example App)
nonceTtlSecondsnumber300How long a nonce is valid before it expires
verifySignature(message, signature) => Promise<string>Custom secp256k1 recovery function. Returns the recovered address. Required in production

Security considerations

Always add verifySignature in production. Without it, the plugin validates message structure and nonce state, but anyone can submit a well-formed SIWE message for any address without a real signature.

Domain and URI binding. The plugin rejects messages where domain or uri do not match the server's config. This prevents phishing attacks where a malicious site captures a signature meant for a different origin.

Nonce reuse prevention. Nonces are deleted on first use regardless of whether verification succeeds. A second attempt with the same nonce always fails.

Chain ID. The chain ID in the message is returned to your application but is not enforced by the plugin. If your app is chain-specific (e.g. only Ethereum mainnet), check that chainId === 1 (or your expected value) after verification.

On this page