Back the guard with a Durable Object so replay protection holds across isolates.
The default store, createMemoryNonceStore, protects a single process. On Workers,
Vercel, or any autoscaled fleet each isolate holds its own in-memory Map, so two
isolates each reserve the same nonce independently and both grant. The reservation has
to live in one place that every isolate agrees on, updated with a genuine atomic
compare-and-set. On Cloudflare, a Durable Object is that place.
The store is a contract, not a specific product. Any backend with a genuine atomic
compare-and-set can implement NonceStore: a Durable Object, Redis SET NX, or a
database unique constraint. A plain get-then-put store (Workers KV, S3) cannot, because
with no atomic write the read-to-set gap reopens the race condition. This guide is one backend,
the one we ship, built start to finish on Cloudflare. The same pattern applies to any
of the others: an atomic reserve, a fenced release, and an eviction path.
How it works
Section titled “How it works”The adapter routes every nonce to its own Durable Object (idFromName(nonce)). A
Durable Object serves one request at a time and holds delivery of other events while a
storage operation to the same object is in flight, so the reserve check-and-set runs
atomically with no lock. That is the compare-and-set the store contract requires, and
it is why concurrent reservations of one nonce resolve to exactly one grant.
Bind the Durable Object
Section titled “Bind the Durable Object”In wrangler.jsonc, bind the class and add a migration for it:
{ "durable_objects": { "bindings": [{ "name": "NONCE_DO", "class_name": "NonceReservationDO" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["NonceReservationDO"] }]}Wire it into the Worker
Section titled “Wire it into the Worker”Export the Durable Object class from your Worker entry, then build the store from the
binding and hand it to createGuard:
import { createGuard, protect, paidResponseCacheDirectives } from "@craigruks/x402-server-guard";import { createDurableObjectNonceStore, NonceReservationDO,} from "@craigruks/x402-server-guard/cloudflare";
export { NonceReservationDO };
interface Env { // Type the binding with the class so the reserve/release RPC methods are visible. NONCE_DO: DurableObjectNamespace<NonceReservationDO>;}
export default { async fetch(request: Request, env: Env): Promise<Response> { const guard = createGuard({ store: createDurableObjectNonceStore(env.NONCE_DO) });
// Verify with your facilitator first, then protect the route exactly as in the // Quickstart. The only change from the default is the store passed to createGuard. // ... return new Response("see the Quickstart for the full protect() flow"); },};The @craigruks/x402-server-guard/cloudflare subpath is compiled against workers types
and is the only entry that imports cloudflare:workers. The core entry stays free of
Worker globals, so importing the main package from Node is unaffected. A transport
failure (the object is unreachable) becomes a store-unavailable deny, so the guard
still fails closed.
Honest limits
Section titled “Honest limits”- Type the binding with the class.
DurableObjectNamespace<NonceReservationDO>, not a bareDurableObjectNamespace. Without the generic the stub has noreserve/releasemethods and the adapter will not typecheck against it. - Storage is not capped the way the memory store is. The in-memory store enforces a
hard
maxEntriesbackpressure limit; per-nonce Durable Objects cannot share one counter (that would reintroduce the global bottleneck this design removes), so there is no equivalent cap here. Each reserved nonce holds one small entry until itsvalidBefore, when a Durable Object alarm reclaims it. A payer who signs many authorizations with a far-futurevalidBeforecan accumulate objects until that expiry. The real bound is rate limiting upstream of the reserve (per payer or per IP); the payer controlsvalidBefore, so a short window is not something you can rely on. Reservation runs after the facilitator verifies the signature, so only authenticated payers reach it, not anonymous traffic. - Requires a recent compatibility date. The adapter uses Durable Object RPC methods
and SQLite-backed storage; set a
compatibility_datenew enough to support them. - One nonce per object is deliberate. It gives per-nonce atomicity without a global lock. It also means the store is keyed on the nonce alone, so scope the nonce per (chain, asset) yourself if one deployment serves several, rather than sharing one namespace.