git clone https://github.com/openclaw/skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/openclaw/skills "$T" && mkdir -p ~/.openclaw/skills && cp -r "$T/skills/buildersgarden/siwa/server-side" ~/.openclaw/skills/openclaw-skills-siwa-server && rm -rf "$T"
skills/buildersgarden/siwa/server-side/skill.mdSIWA 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 |
|---|---|---|
| 30s | Line count + ASCII sum of first chars |
| 20s | + word count |
| 15s | + character at specific position |
| 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:
(single-process),createMemorySIWANonceStore()
(multi-instance), orcreateRedisSIWANonceStore(redis)
(Cloudflare Workers)createKVSIWANonceStore(kv) - Nonce stores handle issue + consume atomically — no manual nonce tracking needed
- For custom backends (SQL, Prisma, etc.), implement the
interface (justSIWANonceStore
+issue
)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
)
@buildersgarden/siwa| Export | Description |
|---|---|
| Sign a SIWA authentication message |
| Parse SIWA message string to fields |
| Verify signature + onchain registration. accepts callback, , or . |
| Issue nonce with optional for server-side tracking |
| Build SIWA message from fields |
Receipt Module (@buildersgarden/siwa/receipt
)
@buildersgarden/siwa/receipt| Export | Description |
|---|---|
| Create HMAC-signed receipt. Options: (ttl in ms, default 30min). Returns . |
| Verify and decode receipt. Returns or . |
| Default receipt validity: 30 minutes (1800000 ms) |
ERC-8128 Module (@buildersgarden/siwa/erc8128
)
@buildersgarden/siwa/erc8128| Export | Description |
|---|---|
| Verify ERC-8128 signed HTTP request. Options: . Returns . |
| Type: |
| Type: |
| Type: |
Client Resolver Module (@buildersgarden/siwa/client-resolver
)
@buildersgarden/siwa/client-resolver| Export | Description |
|---|---|
| Create a resolver that lazily creates and caches per chain. Options: . |
| Extract chain ID from format. Returns . |
| Interface: |
| Type: |
Nonce Store Module (@buildersgarden/siwa/nonce-store
)
@buildersgarden/siwa/nonce-store| Export | Description |
|---|---|
| Interface: |
| In-memory store with TTL expiry (single-process) |
| Redis-backed store. Default prefix: |
| Cloudflare Workers KV store |
| Interface for ioredis / node-redis |
| Interface for Cloudflare KV bindings |
Express Module (@buildersgarden/siwa/express
)
@buildersgarden/siwa/express| Export | Description |
|---|---|
| Auth middleware. Sets (and when x402). Options: |
| JSON parser with rawBody capture for Content-Digest verification |
| CORS middleware with SIWA headers |
Next.js Module (@buildersgarden/siwa/next
)
@buildersgarden/siwa/next| Export | Description |
|---|---|
| Wrap route handler with SIWA auth. Handler: . Options: |
| Return 204 OPTIONS response with CORS headers. Pass to include payment headers. |
| JSON Response with CORS headers. |
| Returns CORS headers object |
Fastify Module (@buildersgarden/siwa/fastify
)
@buildersgarden/siwa/fastify| Export | Description |
|---|---|
| Fastify plugin: CORS with SIWA headers. Uses if available. |
| preHandler hook: verifies ERC-8128 + receipt. Sets . Options: |
Hono Module (@buildersgarden/siwa/hono
)
@buildersgarden/siwa/hono| Export | Description |
|---|---|
| Auth middleware. Sets . 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:
includes built-in RPC endpoints for all supported chains (Base, Base Sepolia, ETH Sepolia, Linea Sepolia, Polygon Amoy). SetcreateClientResolver()environment variables only when you need to override the defaults (e.g., for a private RPC provider).RPC_URL_{chainId}
Further Reading
- Full Documentation — Complete API reference, advanced options, and examples
- SIWA Protocol Specification
- ERC-8004 Registry
- ERC-8128 HTTP Signatures