Sign-in-with-X on an x402 402: stateless wallet login for returning agents
Sign-in-with-X (SIWX) is an x402 extension that lets a seller put a wallet-login challenge inside its 402 Payment Required response, so that a wallet which has already been granted access can prove it controls its address instead of paying again. The Red Cup seller attaches this challenge to the 402s we probed (four routes across GET and POST, JSON, publications and chain reads), verifies the proof without storing any session, and decides separately what each proven wallet may use. This article shows the live challenge, the verification rules from source, and the design choices that keep it stateless.
What does the live 402 contain?
Request any paid route without payment and decode the PAYMENT-REQUIRED header (how to decode it). Under extensions there is a sign-in-with-x entry. Captured from POST /v1/data/json-pointer on 2026-10-22 (the nonce and times differ on every request):
"sign-in-with-x": {
"info": {
"domain": "market.561.group",
"uri": "https://market.561.group/v1/data/json-pointer",
"version": "1",
"nonce": "f5c70d9ea35239c9947516baf33ec590",
"issuedAt": "2026-10-22T00:30:28.688Z",
"expirationTime": "2026-10-22T00:35:28.688Z",
"statement": "Sign in to use what this wallet has been granted at market.561.group.",
"resources": ["https://market.561.group/v1/data/json-pointer"]
},
"supportedChains": [{ "chainId": "eip155:8453", "type": "eip191" }],
"schema": { "required": ["domain","address","uri","version","chainId","type","nonce","issuedAt","signature"], "...": "..." }
}This is an EIP-4361 ("Sign-In with Ethereum") message template in the CAIP-122 shape the x402 extension specifies: a domain, a URI, a nonce, an issue time and expiry, a human statement, and the resources the proof covers. The client fills in its address, signs the message with its wallet, and returns the result as base64 JSON in a SIGN-IN-WITH-X request header.
Why attach login to a payment challenge?
x402 assumes anonymous strangers who pay per call. Real sellers also have wallets they know: a partner granted free calls, a subscriber who prepaid, a buyer whose earlier purchase entitles them to a re-download. Without SIWX, the seller must either mint API keys, which defeats the point of wallet-native commerce, or make the entitled wallet pay again. With SIWX the 402 itself says "if you are a wallet we know, here is how to prove it". The agent either pays or signs; both are one round trip from the challenge.
The seller's statement is deliberately neutral: it does not promise a grant. Whether a proven wallet is entitled to anything is a separate decision, made from an explicit grants record owned by the deployment. The engine refuses to construct the market with sign-in enabled unless that record is supplied, and disabling sign-in neither reads nor requires it.
How does the seller issue a challenge without storing it?
A common login design stores each nonce, then looks it up on return. That puts a database in the hot path of every unpaid request. The engine instead derives the nonce:
function nonceFor(secret, issuedAt, uri) {
return createHmac("sha256", secret)
.update(`sign-in-with-x\n${issuedAt}\n${uri}`)
.digest("hex").slice(0, 32);
}(From sign-in-with-x.mjs in the Red Cup x402 engine.) The nonce is a keyed hash of the issue time and the resource URI under a deployment secret of at least 16 characters. When a proof comes back, the seller recomputes the HMAC from the issuedAt and uri in the proof and compares with timingSafeEqual. If they match, the seller knows it issued a challenge for exactly this resource at exactly this time, without ever having recorded it. No session table, no cache, no cleanup job.
Two further decisions harden the scheme:
- The origin comes from configuration, not the request. The resource URI is rebased onto the configured origin, because "the Host header is the client's to set, the origin is the deployment's". A client cannot make the seller sign off on
https://evil.example/by lying about Host. - The window is five minutes and only for the named resource. A proof is usable for the challenge's five-minute window, and only for the URI it names. A proof harvested from one route cannot be replayed on another.
What exactly does the verifier check?
verifySignIn runs a fixed sequence and returns either { verified: true, account, address } or { verified: false, reason }. The refusal reasons double as documentation:
| Check | Refusal reason |
|---|---|
domain equals the configured origin's host | domain-mismatch |
uri equals the request's resource rebased on the origin | uri-mismatch |
version is "1" and chainId matches eip155:<n> | unsupported-chain-or-version |
address is a 20-byte hex address | malformed-address |
issuedAt is no more than 30 s in the future and within the five-minute window | outside-window |
expirationTime, if present, is in the future | expired |
notBefore, if present, has passed | not-yet-valid |
| the HMAC nonce matches | unknown-challenge |
| the signature recovers to the address | bad-signature |
Only after all these does the verifier build the EIP-4361 text byte for byte as the siwe library writes it, hash it with the EIP-191 personal-message prefix, and recover the signer address from the 65-byte signature. The account identity returned is the CAIP-10 style string eip155:8453:0xChecksummedAddress.
What about smart-contract wallets?
Many agent wallets are contract accounts, which cannot produce a plain secp256k1 signature. For those, EIP-1271 defines an on-chain isValidSignature check. The engine does not hard-wire a chain client. If the recovered signer does not equal the address, the verifier calls an injected verifyContractSignature({ chainId, address, digest, signature }) function if one was supplied, and treats a thrown error as failure. The chain read is therefore the deployment's choice of RPC provider, budget and timeout, not something buried in the library.
The cryptography itself (keccak-256, secp256k1 recovery, checksum addressing) comes from the Group's own data-layer package and is exercised by the engine's tests; the verifier does not call out to a third-party service.
How do I try it?
You can see the challenge without any wallet:
curl -s -i -X POST https://market.561.group/v1/data/json-pointer \
-H 'content-type: application/json' \
-d '{"value":1,"pointer":""}' \
| grep -i '^payment-required' | cut -d' ' -f2 | tr -d '\r' | base64 -d \
| jq '.extensions["sign-in-with-x"].info'To answer it, a client would format the message from info with its own address, sign it with personal_sign, and send SIGN-IN-WITH-X: base64(JSON) containing domain, address, uri, version, chainId, type, nonce, issuedAt and signature. The engine's encodeSignInHeader and decodeSignInHeader do the base64 JSON step and enforce an 8,192-character limit on the header.
What are the limits?
SIWX proves control of an address; it does not say the address is entitled. On this seller, a verified wallet is only as privileged as the grants record, and we do not publish that record. We probed four routes, not all 130, and make no public claim here about how many wallets hold grants. A five-minute proof is replayable inside its window by anyone who sees it, which is why it is scoped to a single resource and carried in a header over TLS; a deployment that needs single-use proofs must add its own nonce store. And EVM only: the verifier rejects non-eip155 chains.
For the other seller policies around the 402, see what an x402 seller should do beyond the spec.