Skip to content

Hardening rationale and sources

Why each mitigation works, with sources.

Why the guard is shaped the way it is, and where each decision comes from. One section per mitigation: the race and replay reservation and the hardening applied to it, cross-resource substitution, grant-before-finality, cache leakage, and how protect composes them.

The claims about this repository’s own code are verifiable by running the tests (npm run test:coverage). Claims about external projects link to the source that was read; where a specific commit was pinned it is named, since those files move.

The attack classes come from two papers on x402 resource servers. The paper that first names each class:

  • Grant-before-finality, payment replay, the duplicate-settlement race, and cache leakage of paid content: Zelin Li, Qin Wang, Zhipeng Wang, “Five Attacks on x402 Agentic Payment Protocol” (arXiv:2605.11781), Attacks I-A, II, and III.
  • Cross-resource substitution, and the duplicate-settlement race again: Shengchen Ling et al., “Free-Riding the Agentic Web: A Systematic Security Analysis of x402 Payments” (arXiv:2605.30998), its Context Binding (I3) and Authorization Uniqueness (I4) invariants.

The race and substitution appear in both; the two we mitigate that appear in only one paper (finality and cache) both come from Five Attacks, not Free-Riding, which does not analyze either.

The reference these attacks apply to is Coinbase’s official coinbase/x402 TypeScript resource server. As surveyed at main commit dd927a2:

  • Replay defense is on-chain only. The EVM facilitator reads authorizationState(from, nonce) at verify and parses the transfer revert at settle (the exact EIP-3009 mechanism under typescript/). Nothing reserves the nonce off-chain between verify and settle, so N concurrent requests carrying one authorization all pass verify before the chain serializes them: a time-of-check/time-of-use race.
  • The signed authorization binds {from, to, value, validAfter, validBefore, nonce} and the token contract (via the EIP-712 domain), but not the resource. A signature valid for route A is valid for route B at the same price and payTo.
  • No adapter sets Cache-Control or Vary on the 402 or the paid 200.

The guard reserves a payment’s nonce before granting. The first request for a nonce wins; a replay or a concurrent race is denied. This closes the race by making the check and the reservation a single atomic step, so there is no window between “is this nonce free?” and “take it”.

Replay protection is keyed on the EIP-3009 nonce, never on the signature bytes. Established off-chain-signature systems do the same, because the nonce is the identity the signature authorizes, not the signature encoding:

  • Uniswap permit2 tracks single-use nonces in an on-chain bitmap keyed by (owner, nonce), consumed with an atomic flip-and-check, never by signature (SignatureTransfer.sol, _useUnorderedNonce).
  • CoW Protocol dedupes signed orders by a content-derived order UID using a database unique constraint, not the signature (orders.rs).

Keying on the nonce is also what makes replay protection immune to signature malleability. An ECDSA signature (r, s, v) has a second form (r, N-s, v^1) that recovers the same signer but is a different byte string. A store keyed on signature bytes would see two distinct entries and let the malleated twin through. Keyed on the signed nonce, both forms carry the same nonce and collide. This matters because common EIP-712 verifiers do not reject the high-s form: @metamask/eth-sig-util’s recoverTypedSignature validates the recovery id but not low-s, leaving that check to the caller. We sidestep the question by never keying on the signature.

This immunity is joint with signature verification. The guard is keyed on the nonce the facilitator authenticated, because verify runs before reserve; a caller that reserves without first verifying is keyed on an unauthenticated, attacker-chosen nonce. And a distinct nonce is a distinct authorization that settles its own on-chain transfer: paying again with a fresh nonce is paying twice, not a double-spend, and is correctly out of scope.

The in-memory store is atomic because a synchronous JavaScript body runs to completion. A store shared across serverless isolates must use a native atomic compare-and-set: a Durable Object, Redis SET ... NX, or a database unique constraint (permit2 and CoW both rely on exactly this kind of atomic write). Plain get-then-put stores (Cloudflare Workers KV, S3) are not sufficient: with no compare-and-set, an await sits in the check-to-set gap and reopens the race condition. A Cloudflare Durable Object adapter ships in the box (createDurableObjectNonceStore on the /cloudflare subpath), which routes each nonce to its own object and gets the atomic check-and-set from Durable Object input gating; other backends (Redis, a database constraint) implement the same NonceStore contract. The compare-and-set closes the double-spend race on its own. Refusing an already-expired authorization atomically with it needs a Lua script or a constraint that encodes the expiry, not bare SET NX; treating the expiry as a separate predicate before the CAS is acceptable, since it only races now crossing a fixed validBefore and the on-chain check is the backstop.

Two items from cross-referencing the guard against permit2, CoW, MetaMask eth-sig-util, and Hyperliquid’s signing SDK.

reserve refuses an authorization whose window has already closed (expiresAt <= now) and returns an expired outcome, which the guard maps to a fail-closed deny. Only the closing edge is checked here; the opening edge (validAfter) is the facilitator’s job, and the facilitator re-checks both edges anyway. CoW enforces order expiry as a read-time predicate against a trusted clock at the moment of use (orders.rs), and Hyperliquid binds an absolute expiry into the signed action (signing.py). In the in-memory store the check shares the reservation’s atomic tick; on a distributed store it may be a separate predicate before the compare-and-set (see above). This covers a caller that invokes reserve directly; the wired flow’s facilitator verifies the window too.

reserve returns a Result, so a store failure is a value. But a distributed adapter (Redis, a Durable Object) rejects its promise on an I/O failure rather than returning one, so the guard wraps the store call in tryCatchAsync: a thrown or rejected store becomes a store-unavailable deny too, not an uncaught rejection. An unavailable store denies; it never grants. This keeps the fail-closed guarantee from being delegated, unenforced, to every adapter author.

The store evicts expired reservations, but expiresAt is the attacker-signed validBefore, so the sweep alone does not bound memory: a flood of far-future authorizations is retained. A hard maxEntries cap makes a fresh reservation past the ceiling fail closed, rather than growing without bound or evicting a live entry (which would reopen the race condition). Peak retention is roughly min(maxEntries, request_rate * validBefore_horizon); there is no claim of an unconditional bound.

The nonce and the resource are map keys, so two encodings of one value would be two keys for one payment: the string-encoding cousin of signature malleability. The guard folds both to a canonical form before keying (createGuard, see src/canonical.ts). The nonce is lowercased and a leading 0x dropped, so 0xABCD, 0xabcd, and abcd collide; folding hex case cannot merge two distinct nonces (the same bytes either way). The resource, when a URL, is reduced to its WHATWG canonical form (scheme and host case-folded, default port dropped) with the case-sensitive path, query, and fragment intact, so distinct resources are never merged. A case-sensitive composed nonce scope can opt out with GuardOptions.canonicalizeNonce.

Source: arXiv:2605.30998, Context Binding (I3); also arXiv:2605.11781 (binding weakness).

An EIP-3009 authorization signs {from, to, value, validAfter, validBefore, nonce} and, via the EIP-712 domain, the token and chain. It does not sign the resource, and PaymentPayload.resource is unsigned client metadata. So two resources behind one payTo at the same price are indistinguishable at the payment layer: a payment can be redeemed at a resource the payer did not intend. The reference server matches only {scheme, network, asset, amount, payTo} (surveyed above), so it has no binding to fall back on.

The guard binds the nonce to the resource it is first reserved for, and denies the same nonce at a different resource with a distinct nonce-resource-mismatch (not the generic nonce-already-reserved). Because the binding happens at reserve, before settle, it catches the substitution in the window the on-chain nonce is not yet consumed, where the facilitator’s post-settle nonce check cannot help. The resource is compared as a canonical key; the guard folds URL scheme and host case by default (see “Folding key encodings” above), so normalize only trailing slashes and query order yourself.

Honest limits, stated so the mitigation is not oversold:

  • First-seen binding cannot know a payment’s intended resource (nothing signed says so). A payment’s very first use at the “wrong” resource still binds and grants there. It stops the same nonce being spent across two resources, and flags the attempt; it does not divine intent.
  • It does not stop a payer front-running their own nonce onto a costlier route. Real intent-binding needs the resource inside what the facilitator verifies, which the exact scheme’s fixed six-field signature cannot carry.
  • A different price or payTo is already caught by the facilitator’s parameter matching; the guard covers the equal-price, same-payTo case it cannot.

Source: arXiv:2605.11781, Attack I-A (revert-grant under optimistic execution).

A facilitator’s settle reporting success means the payment landed in a block, not that it is final. Until it is buried under enough confirmations a chain reorg can drop it and revert the payment, so a server that grants at zero confirmations can deliver a resource against a payment that never sticks.

The guard does not watch the chain; the merchant’s server (or the adapter) holds the grant until the settlement reaches k confirmations, where k is a per-chain setting (an L2 sequencer’s finality differs from PoW/PoS k-confirmations). What the guard adds is the primitive that makes the hold safe to abandon: reserve returns a handle with release, so if the settlement fails or is reorged before finality the server frees the nonce and the payer can retry the same authorization instead of being locked out until the window closes.

release is fenced by a token held inside the handle: only the reserver can free its own hold, so it is not a griefing primitive an attacker can aim at another payer’s in-flight reservation. Not calling release is safe: the reservation simply expires with the authorization. Freeing a hold whose settlement did not stick does not reopen the race condition, because the payment was never granted; releasing a successful reservation is what would, and the flow never does that.

On a single-sequencer L2 like Base (x402’s usual home) reorgs are rare and hard to force; elsewhere the risk is higher. The mitigation is the discipline of holding until finality plus the release-on-failure retry path, not a claim that reorgs are impossible.

Source: arXiv:2605.11781, Attack III (HTTP / proxy-level handling: a paid response cached by an intermediary and served to unpaid clients).

A shared cache (CDN or reverse proxy) in front of the resource server is keyed on the request URL and knows nothing about payment. If a paid 200 is cacheable, the cache stores it and serves it to the next caller for that URL, paid or not: the content leaks for free. The reference x402 adapters set no cache directive on the paid response (surveyed above: no Cache-Control, no Vary), so a shared cache is free to store it.

Unlike the other three, this is not a decision about a nonce; it is a response directive. paidResponseCacheDirectives() returns Cache-Control: no-store, private and a Vary on the payment header, which the server or adapter attaches to every paid response. no-store is load-bearing: any HTTP-conformant cache refuses to store it. private and Vary are defense in depth for a cache that stores anyway. The framework binding applies these headers; the protect helper returns them on a granted decision so the caller does not have to remember to.

Cache leakage has several mitigations. The guard ships the one that is correct with no configuration and no infrastructure, and that fails safe if the merchant does nothing else. The alternatives, and why each is the merchant’s choice to add rather than the guard’s default:

  • Capability URLs. Serve the content at an unguessable path (/download/{token}, as S3 presigned URLs and “anyone with the link” document shares do). The response is cacheable, even publicly, because the cache is keyed on the token and only ever serves a holder of it. This is the right call for large, static, identical-for-every -payer content behind a CDN. The cost is a different security model: the URL becomes a bearer credential, so it leaks through Referer headers, logs, and browser history; the token must be high-entropy to resist enumeration; a cached capability cannot be revoked before its TTL; and it depends on the cache not normalizing the token away (a canonical-key hazard specific to the capability-URL scheme).
  • Signed URLs with an expiry (CloudFront or S3 signed URLs): capability URLs plus a time bound and a signature, so they expire and cannot be forged. Same bearer trade-off, bounded in time.
  • Per-user cache partitioning (Vary on an auth token, or a per-user cache key): cache, but never across users. For personalized paid content.
  • Encrypt and cache: cache the ciphertext freely and hand the decryption key to the payer. The key is the gate.

We default to no-store, private because it is correct for the common case (content served directly at a stable, per-request-verified URL) with zero merchant effort, and a merchant who does nothing else still does not leak. The alternatives are layers a merchant adds deliberately, and they compose with the guard rather than replacing it: run the payment through the guard at the paid route and keep that response no-store, then hand back a cacheable capability or signed URL for delivery. The cache concern moves to that URL, where unguessability (and an expiry) replaces no-store.

The four mitigations compose in one framework-agnostic call, protect, which runs the safe order reserve -> settle -> (confirm) -> deliver (the confirm step runs only under finality: "confirm") and returns the cache directives on grant, releasing the reservation if the settle fails or finality is not reached. It has no runtime dependencies and takes plain callbacks, so a Hono, Express, or @x402/core-hook binding is a thin wrapper over it. The binding lives at the HTTP layer because the served resource (the request URL) is only available there, and binding the nonce to the served route, not the unsigned resource the payer claims, is what makes the substitution mitigation sound. See examples/hono-server.ts.

  • permit2’s nonce bitmap packs many nonces into one storage word because its nonce is a structured uint256. The EIP-3009 nonce is 32 random bytes, so the bitmap layout does not transfer. The principle (one atomic conditional write) does, and is what we use.
  • Independent signature verification (and the low-s malleability check it would need) lives one layer down from the guard, by design: it needs cryptographic primitives, so it stays out of the core path and the core keeps zero runtime dependencies. The facilitator verifies payments; the guard hardens the flow around that.

Unauthorized settlement preemption (Attack I-B) is out of scope

Section titled “Unauthorized settlement preemption (Attack I-B) is out of scope”

[Five Attacks] splits its settlement-path attack in two. Attack I-A (revert-grant under optimistic execution) is what grant-before-finality closes. Attack I-B, unauthorized settlement preemption, is not something a resource-server library can prevent, and this section says why plainly rather than leave the omission unexplained.

In I-B an attacker on the request path (logging middleware, a TLS terminator, an API gateway) or a Byzantine server observes the X-PAYMENT header, then submits the same signed transferWithAuthorization to the chain first, for a few cents of L2 gas. The funds still reach the merchant, because EIP-3009 binds the to address, but the nonce is now consumed by the attacker’s transaction, so the honest facilitator’s settlement fails with nonce-already-used. The payer is charged and the resource is denied. The victim is the payer, not the merchant.

The root cause is that EIP-3009 puts no caller restriction on settlement: any observer can submit a signed authorization. The fix the paper prescribes (M2, facilitator-bound settlement) is a change at the settlement layer, not the resource server: a Permit2 Witness carrying a facilitator field enforced as msg.sender == witness.facilitator, or an EIP-3009 wrapper contract that checks the caller before it transfers. That is on-chain contract code and facilitator control. This library is off-chain middleware with zero runtime dependencies; it deploys no contracts and does not own the settlement call, so M2 lives one to two layers below it and cannot be implemented here.

What the guard does relative to I-B is a byproduct of failing closed, not a fix: it settles before granting, so when the honest settlement fails the merchant denies rather than delivering. That stops a preemption from becoming a free grant, but it cannot un-charge the payer, whose loss already happened on-chain.

The one control the operator does hold: treat the X-PAYMENT header as bearer payment material. Do not log it, terminate TLS at the application, and keep it off untrusted middleware and gateways. That removes the request-path observer the attack depends on.

Server-selection (Attack IV) is out of scope

Section titled “Server-selection (Attack IV) is out of scope”

[Five Attacks] Attack IV (server-selection, including Sybil) happens at endpoint discovery, before the payment protocol begins: an attacker influences which x402 endpoint a client picks, steering traffic to a server it controls or floods. The guard runs inside an endpoint the client has already chosen; it sees a request, not the discovery that routed the request to it, so it has no state on which to act. The defense belongs to the discovery layer (the service registry, DNS, and the client’s own trust and selection policy), not to resource-server middleware. It is named here only so the scope boundary is complete, not selective.