Programmatic authentication and signup using an Ethereum wallet: SIWE (Sign-In With Ethereum, EIP-4361) to obtain an API key, and wallet header signature to authenticate requests without storing a key. Both paths can create accounts on first use.
- Agents and headless clients cannot use browser OAuth or session cookies. SIWE gives them a standard way to sign in and receive an API key over HTTP.
- Per-request signing (wallet header auth) lets clients authenticate without storing an API key; the wallet proves ownership on each request. Why both? SIWE is for onboarding and key issuance; wallet headers are for ongoing requests when the client prefers not to keep a key.
- x402 topup can credit the paying wallet; when wallet signature headers are present, we credit the signer so the payer and the credited account are the same.
ONBOARDING (pick one):
GET /api/auth/siwe/nonce → { nonce, domain, ... }
POST /api/auth/siwe/verify → { apiKey, address, isNewAccount, user, organization }
-- or --
First request with X-Wallet-Address + X-Timestamp + X-Wallet-Signature (account created automatically)
-- or --
POST /api/v1/topup/10|50|100 (x402 payment; body.walletAddress or wallet sig headers)
AUTHENTICATING REQUESTS (pick one):
X-API-Key: <key from SIWE verify or dashboard>
X-Wallet-Address + X-Timestamp + X-Wallet-Signature
Authorization: Bearer <privy JWT or eliza_* API key>
Cookie: privy-token
- Nonce prevents replay: server issues a one-time value, client signs it, server consumes it on verify. Why not timestamp only? A timestamp can be replayed within the window; a consumed nonce cannot.
- Domain binding (message
domainmust match server host) prevents phishing: the user is signing for this service only. - Standard message format (EIP-4361) is interoperable with wallets and other services.
GET /api/auth/siwe/nonce
Returns parameters to build the SIWE message. Why return domain/uri/statement? So clients and agents don’t hardcode or guess; the server is the source of truth.
- Query:
chainId(optional, default 1). - Response:
{ nonce, domain, uri, chainId, version, statement }. - Nonce is stored in Redis with 5 min TTL. If Redis is unavailable, responds 503. Why 503? Without nonce storage we cannot safely verify; failing closed avoids issuing keys on an invalid flow.
- Rate limit: STRICT (10/min in production). Why STRICT? Prevents nonce flooding and brute force.
POST /api/auth/siwe/verify
- Body:
{ message, signature }(full EIP-4361 message string and hex signature). - Server: validates domain (must match
getAppUrl()host), verifies signature (viemverifyMessage), consumes nonce (single-use), then finds or creates user/org via sharedfindOrCreateUserByWalletAddress, issues a new API key, returns it with user/org. - Response:
{ apiKey, address, isNewAccount, user, organization }. - Rate limit: STRICT. Why? Verify creates accounts and issues API keys; strict limit reduces abuse.
- Stored by value in Redis (
siwe:nonce:{nonce}:v1), TTL 300s. Why 5 min? Balance between usability (client has time to sign) and limiting replay window. - Consumed on verify: we get-and-delete the key so each nonce is single-use. Why validate before consume? So invalid or malformed requests don’t burn valid nonces.
- Not IP-bound. Why? Agents run on serverless, VPNs, rotating IPs; binding to IP would break legitimate use without adding real security.
- Headers:
X-Wallet-Address,X-Timestamp,X-Wallet-Signature. - Message signed:
Eliza Cloud Authentication\nTimestamp: ${timestamp}\nMethod: ${method}\nPath: ${path}. Why method + path? Binds the signature to this request so it cannot be replayed on another endpoint. - Timestamp window: 5 minutes. Why? Allows clock skew while limiting replay.
- If the wallet is unknown, we create the account (same slug/credits as SIWE) so the first signed request is also signup. Why signup on first sig? So wallet-only clients can use the API without a separate SIWE step if they prefer.
- POST /api/v1/topup/10, /50, /100: payment is enforced by x402; recipient is resolved by
getTopupRecipient:- If wallet sig headers are present and valid → credit the signer’s wallet (no body
walletAddressrequired). Why? So the payer and the credited account are the same when using wallet auth. - Else → require body.walletAddress and find-or-create that wallet; credit that org. Why both? Frontends may send only body; headless clients can send wallet sig so the credited wallet is cryptographically tied to the request.
- If wallet sig headers are present and valid → credit the signer’s wallet (no body
- New accounts created via topup do not receive initial free credits; they only receive the paid amount. Why? Initial credits are for signup flows (SIWE, wallet header); topup is payment-only.
All wallet-based account creation goes through findOrCreateUserByWalletAddress (in lib/services/wallet-signup.ts):
- Slug:
wallet-${address.toLowerCase()}. Why lowercase? Consistent indexing and uniqueness; EIP-55 checksum is for display only. - Stored address: lowercase in DB. Why? Same as slug; lookups use normalized form.
- Initial credits: controlled by
grantInitialCredits(default true for SIWE and wallet-header; false for topup). Why envINITIAL_FREE_CREDITS? So deployments can set 0 or another value without code change. - Race handling: on unique constraint (duplicate wallet), we re-fetch the user created by the concurrent request and return that. Why? Two concurrent signups for the same wallet should both succeed and see the same account.
- Redis: SIWE nonce storage and wallet-header nonce consumption both require Redis. If Redis is unavailable, the nonce endpoint returns 503 and wallet-header auth throws "Service temporarily unavailable" (no fallback). Wallet-header auth is fully unavailable during Redis outages; this is intentional (fail closed for security). API-key and session auth are unaffected.
- Public paths:
/api/auth/siwe,/api/v1/topup(no session required; SIWE and x402 handle their own auth). - Passthrough: Requests with
X-API-Key,X-Wallet-Signature, orAuthorization: Bearer eliza_*are passed through without requiring a Privy session. Why check X-Wallet-Signature? Otherwise wallet-header auth would get 401 at the proxy beforerequireAuthOrApiKeyruns. - CORS:
X-Wallet-Address,X-Timestamp,X-Wallet-Signatureare inAccess-Control-Allow-Headersso browser clients can send wallet auth.
| File | Purpose |
|---|---|
lib/utils/app-url.ts |
getAppUrl() / getAppHost() for SIWE domain check |
lib/utils/siwe-helpers.ts |
Parse/validate SIWE message, consume nonce |
lib/services/wallet-signup.ts |
findOrCreateUserByWalletAddress (SIWE, wallet-auth, topup) |
lib/services/topup.ts |
getTopupRecipient (wallet sig or body) |
lib/auth/wallet-auth.ts |
verifyWalletSignature (per-request wallet auth + signup) |
app/api/auth/siwe/nonce/route.ts |
GET nonce |
app/api/auth/siwe/verify/route.ts |
POST verify, issue API key |
lib/cache/keys.ts |
CacheKeys.siwe.nonce, CacheTTL.siwe.nonce |
proxy.ts |
Public paths, wallet-sig passthrough, CORS headers |
- docs/siwe-cloud-vs-shaw.md — comparison with the other cloud repo (SIWE and wallet auth).
- content/authentication.mdx — user-facing auth docs (API key, session, x402, wallet/SIWE).