← Blog
September 4, 2026 · 14 min

Sign-In-With-X: what paying once for an x402 route buys

Every x402 post starts with the same premise: the agent pays for each call. Sign-In-With-X is the extension that quietly suspends it, and almost nobody has read what the suspension covers.

x402 is a per-request protocol. A client asks, the server answers 402, the client signs a payment, the server serves. Repeat forever. That model is clean, and it is also expensive when an agent hits the same endpoint two hundred times in an hour.

The Sign-In-With-X extension — SIWX — is the escape hatch. A wallet that has already paid for a resource signs a message instead of a payment, and the server lets it back in for free. The extension specification describes it in one line: clients "prove control of a wallet address by signing a challenge message, allowing servers to identify returning users and skip payment for addresses that have previously paid."

That sentence contains a lot of unspecified surface. Which resource, exactly. For how long. How many times. What happens if the signed proof is copied out of a log. We cloned the repository, read the spec, read all three reference implementations, and reproduced the behaviour that decides those questions.

What we audited, and at which commit

The protocol's repository now lives under the Linux Foundation organisation as x402-foundation/x402 — 6,572 stars at the time of writing. We audited commit 2cc7e9a, dated 2026-09-04, whose subject line is chore(go): release (#3359). The versions in that tree are @x402/extensions 2.25.0, the Python package x402 2.22.0, and the Go module tagged go/v2.9.0.

SIWX is one of seven entries in the official extension registry, alongside Bazaar, Builder Code, two gas-sponsoring extensions, Payment Identifier and Signed Offers & Receipts. It is one of only four with implementations in all three SDKs, which is why cross-language divergence in it is worth a post. We surveyed the whole registry in the extensions layer audit; this is the drill-down into the one extension that changes the economics of a paid route.

Its history is short and well documented in the tree. The TypeScript hook adapters landed on 2026-05-15 (#2304), the Python SDK got the extension on 2026-05-21 (#2393), Go on 2026-06-20 (#2485), and both non-TypeScript ports were rewritten on 2026-08-18 (#3192 and #3193). Three security fixes are visible in the log: #2859 on 2026-07-15 bound domain validation to a configured origin instead of the Host header, #2933 on 2026-07-23 fixed Solana Ed25519 verification against small-order points, and #3133 on 2026-08-13 made the client refuse to sign a challenge that does not match the origin of the response that produced it.

The mechanism in one pass

SIWX is a Server-to-Client extension. The facilitator is not involved at any point, which already tells you something: nothing about this flow touches a chain, and nothing about it is settled. It is authentication bolted onto a payment protocol.

The server advertises support inside the 402 body, under the sign-in-with-x key of the extensions object. The challenge carries the standard CAIP-122 fields — domain, uri, version, nonce, issuedAt, optional expirationTime, notBefore, statement, resources — plus a supportedChains array telling the client which signature types the server will accept.

// 402 response body, abridged
{
  "x402Version": "2",
  "accepts": [ /* payment requirements */ ],
  "extensions": {
    "sign-in-with-x": {
      "info": {
        "domain": "api.example.com",
        "uri": "https://api.example.com/quote",
        "nonce": "a1b2c3d4e5f67890a1b2c3d4e5f67890",
        "issuedAt": "2026-09-04T10:30:00.000Z",
        "expirationTime": "2026-09-04T10:35:00.000Z"
      },
      "supportedChains": [
        { "chainId": "eip155:8453", "type": "eip191" }
      ]
    }
  }
}

The client picks the first chain matching its signer, builds an EIP-4361 message for EVM wallets or a Sign-In-With-Solana message for Solana, signs it, and returns the whole payload plus address and signature as base64 JSON in a SIGN-IN-WITH-X header. EVM verification supports EOAs through ECDSA recovery and smart accounts through EIP-1271 and EIP-6492, which means the extension works for smart-account wallets as well as plain EOAs — at the cost of an RPC call per verification.

Server side, verification is four steps: parse the header, validate the message fields, verify the signature, and then — step four, quoted verbatim from the spec — "the Server checks whether the recovered address has previously paid for the requested resource. This is application-specific logic."

That last sentence is where the entire security model of a paid route ends up. The spec stops. The SDKs do not, and what they ship is the real contract.

The challenge is not a challenge

Read the field list again and you would assume a classic challenge-response: the server mints a nonce, remembers it, and only accepts a signature carrying that nonce back. That is not what happens.

The server generates a fresh nonce on every 402 — in Go, sixteen random bytes hex-encoded — and then forgets it. There is no issued-nonce store anywhere in the tree. Worse for the challenge framing, the Go extension explicitly declares which fields the client is allowed to change:

func (e *ServerExtension) DynamicInfoFields() []string {
    return []string{"nonce", "issuedAt", "expirationTime"}
}

Those three fields are exempt from the echo validation that otherwise forces a client to return the server's own values. So the client may mint its own nonce and its own timestamp. Validation, in all three languages, reduces to: the domain equals the configured origin host, the uri origin equals the configured origin, issuedAt is not in the future and is younger than five minutes, and any expirationTime or notBefore present is coherent.

The nonce is only checked for reuse, and only when the storage backend opts in. The spec is explicit about the strength of that requirement: "Nonce: MUST be unique. Server SHOULD track used nonces to prevent replay attacks." A SHOULD, not a MUST.

What a SIWX proof actually is — not a response to a server challenge, but a self-issued, self-timestamped bearer token, signed by the wallet, scoped to an origin, and valid for five minutes by default. Anyone holding a copy of that header can present it. The signature proves the wallet consented once; it does not prove the presenter is the wallet.

That framing matters for agents specifically. Agents log HTTP headers. Agents route through proxies and gateways. Agents hand traces to observability pipelines. A payment signature is single-use by construction — the chain rejects the second one. A SIWX header is not, unless the server chose to make it so.

Three in-memory stores, three replay postures

Every SDK defines a storage interface with two required methods and two optional ones. The required pair records and queries who paid. The optional pair records and queries used nonces. The TypeScript documentation states the rule plainly: "Both methods must be implemented together — implementing only one will throw an error at startup."

Each SDK also ships an in-memory implementation, and all three example servers in the repository use it. Here the three ports diverge.

Go implements it. InMemoryStorage carries a nonces map[string]struct{} alongside the paid set, and satisfies the optional NonceStorage interface. The server does a runtime type assertion, finds it, and checks every incoming nonce. Replay protection is on by default.

TypeScript does not. InMemorySIWxStorage holds exactly one field, paidAddresses. No hasUsedNonce, no recordNonce. The hook checks for the method, does not find it, and skips the check. The canonical example in the package README — the copy-paste path for anyone adopting the extension — instantiates that class. Replay protection is off by default.

Python is a third case, and it is the interesting one.

The Python guard checks a method that does not exist

Python declares the storage contract as a Protocol with four methods: has_paid, record_payment, has_used_nonce, record_nonce. The request hook then enforces the all-or-nothing rule at construction time:

has_used_nonce = callable(getattr(storage, "has_used_nonce", None))
has_record_nonce = callable(getattr(storage, "has_record_nonce", None))
if has_used_nonce != has_record_nonce:
    raise ValueError(
        "SIWxStorage nonce tracking requires both has_used_nonce and record_nonce "
        "to be implemented"
    )

The second line probes has_record_nonce. The method defined in the Protocol, documented in the extension reference and called forty lines later, is record_nonce. No storage class will ever carry an attribute by the probed name, so has_record_nonce is permanently False.

The consequences invert the guard. Implement the documented interface correctly — both has_used_nonce and record_nonce — and the comparison becomes True != False, so the hook refuses to build and your server does not start. Implement neither, and the hook builds cleanly with replay tracking silently disabled. The only configuration that boots is the one without the defence.

We reproduced it against the published package. Install x402 2.22.0, hand the request hook a storage class implementing the Protocol exactly, then hand it the shipped in-memory class:

# x402 version: 2.22.0
[Protocol-conformant storage] RAISED ValueError: SIWxStorage nonce tracking
  requires both has_used_nonce and record_nonce to be implemented
[shipped InMemorySIWxStorage] hook created OK -> replay tracking active? False

There is a way through — define a method literally named has_record_nonce next to the real one, so both probes return true — but nothing in the documentation would lead anyone there. The realistic outcome is that a Python operator hits the ValueError, reads it as "this storage is wrong", removes the nonce methods to make the error go away, and ships without replay protection while believing the opposite.

The typo has been in the tree since the extension first landed in the Python SDK on 2026-05-21, and survived the full rewrite of 2026-08-18. It survives because no test exercises it: the Python unit suite for SIWX contains no reference to record_nonce or has_used_nonce at all. Go's server tests assert on HasUsedNonce directly, which is precisely why Go is the port that works.

Entitlement is a path, and it never expires

Step four of the spec — "application-specific logic" — is implemented identically in all three SDKs, and the implementation is a set membership test.

On successful settlement, the settle hook takes the resource URL from the payment payload, reduces it to URL.pathname, and records the pair. On a later request carrying a valid SIWX header, the request hook asks whether the recovered payer is in the set for context.path. Both Python HTTP adapters resolve that to the bare path: Flask returns request.path, FastAPI returns request.url.path. Neither includes the query string.

We ran the real settle hook against a synthetic settlement to see what key it writes:

# resource url paid for:
#   https://api.example.com/quote?symbol=BTC&depth=50

stored keys: {'/quote': {'0xabc...0001'}}

Three properties follow, and none of them is documented in the extension reference.

The grant covers the path, not the request. Paying for /quote?symbol=BTC&depth=50 stores /quote, and every later request to /quote with any query string resolves to the same key. For a REST API where the query string selects the expensive part of the work — the symbol, the date range, the model, the token budget — one purchase of the cheapest variant buys every variant.

The grant has no cardinality. It is a set, not a counter. One settlement equals unlimited subsequent requests. There is no notion of remaining calls anywhere in the storage interface.

The grant has no expiry. There is no timestamp on the record and no eviction path in any of the three in-memory implementations. A wallet that paid once in May can still sign in today. If you want a subscription window, you write it yourself, in a storage backend you supply.

// The gap in one line

x402 prices a request; SIWX grants a path

The payment protocol is careful about the unit of value: a scheme, an amount, an asset, a resource. The moment SIWX records that payment, all of that collapses into a string and an address in a set.

The mismatch is not a bug in the SDKs — they implement what the spec delegates. It is a design boundary that every seller inherits, and most sellers will inherit it by copying the example.

What the extension gets right

An audit that only lists gaps is a bad audit. SIWX does several things well, and two of them are things comparable schemes get wrong.

Domain binding is handled properly and was hardened deliberately. The spec requires the server to validate domain and the uri origin against its own configured public origin, "not against request-derived values such as the Host header." All three SDKs refuse to construct the hook without an explicit origin, and reject origins carrying credentials, a path, a query or a fragment. Commit #2859 made that change in July after the earlier header-derived behaviour; the same discipline appears in the reverse direction in #3133, where the client refuses to sign a challenge whose origin does not match the response that produced it. Both directions of the cross-site replay are closed.

Chain-agnosticism is real rather than nominal. The same header carries EIP-191 signatures for EVM chains and Ed25519 signatures for Solana, dispatched on the CAIP-2 namespace, so a seller does not maintain two auth paths.

And the temporal bounds are enforced consistently: a five-minute default maximum age on issuedAt, rejection of future timestamps, and honouring of expirationTime and notBefore when present, with a machine-readable failure code for each check. Compared to the average bearer token, a five-minute self-issued proof with an explicit failure taxonomy is a real improvement.

What is missing is not cryptography. It is the accounting layer above it.

What it means for LLM4Agents

We run an OpenAI-compatible gateway where agents pay per call in stablecoins. Every LLM4Agents route is metered, and the meter is the product. That makes SIWX a shape we cannot adopt as-is, and a shape we have to be able to talk to.

Adopting it verbatim on the seller side would be an outage of the business model. Our unit of value is tokens consumed, not endpoints touched. A path-keyed permanent grant applied to /v1/chat/completions means the first payment buys unlimited inference forever. The upto scheme exists precisely because the cost of a call is unknown until it finishes — we covered that in the metered billing audit — and SIWX collapses the whole variable-cost story back into a boolean.

There is a version we can adopt, and it is narrow. SIWX is a good fit for artefacts, not for compute: a completed evaluation report, a cached dataset, a generated artefact that a buyer already paid for and may fetch again. Re-fetching a stored result should not cost a second payment. That is exactly what the extension was designed for, and it is where we would use it.

On the buyer side, the calculus is different and mostly favourable. When our agents pay other people's x402 endpoints, a seller offering SIWX means one payment instead of two hundred, and our client already holds the signing key. The cost is that the agent starts emitting a reusable credential into every request. Our threat model for that is the one we described in the agent threat model: any long-lived header an agent carries is a header an agent can leak, through a prompt-injected tool call, a verbose log, or a compromised MCP server in the chain.

The strategic read is that SIWX is the point where x402 grows a session, and sessions are where payment protocols turn back into ordinary web auth. We drew that boundary in bearer versus walk-up: bearer credentials are correct for known counterparties with an account, and per-request payment is correct for strangers. SIWX is the migration path between them, executed inside the payment protocol instead of beside it. Any gateway that intends to sit between agents and sellers has to model both states, and know which one each call is in.

Staying on the frontier

Concretely, in order.

First, ship the Python fix upstream. The has_record_nonce probe is a one-word correction plus the test that would have caught it — a storage double implementing the documented Protocol, asserting that the hook builds and that a replayed nonce is rejected. It is the smallest possible contribution with a real security effect, and it puts our name on the extension we depend on.

Second, treat any SIWX grant we issue as a lease, never a set membership. Our storage implementation records the payer, the resource, an expiry, and a remaining-call counter, and denies on any of the three. The interface the SDKs define is a Protocol with two required methods; nothing stops the implementation behind it from being a real entitlement table. Default lease: minutes, not months.

Third, key entitlements on the full request identity, not URL.pathname. For a gateway, the correct key includes the model and the parameters that move the price. Where a canonical form is awkward, hash the normalised request and key on that. Path-only keying is acceptable for a static file; it is not acceptable in front of inference.

Fourth, on the buyer side, treat the SIWX header as a secret with the same handling as a private key: never logged, never traced, never forwarded outside the origin it was minted for, and re-minted per request rather than cached. Five minutes of validity is short enough to be safe only if the header does not sit in a trace store for a week.

Fifth, pair SIWX with the payment-identifier extension anywhere we accept it. Idempotency identifiers and used-nonce tracking are the same defence pointed at two different replay surfaces, and adopting one without the other leaves the obvious half open. The extension registry is small enough to adopt coherently rather than piecemeal — the census in the extensions audit lists all seven.

Sixth, and beyond our own deployment: push for the spec to say something normative about the grant. Step four currently delegates the entire entitlement model to "application-specific logic", and three SDKs independently implemented the weakest reasonable version of it. A single paragraph — grants SHOULD carry an expiry, and SHOULD be keyed on the resource identity used to price the payment — would move the default for everyone who copies the example, which is everyone.

Per-call payment, no permanent grants

An OpenAI-compatible gateway where every call is metered, priced and settled in stablecoins — and where a session never becomes a blank cheque.

Register your agent