SIWA Server-Side Verification
This guide covers server-side SIWA verification for backends and APIs that need to authenticate agents. No wallet or signing required — only verification.
For full API reference and advanced options, see https://siwa.id/docs.
Quick Start
1. Install
npm install @buildersgarden/siwa viem
2. Set Environment Variables
import { parseSIWAMessage, verifySIWA, createClientResolver, parseChainId } from "@buildersgarden/siwa";
// Dynamic client resolver — supports all chains, no hardcoding needed
const resolver = createClientResolver();
async function verifyAgent(message: string, signature: string) {
const fields = parseSIWAMessage(message);
const chainId = parseChainId(fields.agentRegistry);
const client = resolver.getClient(chainId!);
const result = await verifySIWA(
message,
signature,
"api.example.com",
(nonce) => validateAndConsumeNonce(nonce),
client,
);
if (!result.valid) {
throw new Error(result.error);
}
return {
address: result.address,
agentId: result.agentId,
verified: result.verified, // "onchain" | "offline"
};
}
Framework Middleware
The SDK provides pre-built middleware that handles SIWA sign-in (nonce + verify), ERC-8128 request verification, receipts, and CORS — all in a few lines.
Complete Server Implementation
Express.js
import express from "express";
import { randomBytes } from "crypto";
import { parseSIWAMessage, verifySIWA, createClientResolver, parseChainId } from "@buildersgarden/siwa";
import { createReceipt, verifyReceipt } from "@buildersgarden/siwa/receipt";
import { verifyAuthenticatedRequest } from "@buildersgarden/siwa/erc8128";
const app = express();
app.use(express.json());
// Dynamic client resolver — supports all chains, no hardcoding needed
const resolver = createClientResolver();
// In-memory nonce store (use Redis in production)
const nonceStore = new Map<string, { nonce: string; expires: number }>();
const SIWA_SECRET = process.env.SIWA_SECRET || "change-me-in-production";
// ─── Nonce Endpoint ──────────────────────────────────────────────────
app.post("/api/siwa/nonce", (req, res) => {
const { address, agentId, agentRegistry } = req.body;
if (!address || agentId === undefined || !agentRegistry) {
return res.status(400).json({ error: "Missing required fields" });
}
const nonce = randomBytes(16).toString("hex");
const issuedAt = new Date().toISOString();
const expirationTime = new Date(Date.now() + 10 * 60 * 1000).toISOString();
// Store nonce with expiration
const key = `${address}:${agentId}:${agentRegistry}`;
nonceStore.set(key, { nonce, expires: Date.now() + 10 * 60 * 1000 });
const chainId = parseChainId(agentRegistry);
res.json({ nonce, issuedAt, expirationTime, chainId });
});
// ─── Verify Endpoint ─────────────────────────────────────────────────
app.post("/api/siwa/verify", async (req, res) => {
const { message, signature } = req.body;
if (!message || !signature) {
return res.status(400).json({ error: "Missing message or signature" });
}
try {
// 1. Parse the SIWA message and resolve the client for this chain
const fields = parseSIWAMessage(message);
const chainId = parseChainId(fields.agentRegistry);
if (!chainId) {
return res.status(400).json({ error: "Invalid agentRegistry format" });
}
const client = resolver.getClient(chainId);
// 2. Verify nonce was issued by us
const key = `${fields.address}:${fields.agentId}:${fields.agentRegistry}`;
const stored = nonceStore.get(key);
if (!stored) {
return res.status(401).json({ error: "Invalid or expired nonce" });
}
if (stored.nonce !== fields.nonce) {
return res.status(401).json({ error: "Nonce mismatch" });
}
if (Date.now() > stored.expires) {
nonceStore.delete(key);
return res.status(401).json({ error: "Nonce expired" });
}
// 3. Verify signature and onchain registration
const result = await verifySIWA(
message,
signature,
process.env.DOMAIN || "localhost",
(nonce) => {
// Validate nonce was issued by us and consume it
if (stored.nonce !== nonce) return false;
nonceStore.delete(key);
return true;
},
client,
);
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
// 4. Create receipt for authenticated API calls
const { receipt } = createReceipt({
address: result.address,
agentId: result.agentId,
agentRegistry: result.agentRegistry,
chainId: result.chainId,
verified: result.verified,
}, {
secret: SIWA_SECRET,
ttl: 3600_000, // 1 hour in ms
});
res.json({
success: true,
address: result.address,
agentId: result.agentId,
verified: result.verified,
receipt,
});
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});
// ─── Protected Endpoint (ERC-8128) ───────────────────────────────────
app.post("/api/agent-action", async (req, res) => {
try {
// Verify the ERC-8128 signed request
const result = await verifyAuthenticatedRequest(req, {
receiptSecret: SIWA_SECRET,
});
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
// Access verified agent info
const { address, agentId } = result.agent;
// Process the action
const { action, params } = req.body;
res.json({
success: true,
agent: { address, agentId, verified },
result: `Processed ${action} for agent #${agentId}`,
});
} catch (error: any) {
res.status(401).json({ error: error.message });
}
});
app.listen(3000, () => {
console.log("SIWA server running on http://localhost:3000");
});
Next.js App Router
lib/siwa-resolver.ts (shared module)
import { createClientResolver, createMemorySIWANonceStore } from "@buildersgarden/siwa";
export const resolver = createClientResolver();
export const nonceStore = createMemorySIWANonceStore();
app/api/siwa/nonce/route.ts
import { NextResponse } from "next/server";
import { createSIWANonce, parseChainId } from "@buildersgarden/siwa";
import { resolver, nonceStore } from "@/lib/siwa-resolver";
export async function POST(req: Request) {
const { address, agentId, agentRegistry } = await req.json();
if (!address || agentId === undefined || !agentRegistry) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const chainId = parseChainId(agentRegistry);
if (!chainId) {
return NextResponse.json({ error: "Invalid agentRegistry format" }, { status: 400 });
}
const client = resolver.getClient(chainId);
const result = await createSIWANonce(
{ address, agentId, agentRegistry },
client,
{ nonceStore },
);
if (result.status !== "nonce_issued") {
return NextResponse.json(result, { status: 403 });
}
return NextResponse.json({
nonce: result.nonce,
issuedAt: result.issuedAt,
expirationTime: result.expirationTime,
chainId,
});
}
app/api/siwa/verify/route.ts
import { NextResponse } from "next/server";
import { parseSIWAMessage, verifySIWA, parseChainId } from "@buildersgarden/siwa";
import { createReceipt } from "@buildersgarden/siwa/receipt";
import { resolver, nonceStore } from "@/lib/siwa-resolver";
const SIWA_SECRET = process.env.SIWA_SECRET!;
export async function POST(req: Request) {
const { message, signature } = await req.json();
if (!message || !signature) {
return NextResponse.json({ error: "Missing message or signature" }, { status: 400 });
}
try {
const fields = parseSIWAMessage(message);
const chainId = parseChainId(fields.agentRegistry);
if (!chainId) {
return NextResponse.json({ error: "Invalid agentRegistry format" }, { status: 400 });
}
const client = resolver.getClient(chainId);
const result = await verifySIWA(
message,
signature,
process.env.NEXT_PUBLIC_DOMAIN!,
{ nonceStore },
client,
);
if (!result.valid) {
return NextResponse.json({ error: result.error }, { status: 401 });
}
// Create receipt
const { receipt } = createReceipt({
address: result.address,
agentId: result.agentId,
agentRegistry: result.agentRegistry,
chainId: result.chainId,
verified: result.verified,
}, {
secret: SIWA_SECRET,
ttl: 3600_000, // 1 hour in ms
});
return NextResponse.json({
success: true,
address: result.address,
agentId: result.agentId,
verified: result.verified,
receipt,
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
}
app/api/protected/route.ts
import { NextResponse } from "next/server";
import { verifyAuthenticatedRequest } from "@buildersgarden/siwa/erc8128";
const SIWA_SECRET = process.env.SIWA_SECRET!;
export async function GET(req: Request) {
const result = await verifyAuthenticatedRequest(req, {
receiptSecret: SIWA_SECRET,
});
if (!result.valid) {
return NextResponse.json({ error: result.error }, { status: 401 });
}
return NextResponse.json({
message: `Hello Agent #${result.agent.agentId}!`,
agent: result.agent,
});
}
export async function POST(req: Request) {
const result = await verifyAuthenticatedRequest(req, {
receiptSecret: SIWA_SECRET,
});
if (!result.valid) {
return NextResponse.json({ error: result.error }, { status: 401 });
}
const body = await req.json();
return NextResponse.json({
success: true,
agent: result.agent,
received: body,
});
}
SDK Wrappers for Express & Next.js
The SDK provides pre-built middleware for common frameworks:
Express Middleware
import express from "express";
import { siwaMiddleware, siwaJsonParser, siwaCors } from "@buildersgarden/siwa/express";
const app = express();
// Apply SIWA middleware to protected routes — no hardcoded chain needed
app.use("/api/protected", siwaMiddleware({
receiptSecret: process.env.SIWA_SECRET!,
}));
app.get("/api/protected/data", (req, res) => {
// req.agent contains verified agent info
const { address, agentId, verified } = req.agent;
res.json({
message: `Hello Agent #${agentId}!`,
address,
verified,
});
});
Next.js Wrapper
import { withSiwa, siwaOptions } from "@buildersgarden/siwa/next";
export const POST = withSiwa(async (agent, req) => {
const body = await req.json();
return { agent: { address: agent.address, agentId: agent.agentId }, received: body };
}, {
receiptSecret: process.env.SIWA_SECRET!,
allowedSignerTypes: ['eoa', 'sca'],
});
export { siwaOptions as OPTIONS };
Express
import express from "express";
import { siwaMiddleware, siwaJsonParser, siwaCors } from "@buildersgarden/siwa/express";
const app = express();
app.use(siwaJsonParser());
app.use(siwaCors());
app.get("/api/protected", siwaMiddleware({
receiptSecret: process.env.SIWA_SECRET!,
}), (req, res) => {
res.json({ agent: req.agent });
});
Fastify
import Fastify from "fastify";
import { siwaPlugin, siwaAuth } from "@buildersgarden/siwa/fastify";
const fastify = Fastify();
await fastify.register(siwaPlugin);
fastify.post("/api/protected", {
preHandler: siwaAuth({
receiptSecret: process.env.SIWA_SECRET!,
allowedSignerTypes: ['eoa'],
}),
}, async (req) => {
return { agent: req.agent };
});
await fastify.listen({ port: 3000 });
Hono
import { Hono } from "hono";
import { siwaMiddleware, siwaCors } from "@buildersgarden/siwa/hono";
const app = new Hono();
app.use("*", siwaCors());
app.post("/api/protected", siwaMiddleware({
receiptSecret: process.env.SIWA_SECRET!,
}), (c) => {
return c.json({ agent: c.get("agent") });
});
export default app;
x402 Payment Middleware
Add pay-per-request or pay-once monetization to any SIWA-protected endpoint. The middleware enforces: SIWA authentication first (401), then payment verification (402).
Server Setup
import { createFacilitatorClient, type X402Config } from "@buildersgarden/siwa/x402";
const facilitator = createFacilitatorClient({
url: "https://api.cdp.coinbase.com/platform/v2/x402",
});
const x402: X402Config = {
facilitator,
resource: { url: "/api/premium", description: "Premium data" },
accepts: [{
scheme: "exact",
network: "eip155:84532",
amount: "1000000", // 1 USDC (6 decimals)
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
payTo: "0xYourAddress",
maxTimeoutSeconds: 60,
}],
};
Pay-Once Sessions
import { createMemoryX402SessionStore } from "@buildersgarden/siwa/x402";
const x402WithSession: X402Config = {
...x402,
session: {
store: createMemoryX402SessionStore(),
ttl: 3_600_000, // 1 hour
},
};
Framework Examples
Next.js:
export const POST = withSiwa(async (agent, req, payment) => {
return { agent, txHash: payment?.txHash };
}, { x402 });
export const OPTIONS = () => siwaOptions({ x402: true });
Express:
app.post("/api/premium", siwaMiddleware({ x402 }), (req, res) => {
res.json({ agent: req.agent, txHash: req.payment?.txHash });
});
app.use(siwaCors({ x402: true }));
Hono:
app.use("*", siwaCors({ x402: true }));
app.post("/api/premium", siwaMiddleware({ x402 }), (c) => {
return c.json({ agent: c.get("agent"), txHash: c.get("payment")?.txHash });
});
Fastify:
await fastify.register(siwaPlugin, { x402: true });
fastify.post("/api/premium", { preHandler: siwaAuth({ x402 }) }, async (req) => {
return { agent: req.agent, txHash: req.payment?.txHash };
});
Verification Options
verifySIWA(
message: string, // Full SIWA message string
signature: string, // EIP-191 signature hex
expectedDomain: string, // Must match message domain
nonceValid: NonceValidator, // Nonce validation (see below)
client: PublicClient, // viem client for onchain checks
criteria?: SIWAVerifyCriteria, // Optional verification criteria
)
// NonceValidator: callback, stateless token, or nonce store
type NonceValidator =
| ((nonce: string) => boolean | Promise<boolean>)
| { nonceToken: string; secret: string }
| { nonceStore: SIWANonceStore };
Using Nonce Stores
import { createSIWANonce, verifySIWA } from "@buildersgarden/siwa";
import { createMemorySIWANonceStore } from "@buildersgarden/siwa/nonce-store";
const nonceStore = createMemorySIWANonceStore();
// Issue nonce
const nonce = await createSIWANonce(params, client, { nonceStore });
// Verify — nonceStore consumes the nonce automatically
const result = await verifySIWA(
message, signature, "example.com",
{ nonceStore },
client,
{ allowedSignerTypes: ['eoa'] },
);
Available stores: createMemorySIWANonceStore() (single-process), createRedisSIWANonceStore(redis) (multi-instance), createKVSIWANonceStore(kv) (Cloudflare Workers).
Captcha (Reverse CAPTCHA)
Servers can challenge agents at sign-in or during authenticated requests to prove they are AI agents.
Sign-In Captcha
import { createSIWANonce } from "@buildersgarden/siwa";
const result = await createSIWANonce(
{ address, agentId, agentRegistry },
client,
{
secret: SIWA_SECRET,
captchaPolicy: async ({ address }) => {
const known = await db.agents.exists(address);
return known ? null : 'medium';
},
captchaOptions: { secret: SIWA_SECRET },
},
);
if (result.status === 'captcha_required') {
return res.json(result); // Agent solves and resubmits
}
Per-Request Captcha
export const POST = withSiwa(handler, {
captchaPolicy: () => Math.random() < 0.05 ? 'easy' : null,
captchaOptions: { secret: process.env.SIWA_SECRET! },
});
| Level | Time Limit | Constraints |
|---|---|---|
easy |
30s | Line count + ASCII sum of first chars |
medium |
20s | + word count |
hard |
15s | + character at specific position |
extreme |
10s | + total character count |
Security Notes
For authenticated API calls, agents sign HTTP requests with ERC-8128:
import { verifyAuthenticatedRequest } from "@buildersgarden/siwa/erc8128";
async function handleRequest(req: Request) {
const result = await verifyAuthenticatedRequest(req, {
receiptSecret: process.env.SIWA_SECRET!,
// Optional: nonce store for replay protection
nonceStore: myNonceStore,
});
if (!result.valid) {
return new Response(JSON.stringify({ error: result.error }), {
status: 401,
});
}
// result.agent contains:
// - address: string
// - agentId: number
// - agentRegistry: string
// - chainId: number
// - signerType?: 'eoa' | 'sca'
return new Response(JSON.stringify({ agent: result.agent }));
}
Security Considerations
Nonce Management
- Use the built-in nonce store for production:
createMemorySIWANonceStore()(single-process),createRedisSIWANonceStore(redis)(multi-instance), orcreateKVSIWANonceStore(kv)(Cloudflare Workers) - Nonce stores handle issue + consume atomically — no manual nonce tracking needed
- For custom backends (SQL, Prisma, etc.), implement the
SIWANonceStoreinterface (justissue+consume) - Memory store is single-process only — nonces are lost on restart; use Redis for production
Domain Verification
- Always verify the domain matches your expected domain
- Prevents SIWA messages signed for other services from being reused
Receipt Security
- Use a strong secret (32+ random bytes)
- Rotate secrets periodically
- Set appropriate expiration times
- Never expose the secret to clients
Clock Tolerance
- Allow some clock skew between client and server
- Default is 60 seconds
- Adjust based on your security requirements
SDK Reference
Main Module (@buildersgarden/siwa)
| Export | Description |
|---|---|
signSIWAMessage(fields, signer) |
Sign a SIWA authentication message |
parseSIWAMessage(message) |
Parse SIWA message string to fields |
verifySIWA(message, signature, domain, nonceValid, client, criteria?) |
Verify signature + onchain registration. nonceValid accepts callback, { nonceToken, secret }, or { nonceStore }. |
createSIWANonce(params, client, options?) |
Issue nonce with optional { nonceStore } for server-side tracking |
buildSIWAMessage(fields) |
Build SIWA message from fields |
Receipt Module (@buildersgarden/siwa/receipt)
| Export | Description |
|---|---|
createReceipt(payload, options) |
Create HMAC-signed receipt. Options: { secret, ttl? } (ttl in ms, default 30min). Returns { receipt, expiresAt }. |
verifyReceipt(receipt, secret) |
Verify and decode receipt. Returns ReceiptPayload or null. |
DEFAULT_RECEIPT_TTL |
Default receipt validity: 30 minutes (1800000 ms) |
ERC-8128 Module (@buildersgarden/siwa/erc8128)
| Export | Description |
|---|---|
verifyAuthenticatedRequest(req, options) |
Verify ERC-8128 signed HTTP request. Options: VerifyOptions. Returns AuthResult. |
VerifyOptions |
Type: { receiptSecret, rpcUrl?, verifyOnchain?, publicClient?, nonceStore?, allowedSignerTypes? } |
AuthResult |
Type: { valid: true, agent: SiwaAgent } | { valid: false, error: string } |
SiwaAgent |
Type: { address, agentId, agentRegistry, chainId, signerType? } |
Client Resolver Module (@buildersgarden/siwa/client-resolver)
| Export | Description |
|---|---|
createClientResolver(options?) |
Create a resolver that lazily creates and caches PublicClient per chain. Options: { rpcOverrides?, allowedChainIds? }. |
parseChainId(agentRegistry) |
Extract chain ID from eip155:{chainId}:{address} format. Returns number | null. |
ClientResolver |
Interface: { getClient(chainId), isSupported(chainId), supportedChainIds() } |
ClientResolverOptions |
Type: { rpcOverrides?: Record<number, string>, allowedChainIds?: number[] } |
Nonce Store Module (@buildersgarden/siwa/nonce-store)
| Export | Description |
|---|---|
SIWANonceStore |
Interface: { issue(nonce, ttlMs), consume(nonce) } |
createMemorySIWANonceStore() |
In-memory store with TTL expiry (single-process) |
createRedisSIWANonceStore(redis, prefix?) |
Redis-backed store. Default prefix: "siwa:nonce:" |
createKVSIWANonceStore(kv, prefix?) |
Cloudflare Workers KV store |
RedisLikeClient |
Interface for ioredis / node-redis |
KVNamespaceLike |
Interface for Cloudflare KV bindings |
Express Module (@buildersgarden/siwa/express)
| Export | Description |
|---|---|
siwaMiddleware(options?) |
Auth middleware. Sets req.agent (and req.payment when x402). Options: { receiptSecret?, rpcUrl?, verifyOnchain?, publicClient?, allowedSignerTypes?, x402?: X402Config } |
siwaJsonParser() |
JSON parser with rawBody capture for Content-Digest verification |
siwaCors(options?) |
CORS middleware with SIWA headers |
Next.js Module (@buildersgarden/siwa/next)
| Export | Description |
|---|---|
withSiwa(handler, options?) |
Wrap route handler with SIWA auth. Handler: (agent, req, payment?) => object | Response. Options: { receiptSecret?, rpcUrl?, verifyOnchain?, allowedSignerTypes?, x402?: X402Config } |
siwaOptions(corsOpts?) |
Return 204 OPTIONS response with CORS headers. Pass { x402: true } to include payment headers. |
corsJson(data, init?) |
JSON Response with CORS headers. init: { status?: number } |
corsHeaders() |
Returns CORS headers object |
Fastify Module (@buildersgarden/siwa/fastify)
| Export | Description |
|---|---|
siwaPlugin |
Fastify plugin: CORS with SIWA headers. Uses @fastify/cors if available. |
siwaAuth(options?) |
preHandler hook: verifies ERC-8128 + receipt. Sets req.agent. Options: { receiptSecret?, rpcUrl?, verifyOnchain?, publicClient?, allowedSignerTypes? } |
Hono Module (@buildersgarden/siwa/hono)
| Export | Description |
|---|---|
siwaMiddleware(options?) |
Auth middleware. Sets c.set("agent", agent). Options: { receiptSecret?, rpcUrl?, verifyOnchain?, publicClient?, allowedSignerTypes? } |
siwaCors(options?) |
CORS middleware with SIWA headers + OPTIONS preflight |
Environment Variables
# Required
SIWA_SECRET=your-32-byte-random-secret
# Optional — override RPC endpoints per chain (createClientResolver checks these)
RPC_URL_84532=https://sepolia.base.org
RPC_URL_8453=https://mainnet.base.org
RPC_URL_11155111=https://rpc.sepolia.org
# Optional
DOMAIN=api.example.com
Note:
createClientResolver()includes built-in RPC endpoints for all supported chains (Base, Base Sepolia, ETH Sepolia, Linea Sepolia, Polygon Amoy). SetRPC_URL_{chainId}environment variables only when you need to override the defaults (e.g., for a private RPC provider).
Further Reading
- Full Documentation — Complete API reference, advanced options, and examples
- SIWA Protocol Specification
- ERC-8004 Registry
- ERC-8128 HTTP Signatures
Converted and distributed by TomeVault — claim your Tome and manage your conversions.