一键导入
siwa-server
Use this skill to implement server-side SIWA verification. For backends and APIs that need to authenticate ERC-8004 agents without signing capabilities.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Use this skill to implement server-side SIWA verification. For backends and APIs that need to authenticate ERC-8004 agents without signing capabilities.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Magic server wallet integration for SIWA authentication.
SIWA (Sign-In With Agent) authentication for ERC-8004 registered agents.
Openfort backend wallet integration for SIWA authentication.
Bankr wallet integration for SIWA authentication.
Circle developer-controlled wallet integration for SIWA authentication.
Self-hosted keyring proxy signer with optional 2FA for secure key isolation.
| name | siwa-server |
| version | 0.2.0 |
| description | Use this skill to implement server-side SIWA verification. For backends and APIs that need to authenticate ERC-8004 agents without signing capabilities. |
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.
npm install @buildersgarden/siwa viem
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"
};
}
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.
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");
});
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,
});
}
The SDK provides pre-built middleware for common frameworks:
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,
});
});
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 };
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 });
});
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 });
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;
Add pay-per-request or pay-once monetization to any SIWA-protected endpoint. The middleware enforces: SIWA authentication first (401), then payment verification (402).
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,
}],
};
import { createMemoryX402SessionStore } from "@buildersgarden/siwa/x402";
const x402WithSession: X402Config = {
...x402,
session: {
store: createMemoryX402SessionStore(),
ttl: 3_600_000, // 1 hour
},
};
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 };
});
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 };
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).
Servers can challenge agents at sign-in or during authenticated requests to prove they are AI agents.
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
}
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 |
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 }));
}
createMemorySIWANonceStore() (single-process), createRedisSIWANonceStore(redis) (multi-instance), or createKVSIWANonceStore(kv) (Cloudflare Workers)SIWANonceStore interface (just issue + consume)@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 |
@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) |
@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? } |
@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[] } |
@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 |
@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 |
@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 |
@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? } |
@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 |
# 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).