This project is a work in progress. Use at your own risk — we welcome feedback and contributions.

SIWA Documentation

Sign In With Agent — v1.0

What is SIWA? SIWA (Sign In With Agent) helps AI agents authenticate with services using their ERC-8004 onchain identity. Like SIWE (Sign In With Ethereum) for humans, but for agents with verifiable registration.

For AI agents: Read /skill.md for structured instructions.

Getting Started

SIWA provides two core capabilities:

  • Agent-side: Sign SIWA messages to prove ownership of an ERC-8004 identity
  • Server-side: Verify signatures and check onchain registration with multiple modular criteria

Agents use SIWA to authenticate with services by signing a structured message that proves ownership of their ERC-8004 identity. The service verifies the signature and checks onchain registration.

1

Install the SDK

npm install @buildersgarden/siwa
2

Create a Signer

Choose any wallet provider. See Wallet Options for all options.

import { createLocalAccountSigner } from "@buildersgarden/siwa/signer";
import { privateKeyToAccount } from "viem/accounts";
// Example: private key signer
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const signer = createLocalAccountSigner(account);
3

Request a Nonce

Get a nonce from the service you want to authenticate with.

const response = await fetch("https://api.example.com/siwa/nonce", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address: await signer.getAddress() }),
});
const { nonce, issuedAt } = await response.json();
4

Sign and Submit

Build and sign the SIWA message, then submit it to the service.

import { signSIWAMessage } from "@buildersgarden/siwa";
const { message, signature } = await signSIWAMessage({
domain: "api.example.com",
uri: "https://api.example.com/siwa",
agentId: 42, // Your ERC-8004 token ID
agentRegistry: "eip155:84532:0x8004A818BFB912233c491871b3d84c89A494BD9e",
chainId: 84532,
nonce,
issuedAt,
}, signer);
// Submit to service
const verifyResponse = await fetch("https://api.example.com/siwa/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, signature }),
});
const { receipt } = await verifyResponse.json();
5

Make Authenticated Requests

Use the receipt for subsequent API calls with ERC-8128 signatures.

import { signAuthenticatedRequest } from "@buildersgarden/siwa/erc8128";
const request = new Request("https://api.example.com/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "transfer" }),
});
const signedRequest = await signAuthenticatedRequest(request, receipt, signer, 84532);
const response = await fetch(signedRequest);

How It Works

Authentication Flow

  1. 1.The agent requests a nonce from the service, sending its address and agent ID.
  2. 2.The service returns the nonce along with timestamps.
  3. 3.The agent builds a SIWA message and signs it with its wallet.
  4. 4.The agent sends the message and signature to the service.
  5. 5.The service verifies the signature and calls the blockchain to confirm the agent owns that identity NFT.
  6. 6.If verified, the service returns a receipt. The agent uses this for subsequent authenticated requests.

The SDK provides two main functions:

  • Agent-side: signSIWAMessage(fields, signer) — builds and signs the message
  • Server-side: verifySIWA(message, signature, domain, nonceValid, client, criteria?) — validates signature and checks onchain ownership
SIWA authentication flow: Agent requests nonce, signs SIWA message, Service verifies signature and checks onchain ownership, returns HMAC-signed verification receipt, then subsequent API requests use ERC-8128 HTTP signatures

Signing (Agent-Side)

Everything an agent needs to authenticate: choose a signer, sign SIWA messages for initial sign-in, then sign subsequent API requests with ERC-8128.
SIWA supports both EOA (Externally Owned Account) and SCA (Smart Contract Account) signers — agents backed by smart wallets like Safe, Base Accounts, or ERC-6551 Token Bound Accounts work alongside traditional EOA-based agents.

Bankr

Bankr's Agent API provides smart contract wallets (ERC-4337) for AI agents with built-in signing capabilities. No additional SDK is needed — the signer communicates directly with the Bankr API over HTTP. Signatures are verified via ERC-1271 automatically.

# No extra package needed — only @buildersgarden/siwa

Create a signer from your Bankr API key:

import { signSIWAMessage } from "@buildersgarden/siwa";
import { createBankrSiwaSigner } from "@buildersgarden/siwa/signer";

...

Already using Bankr's OpenClaw trading skill for swaps and DeFi? This signer adds SIWA authentication on top — same API key, same wallet.

Circle

Circle's developer-controlled wallets provide secure key management for AI agents. Install the Circle SDK alongside SIWA:

npm install @circle-fin/developer-controlled-wallets

Create a signer from your Circle wallet credentials:

import { signSIWAMessage } from "@buildersgarden/siwa";
import { createCircleSiwaSigner } from "@buildersgarden/siwa/signer";

...

If you already have a Circle client instance, use createCircleSiwaSignerFromClient:

import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";
import { createCircleSiwaSignerFromClient } from "@buildersgarden/siwa/signer";

...

Openfort

Openfort's backend wallets provide secure key management and account abstraction for AI agents. Install the Openfort Node SDK alongside SIWA:

npm install @openfort/openfort-node

Create a signer from your Openfort wallet credentials:

import { signSIWAMessage } from "@buildersgarden/siwa";
import { createOpenfortSiwaSigner } from "@buildersgarden/siwa/signer";

...

If you already have an Openfort client instance, use createOpenfortSiwaSignerFromClient:

import Openfort from "@openfort/openfort-node";
import { createOpenfortSiwaSignerFromClient } from "@buildersgarden/siwa/signer";

...

Privy

Privy's server wallets provide embedded wallet infrastructure for AI agents. Install the Privy Node SDK alongside SIWA:

npm install @privy-io/node

Create a signer from your Privy wallet:

import { PrivyClient } from "@privy-io/node";
import { signSIWAMessage } from "@buildersgarden/siwa";
import { createPrivySiwaSigner } from "@buildersgarden/siwa/signer";
...

Private Key (Backend Scripts)

For server-side agents or scripts with direct private key access:

import { signSIWAMessage } from "@buildersgarden/siwa";
import { createLocalAccountSigner } from "@buildersgarden/siwa/signer";
import { privateKeyToAccount } from "viem/accounts";
...

Keyring Proxy (Self-Hosted, Non-Custodial)

For AI agents that need secure key isolation, we provide an optional keyring proxy — a separate service that holds the encrypted private key and performs all signing. The agent never touches the key.

import { signSIWAMessage } from "@buildersgarden/siwa";
import { createKeyringProxySigner } from "@buildersgarden/siwa/signer";

...

The keyring proxy is completely optional. It's useful when you want:

  • Key isolation from the agent process (protection against prompt injection)
  • Optional 2FA via Telegram for transaction approval
  • Self-hosted, non-custodial key management

See Deploy for keyring proxy setup, architecture, and security model.

Smart Accounts

Smart contract wallets (Safe, ZeroDev/Kernel, Coinbase Smart Wallet) work with the same createWalletClientSigner — their SDKs expose a standard WalletClient or EIP-1193 provider. The SDK detects the signer type automatically during verification via ERC-1271.

import { signSIWAMessage } from "@buildersgarden/siwa";
import { createWalletClientSigner } from "@buildersgarden/siwa/signer";
import { createWalletClient, custom } from "viem";
...

SIWA Sign-In

Build and sign a SIWA message to prove ownership of an ERC-8004 identity. The authentication flow consists of two steps:

  • 1.Get a nonce from the server's /siwa/nonce endpoint
  • 2.Sign and verify by sending the signature to /siwa/verify
FunctionReturnsDescription
signSIWAMessage(fields, signer){ message, signature, address }Build and sign a SIWA message.
buildSIWAMessage(fields)stringBuild a formatted SIWA message string.

Import from @buildersgarden/siwa.

import { signSIWAMessage } from "@buildersgarden/siwa";

// Step 1: Request nonce from server
...

ERC-8128 Request Signing

After SIWA sign-in, sign every outgoing API request with ERC-8128 HTTP Message Signatures.

FunctionReturnsDescription
signAuthenticatedRequest(req, receipt, signer, chainId, options?)RequestSign outgoing request with ERC-8128.
createErc8128Signer(signer, chainId, options?)EthHttpSignerCreate ERC-8128 HTTP signer from SIWA Signer.

Import from @buildersgarden/siwa/erc8128.

import { signAuthenticatedRequest } from "@buildersgarden/siwa/erc8128";

const request = new Request("https://api.example.com/action", {
...

Verification (Server-Side)

Everything a service needs to verify agents: validate SIWA sign-in, verify ERC-8128 signed requests, issue receipts, and plug into Express or Next.js.

SIWA Verification

Verify a signed SIWA message, check onchain ownership, and optionally validate ERC-8004 agent profile criteria. Implement two endpoints:

  • 1./siwa/nonce — Issue a nonce for the agent to sign
  • 2./siwa/verify — Verify the signed message and issue a receipt
FunctionReturnsDescription
verifySIWA(msg, sig, domain, nonceValid, client, criteria?)SIWAVerificationResultVerify signature + onchain ownership + criteria.
parseSIWAMessage(message)SIWAMessageFieldsParse SIWA message string to fields.

Import from @buildersgarden/siwa. Parameters:

ParameterTypeDescription
messagestringThe full SIWA message string.
signaturestringEIP-191 signature hex string.
expectedDomainstringMust match message domain.
nonceValidfunction | objectNonce validator: callback (nonce) => boolean, { nonceToken, secret } for stateless, or { nonceStore } for store-based replay protection. See Nonce Store.
clientPublicClientviem PublicClient for onchain verification.
criteria?SIWAVerifyCriteriaOptional criteria to filter agents (see below).

Criteria Options

Use criteria to enforce policies on which agents can authenticate. These are checked during sign-in and encoded into the receipt.

CriteriaTypeDescription
allowedSignerTypes('eoa' | 'sca')[]Restrict to EOA-only or allow smart contract accounts.
mustBeActivebooleanRequire agent metadata.active === true.
requiredServicesstring[]Agent must support these services (e.g., 'llm', 'web3').
requiredTruststring[]Agent must support these trust models (e.g., 'tee', 'crypto-economic').
minScorenumberMinimum reputation score (requires reputationRegistryAddress).
minFeedbackCountnumberMinimum feedback count (requires reputationRegistryAddress).
reputationRegistryAddressstringReputation registry address for score/feedback checks.
custom(agent) => Promise<boolean>Custom validation function with full agent profile.

The result includes signerType and optionally agent (full profile if criteria fetched metadata).

Nonce Endpoint Examples

Issue a nonce for the agent to sign. Import createSIWANonce from @buildersgarden/siwa.

// app/api/siwa/nonce/route.ts
import { createSIWANonce } from "@buildersgarden/siwa";
import { corsJson, siwaOptions } from "@buildersgarden/siwa/next";
...
// routes/siwa.ts
import express from "express";
import { createSIWANonce } from "@buildersgarden/siwa";
...
// src/routes/siwa.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
...
// src/routes/siwa.ts
import { FastifyPluginAsync } from "fastify";
import { createSIWANonce } from "@buildersgarden/siwa";
...

Verify Endpoint Examples

Verify the signed message and issue a receipt.

// app/api/siwa/verify/route.ts
import { verifySIWA } from "@buildersgarden/siwa";
import { createReceipt } from "@buildersgarden/siwa/receipt";
...
// routes/siwa.ts (add to same router as nonce)
import { verifySIWA } from "@buildersgarden/siwa";
import { createReceipt } from "@buildersgarden/siwa/receipt";
...
// src/routes/siwa.ts (add to same app as nonce)
import { verifySIWA } from "@buildersgarden/siwa";
import { createReceipt } from "@buildersgarden/siwa/receipt";
...
// src/routes/siwa.ts (add to same plugin as nonce)
import { verifySIWA } from "@buildersgarden/siwa";
import { createReceipt } from "@buildersgarden/siwa/receipt";
...

ERC-8128 Request Verification

Verify incoming ERC-8128 signed requests from authenticated agents.

FunctionReturnsDescription
verifyAuthenticatedRequest(req, options)AuthResultVerify incoming signed request.

Import from @buildersgarden/siwa/erc8128. Options accept allowedSignerTypes for policy enforcement. The verified agent includes a signerType field.

Receipts

Stateless HMAC-signed tokens issued after successful SIWA verification. The agent includes the receipt in subsequent requests, and the server verifies it.

FunctionReturnsDescription
createReceipt(payload, options){ receipt, expiresAt }Create an HMAC-signed receipt.
verifyReceipt(receipt, secret)ReceiptPayload | nullVerify and decode. Returns null if invalid.
Payload FieldTypeDescription
addressstringAgent wallet address.
agentIdnumberERC-8004 token ID.
signerTypeSignerType?Optional: 'eoa' or 'sca'.

Import from @buildersgarden/siwa/receipt.

Server Middleware

Drop-in middleware for popular frameworks. Each handles ERC-8128 verification, receipt checking, and CORS.

Note: ERC-8004 criteria (reputation, services, trust models) are checked during SIWA sign-in via verifySIWA(). The middleware below verifies ERC-8128 signatures on subsequent requests — it accepts allowedSignerTypes and verifyOnchain options for per-request policy.

OptionTypeDescription
receiptSecretstringHMAC secret for receipt verification. Defaults to RECEIPT_SECRET env.
allowedSignerTypes('eoa' | 'sca')[]Restrict to EOA-only or SCA-only agents.
verifyOnchainbooleanRe-check ownerOf on every request (slower but more secure).
rpcUrlstringRPC URL for onchain verification.
publicClientPublicClientviem PublicClient for ERC-1271 smart account signatures.

Next.js (App Router)

Import from @buildersgarden/siwa/next — exports: withSiwa, siwaOptions, corsJson

// app/api/protected/route.ts
import { withSiwa, siwaOptions } from "@buildersgarden/siwa/next";

...

Express

Import from @buildersgarden/siwa/express — exports: siwaMiddleware, siwaJsonParser, siwaCors

import express from "express";
import { siwaMiddleware, siwaJsonParser, siwaCors } from "@buildersgarden/siwa/express";
import { createPublicClient, http } from "viem";
...

Hono

Import from @buildersgarden/siwa/hono — exports: siwaMiddleware, siwaCors

import { Hono } from "hono";
import { siwaMiddleware, siwaCors } from "@buildersgarden/siwa/hono";
import { createPublicClient, http } from "viem";
...

Fastify

Import from @buildersgarden/siwa/fastify — exports: siwaPlugin, siwaAuth

import Fastify from "fastify";
import { siwaPlugin, siwaAuth } from "@buildersgarden/siwa/fastify";
import { createPublicClient, http } from "viem";
...

Nonce Store

Pluggable server-side nonce tracking for replay protection. The SDK ships built-in adapters for Memory, Redis, Cloudflare KV, and any SQL database — no extra dependencies, you bring your own client.

Why Nonces Matter

A SIWA message is valid for its entire TTL window (default 5 minutes). Without server-side tracking, an attacker who intercepts a signed message can replay it — submitting the same signature multiple times to authenticate as the agent.

A nonce store solves this by recording every issued nonce and consuming it on first use. Once consumed, the nonce is deleted and any replay attempt is rejected.

The SDK provides three nonce validation strategies:

  • Callback(nonce) => boolean: you manage nonce storage yourself
  • Stateless token{ nonceToken, secret }: HMAC-signed token, no server storage needed (but replayable within TTL)
  • Nonce store{ nonceStore }: server-side tracking with exactly-once consumption (recommended for production)

The SIWANonceStore interface:

interface SIWANonceStore {
/** Store an issued nonce. Returns true on success, false if already exists. */
issue(nonce: string, ttlMs: number): Promise<boolean>;
/** Atomically check-and-delete a nonce. Returns true if it existed. */
consume(nonce: string): Promise<boolean>;
}

Wire the store into both createSIWANonce (issue) and verifySIWA (consume):

import { createSIWANonce, verifySIWA } from "@buildersgarden/siwa";
import { createMemorySIWANonceStore } from "@buildersgarden/siwa/nonce-store";
const nonceStore = createMemorySIWANonceStore();
// Nonce endpoint — issue
const result = await createSIWANonce(params, client, { nonceStore });
// Verify endpoint — consume
const verification = await verifySIWA(
message, signature, domain,
{ nonceStore },
client,
);

Memory (Default)

In-memory store using a Map with TTL-based expiry. Suitable for single-process servers. Nonces are lost on restart.

import { createMemorySIWANonceStore } from "@buildersgarden/siwa/nonce-store";
const nonceStore = createMemorySIWANonceStore();

Redis

Redis-backed store using SET ... PX ttl NX for atomic issue and DEL for atomic consume. Works with any Redis client that implements set() and del().

import { createRedisSIWANonceStore } from "@buildersgarden/siwa/nonce-store";
// ioredis (works out of the box)
import Redis from "ioredis";
const redis = new Redis();
const nonceStore = createRedisSIWANonceStore(redis);

For node-redis v4, wrap with a small adapter since its set() signature differs:

import { createClient } from "redis";
import { createRedisSIWANonceStore } from "@buildersgarden/siwa/nonce-store";
const client = createClient(); await client.connect();
const nonceStore = createRedisSIWANonceStore({
set: (...args: unknown[]) =>
client
.set(args[0] as string, args[1] as string, {
PX: args[3] as number,
NX: true,
})
.then((r) => r ?? null),
del: (key: unknown) => client.del(key as string),
});

Cloudflare KV

Cloudflare Workers KV-backed store. Uses put with expirationTtl for auto-expiry. The consume path does a get + delete — not fully atomic, but acceptable for random nonces.

import { createKVSIWANonceStore } from "@buildersgarden/siwa/nonce-store";
// In a Cloudflare Worker
export default {
async fetch(request: Request, env: Env) {
const nonceStore = createKVSIWANonceStore(env.SIWA_NONCES);
// use with createSIWANonce / verifySIWA
},
};

Bind a KV namespace called SIWA_NONCES in your wrangler.toml:

[[kv_namespaces]]
binding = "SIWA_NONCES"
id = "<your-kv-namespace-id>"

Database

For databases, implement SIWANonceStore directly — it's just two methods. No factory needed.

Schema — create a table with a unique nonce column and an expiry timestamp:

CREATE TABLE siwa_nonces (
nonce VARCHAR(64) PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL
);
-- Optional: periodic cleanup of expired rows
-- DELETE FROM siwa_nonces WHERE expires_at < NOW();

Prisma:

import type { SIWANonceStore } from "@buildersgarden/siwa/nonce-store";
const nonceStore: SIWANonceStore = {
async issue(nonce, ttlMs) {
try {
await prisma.siwaNonce.create({
data: { nonce, expiresAt: new Date(Date.now() + ttlMs) },
});
return true;
} catch (e: any) {
if (e.code === "P2002") return false; // unique constraint
throw e;
}
},
async consume(nonce) {
try {
await prisma.siwaNonce.delete({ where: { nonce } });
return true;
} catch {
return false;
}
},
};

Drizzle:

import type { SIWANonceStore } from "@buildersgarden/siwa/nonce-store";
import { eq, and, gt } from "drizzle-orm";
import { siwaNonces } from "./schema";
const nonceStore: SIWANonceStore = {
async issue(nonce, ttlMs) {
try {
await db.insert(siwaNonces).values({
nonce,
expiresAt: new Date(Date.now() + ttlMs),
});
return true;
} catch (e: any) {
if (e.code === "23505") return false; // unique violation
throw e;
}
},
async consume(nonce) {
const result = await db
.delete(siwaNonces)
.where(
and(
eq(siwaNonces.nonce, nonce),
gt(siwaNonces.expiresAt, new Date()),
),
);
return (result.rowCount ?? 0) > 0;
},
};

Captcha (Reverse CAPTCHA)

SIWA includes a reverse CAPTCHA — inspired by MoltCaptcha — that proves an entity is an AI agent, not a human. Challenges exploit how LLMs generate text in a single autoregressive pass, satisfying multiple constraints simultaneously, while humans must iterate.

The server generates a challenge with constraints the agent must satisfy in its response text — line count, sum of ASCII values of first characters, word count, character at a specific position, and total character count. Higher difficulty levels add more constraints with tighter time limits.

Two integration points:

  • Sign-in flow — server requires captcha before issuing a nonce
  • Per-request — middleware randomly challenges agents during authenticated API calls (server-defined policy/probability)

Sign-In Captcha

Add captchaPolicy and captchaOptions to your nonce endpoint. The policy function receives the agent's identity and returns a difficulty level or null to skip.

import { createSIWANonce } from "@buildersgarden/siwa";
const result = await createSIWANonce(
{ address, agentId, agentRegistry, challengeResponse },
client,
{
secret: SIWA_SECRET,
captchaPolicy: async ({ address }) => {
const known = await db.agents.exists(address);
return known ? null : 'medium'; // challenge unknown agents
},
captchaOptions: { secret: SIWA_SECRET },
},
);
// result.status is 'captcha_required' | 'nonce_issued' | SIWAResponse

Agent-side: use solveCaptchaChallenge() to detect and solve in one step.

import { solveCaptchaChallenge } from "@buildersgarden/siwa/captcha";
const res = await fetch("/api/siwa/nonce", {
method: "POST",
body: JSON.stringify({ address, agentId, agentRegistry }),
});
const data = await res.json();
const captcha = await solveCaptchaChallenge(data, async (challenge) => {
// LLM generates text satisfying all constraints in a single pass
// Your LLM generates text satisfying all constraints in one pass.
// Use any provider (Anthropic, OpenAI, etc.) — just return a string.
return await generateText(challenge);
});
if (captcha.solved) {
// Retry with challengeResponse
await fetch("/api/siwa/nonce", {
method: "POST",
body: JSON.stringify({ ...params, challengeResponse: captcha.challengeResponse }),
});
}

Per-Request Captcha

Add captchaPolicy to middleware for random spot-checks on authenticated requests.

// Next.js
export const POST = withSiwa(handler, {
captchaPolicy: ({ request }) => {
if (request?.url.includes('/transfer') && Math.random() < 0.05)
return 'hard';
return null;
},
captchaOptions: { secret: process.env.SIWA_SECRET! },
});
// Express
app.use(siwaMiddleware({
captchaPolicy: () => Math.random() < 0.05 ? 'easy' : null,
captchaOptions: { secret: SIWA_SECRET },
}));

Agent-side: use retryWithCaptcha() to detect a 401 captcha challenge, solve it, re-sign with ERC-8128, and get a ready-to-send retry request.

import { signAuthenticatedRequest, retryWithCaptcha } from "@buildersgarden/siwa/erc8128";
const url = "https://api.example.com/action";
const body = JSON.stringify({ key: "value" });
// Sign and send
const signed = await signAuthenticatedRequest(
new Request(url, { method: "POST", body }), receipt, signer, chainId,
);
const response = await fetch(signed);
// Detect + solve + re-sign if captcha required
const result = await retryWithCaptcha(
response,
new Request(url, { method: "POST", body }), // fresh request
receipt, signer, chainId,
async (challenge) => generateText(challenge), // your LLM solver
);
if (result.retry) {
const retryResponse = await fetch(result.request);
}

Difficulty Levels & Configuration

LevelTime LimitConstraints
easy30sLine count + ASCII sum of first chars
medium20s+ word count
hard15s+ character at specific position
extreme10s+ total character count

All verification behavior is configurable via captchaOptions.verify:

OptionDefaultDescription
useServerTimingtrueUse server wall-clock time instead of trusting the agent's solvedAt.
timingToleranceSeconds2Extra seconds added to time limit for network latency.
asciiTolerance0Allow ±N tolerance on ASCII sum comparison.
revealConstraintstrueInclude actual vs target values in verification results.
consumeChallengeCallback for one-time-use tokens. Return false to reject replays.

Override difficulty settings per tier with captchaOptions.difficulties:

captchaOptions: {
secret: SIWA_SECRET,
verify: {
useServerTiming: true,
timingToleranceSeconds: 5,
revealConstraints: false,
consumeChallenge: (token) => redis.set(`captcha:${token}`, '1', 'NX', 'EX', 60).then(r => r === 'OK'),
},
difficulties: {
easy: { timeLimitSeconds: 45 },
extreme: { timeLimitSeconds: 8 },
},
}

Import from @buildersgarden/siwa/captcha.

ExportSideDescription
solveCaptchaChallenge(nonceResponse, solver)AgentDetect + solve captcha from nonce response.
retryWithCaptcha(response, request, ...)AgentDetect captcha in 401, solve, re-sign, return retry request. (from erc8128 module)
packCaptchaResponse(token, text)AgentPackage solution for submission.
createCaptchaChallenge(difficulty, opts)ServerGenerate challenge + HMAC-signed token.
verifyCaptchaSolution(token, solution, secret, verifyOpts?)ServerVerify all constraints + timing. Async.
unpackCaptchaResponse(packed)ServerUnpack agent response.

x402 Payments

Overview

The x402 protocol adds pay-per-request monetization to any SIWA-protected endpoint. When a route requires payment, the middleware enforces a two-gate flow: SIWA authentication first, then payment verification. Both must pass before the handler runs.

  1. 1.Agent sends a request with ERC-8128 SIWA headers (always required).
  2. 2.Middleware verifies SIWA identity. If invalid, returns 401.
  3. 3.If no Payment-Signature header is present, returns 402 with a Payment-Required header containing accepted payment options.
  4. 4.Agent constructs a payment, encodes it as a Payment-Signature header, and retries.
  5. 5.Middleware sends the payment to a facilitator for verification and on-chain settlement.
  6. 6.On success, the handler runs and the response includes a Payment-Response header with the transaction hash.
HeaderDirectionDescription
Payment-RequiredServer to AgentBase64-encoded JSON with accepted payment options. Sent with 402 responses.
Payment-SignatureAgent to ServerBase64-encoded signed payment payload from the agent.
Payment-ResponseServer to AgentBase64-encoded settlement result with transaction hash.

Server Setup

Three things are needed to enable x402 on a route: a facilitator client (verifies and settles payments), a resource description, and an accepts array listing payment options.

import {
createFacilitatorClient,
type X402Config,
} from "@buildersgarden/siwa/x402";
// 1. Create facilitator client
const facilitator = createFacilitatorClient({
url: "https://x402-facilitator.example.com",
});
// 2. Define x402 config
const x402: X402Config = {
facilitator,
resource: {
url: "/api/premium",
description: "Premium data access",
},
accepts: [
{
scheme: "exact",
network: "eip155:84532", // Base Sepolia
amount: "1000000", // 1 USDC (6 decimals)
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
payTo: "0xYourAddress",
maxTimeoutSeconds: 60,
},
],
};

Pass the x402 config to any framework middleware. Routes without x402 remain SIWA-only (free).

Sessions (Pay-Once)

By default, every request requires a new payment. Add session to the x402 config to enable pay-once mode: the agent pays on the first request and subsequent requests within the TTL window pass through without payment.

Sessions are keyed by (address, resource.url) — different agents and different routes are isolated from each other.

import {
createFacilitatorClient,
createMemoryX402SessionStore,
} from "@buildersgarden/siwa/x402";
const facilitator = createFacilitatorClient({
url: process.env.X402_FACILITATOR_URL!,
});
// In-memory store (use Redis/DB in production)
const sessionStore = createMemoryX402SessionStore();
// Pass to any middleware
const x402Config = {
facilitator,
resource: { url: "/api/premium", description: "Premium access" },
accepts: [/* ... */],
session: {
store: sessionStore,
ttl: 3600_000, // 1 hour
},
};

On the first request, the agent pays and the middleware stores a session. On subsequent requests from the same agent to the same resource, the session is found and payment is skipped. After TTL expiry, payment is required again.

For production, implement the X402SessionStore interface with a shared store:

interface X402SessionStore {
get(address: string, resource: string): Promise<X402Session | null>;
set(address: string, resource: string, session: X402Session, ttlMs: number): Promise<void>;
}
interface X402Session {
paidAt: number;
txHash?: string;
}

Config Reference

FieldTypeDescription
facilitatorFacilitatorClientClient for payment verification and settlement.
resourceResourceInfoThe resource being paid for: { url, description? }.
acceptsPaymentRequirements[]Array of accepted payment options.
session?X402SessionConfigOptional pay-once session: { store, ttl }.

PaymentRequirements

FieldTypeDescription
schemestringPayment scheme (e.g. "exact").
networkstringChain identifier (e.g. "eip155:84532").
amountstringPayment amount in smallest unit (e.g. wei, USDC base units).
assetstringToken contract address.
payTostringRecipient address.
maxTimeoutSeconds?numberMaximum time for settlement.

X402Payment (verified payment on request)

FieldTypeDescription
schemestringPayment scheme.
networkstringChain identifier.
amountstringAmount paid.
assetstringToken address.
payTostringRecipient address.
txHash?stringOn-chain transaction hash from settlement.

FacilitatorClient

MethodReturnsDescription
verify(payload, requirements)VerifyResponseValidate a payment signature.
settle(payload, requirements)SettleResponseExecute on-chain settlement. Returns txHash on success.

Use createFacilitatorClient({ url }) to create a client that POSTs to /verify and /settle endpoints on the facilitator. Import from @buildersgarden/siwa/x402.

Middleware

Each framework wrapper accepts an optional x402 field. When set, both SIWA authentication and a valid payment are required. The verified payment is available in the handler.

Express

Import from @buildersgarden/siwa/express. Payment is available on req.payment.

import express from "express";
import { siwaMiddleware, siwaJsonParser, siwaCors } from "@buildersgarden/siwa/express";
import { createFacilitatorClient } from "@buildersgarden/siwa/x402";
...

Next.js

Import from @buildersgarden/siwa/next. Payment is the 3rd argument to the handler.

// app/api/premium/route.ts
import { withSiwa, siwaOptions } from "@buildersgarden/siwa/next";
import { createFacilitatorClient } from "@buildersgarden/siwa/x402";
...

Hono

Import from @buildersgarden/siwa/hono. Payment is available via c.get("payment").

import { Hono } from "hono";
import { siwaMiddleware, siwaCors } from "@buildersgarden/siwa/hono";
import { createFacilitatorClient } from "@buildersgarden/siwa/x402";
...

Fastify

Import from @buildersgarden/siwa/fastify. Payment is available on req.payment.

import Fastify from "fastify";
import { siwaPlugin, siwaAuth } from "@buildersgarden/siwa/fastify";
import { createFacilitatorClient } from "@buildersgarden/siwa/x402";
...

Agent-Side

When an agent receives a 402 response, it should:

  1. 1.Read the Payment-Required header and decode the payment options.
  2. 2.Construct a PaymentPayload with the signed payment.
  3. 3.Encode it as a Payment-Signature header and retry the request.
import {
  encodeX402Header,
  decodeX402Header,
...

Identity & Registry

Read and write agent identity state, and query onchain profiles.

Identity File

FunctionDescription
ensureIdentityExists(path, template)Initialize SIWA_IDENTITY.md if missing.
readIdentity(path)Parse SIWA_IDENTITY.md to typed object.
writeIdentityField(key, value, path)Write a field to SIWA_IDENTITY.md.
isRegistered({ identityPath, client? })Check registration status.

Import from @buildersgarden/siwa/identity.

Onchain Registry

FunctionReturnsDescription
registerAgent(options)RegisterResultRegister as ERC-8004 agent onchain.
getAgent(agentId, options)AgentProfileRead agent profile from registry.
getReputation(agentId, options)ReputationSummaryRead agent reputation.

Import from @buildersgarden/siwa/registry.

Protocol Specification

Message Format

{domain} wants you to sign in with your Agent account:
{address}
{statement}
URI: {uri}
Version: 1
Agent ID: {agentId}
Agent Registry: {agentRegistry}
Chain ID: {chainId}
Nonce: {nonce}
Issued At: {issuedAt}
[Expiration Time: {expirationTime}]
[Not Before: {notBefore}]
[Request ID: {requestId}]

Field Definitions

FieldRequiredDescription
domainYesOrigin domain requesting authentication.
addressYesAgent wallet address. Must be EIP-55 checksummed (mixed-case encoding).
statementNoHuman-readable purpose string.
uriYesRFC 3986 URI of the resource.
versionYesMust be "1".
agentIdYesERC-721 tokenId in the Identity Registry (numeric).
agentRegistryYesCAIP-10 reference: eip155:{chainId}:{registryAddress}. See Address Formatting.
chainIdYesEIP-155 chain ID (numeric). Must match the chain in agentRegistry.
nonceYesServer-generated, >= 8 alphanumeric chars.
issuedAtYesRFC 3339 datetime.
expirationTimeNoRFC 3339 datetime.
notBeforeNoRFC 3339 datetime.
requestIdNoOpaque request identifier.

See Address Formatting Standards for details on CAIP-10, EIP-55, and EIP-155.

SIWA vs SIWE

AspectSIWE (EIP-4361)SIWA
PurposeHuman wallet authAgent identity auth
Identity proofOwns an Ethereum addressOwns an ERC-8004 agent NFT
Onchain checkNone requiredownerOf(agentId) REQUIRED
Extra fieldsNoneagentId, agentRegistry
SigningEIP-191EIP-191 (same)
Message prefix...your Ethereum account...your Agent account

Contract Addresses

Mainnet

ChainChain IDIdentity RegistryReputation Registry
Ethereum10x8004A169FB4a3325136EB29fA0ceB6D2e539a4320x8004BAa17C55a88189AE136b182e5fdA19dE9b63
Base84530x8004A169FB4a3325136EB29fA0ceB6D2e539a4320x8004BAa17C55a88189AE136b182e5fdA19dE9b63

Testnets

ChainChain IDIdentity Registry
Ethereum Sepolia111551110x8004A818BFB912233c491871b3d84c89A494BD9e
Base Sepolia845320x8004A818BFB912233c491871b3d84c89A494BD9e
Linea Sepolia591410x8004A818BFB912233c491871b3d84c89A494BD9e
Polygon Amoy800020x8004A818BFB912233c491871b3d84c89A494BD9e

Address Formatting Standards

SIWA uses standardized address formats from the blockchain ecosystem:

CAIP-10: Agent Registry Reference

CAIP-10 (Chain Agnostic Improvement Proposal) defines a standard way to reference blockchain accounts across different networks.

The agentRegistry field uses CAIP-10 format to uniquely identify the ERC-8004 Identity Registry contract on a specific chain:

{namespace}:{chainId}:{contractAddress}
Format breakdown:
namespace = "eip155" (EVM-compatible chains per EIP-155)
chainId = numeric chain identifier (e.g., 8453 for Base)
contractAddress = EIP-55 checksummed contract address
Examples:
eip155:8453:0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 (Base mainnet)
eip155:84532:0x8004A818BFB912233c491871b3d84c89A494BD9e (Base Sepolia)
eip155:1:0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 (Ethereum mainnet)

EIP-55: Checksummed Addresses

EIP-55 defines mixed-case checksum encoding for Ethereum addresses.

All wallet addresses (address field) must be EIP-55 checksummed. This provides error detection for typos:

Correct (checksummed): 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0
Incorrect (lowercase): 0x742d35cc6634c0532925a3b844bc9e7595f0beb0

Most libraries (viem, ethers.js) automatically return checksummed addresses. You can use getAddress() from viem to ensure proper formatting.

EIP-155: Chain IDs

EIP-155 introduced chain IDs to prevent replay attacks across different networks.

The chainId field identifies which blockchain network the agent is registered on. Common chain IDs:

NetworkChain IDType
Ethereum1Mainnet
Base8453Mainnet
Base Sepolia84532Testnet
Ethereum Sepolia11155111Testnet

Public RPC Endpoints

ChainRPC URL
Basehttps://mainnet.base.org
Ethereum Sepoliahttps://rpc.sepolia.org
Base Sepoliahttps://sepolia.base.org
Linea Sepoliahttps://rpc.sepolia.linea.build
Polygon Amoyhttps://rpc-amoy.polygon.technology

For production, use a provider like Alchemy or Infura with your own API key.

Explorer

View registered agents at 8004scan.io