·8 min read·by more.md
A practical walk-through of verifying x402 payment receipts. The gotchas. The 50 lines that matter. The chargeback that taught us to fail closed.
x402 receipts: the 50-line verification flow that stopped chargebacks (for a day)
x402 is great. The receipt verification path on the seller side is short. That brevity is also the trap: every line that looks optional is the line that bites you in production.
This post is the version of the verifier we wish we had shipped on day 1.
The 50 lines
import { verifyJWS, parseJWS } from '@more-md/x402';
export async function verifyReceipt({
receipt,
expectedResource,
expectedAmount,
expectedCurrency,
trustedKeyset,
ledger,
now = Date.now(),
}: VerifyArgs): Promise<VerifyResult> {
const parsed = parseJWS(receipt);
if (!parsed) return fail('malformed_receipt');
const { header, payload } = parsed;
if (header.alg !== 'EdDSA') return fail('unsupported_alg');
const sigOk = await verifyJWS(receipt, trustedKeyset);
if (!sigOk) return fail('bad_signature');
if (payload.iss !== EXPECTED_ISSUER) return fail('wrong_issuer');
if (payload.aud !== expectedResource) return fail('wrong_resource');
if (payload.exp <= now / 1000) return fail('expired');
if (payload.nbf > now / 1000 + CLOCK_SKEW_SEC) return fail('not_yet_valid');
if (payload.amount !== expectedAmount) return fail('amount_mismatch');
if (payload.currency !== expectedCurrency) return fail('currency_mismatch');
// CRITICAL: atomic. Do not check, then mark.
const fresh = await ledger.consumeIfFresh(payload.jti);
if (!fresh) return fail('replay');
return { ok: true, paidBy: payload.sub };
}
That is the entire verifier. ~25 effective lines. Every other line is a guard against a real attack.
The chargeback that taught us to fail closed
Day 4 of mainnet beta, one buyer agent figured out that our verifier was
checking the receipt's jti against the ledger and then marking it
spent. Two requests in flight, both passed the freshness check, both got
the resource, only one got the spend recorded.
The fix is the consumeIfFresh primitive: atomic check-and-mark. In our
Redis-backed ledger that is a Lua script; in Postgres it is a single
INSERT ... ON CONFLICT DO NOTHING RETURNING. In any other distributed
store you have to think hard or you have a double-spend.
We wrote up the broader pattern in the Distributed nonce ledger guide. The TL;DR: if your verifier has two database round-trips, you have a race condition.
Other lines that look optional and are not
algcheck beforeverifyJWS. Otherwise an attacker downgrades you toalg: none.isscheck. Otherwise any signed receipt from any service passes.audcheck. Otherwise a $0.01 receipt for a $0.01 resource pays for the $1,000 resource next door.- Clock skew.
nbfslightly in the future is normal under NTP drift; an exact comparison rejects 1% of legitimate traffic. - The default for unrecognized fields. We fail closed: any unknown
critheader rejects the receipt. The alternative, silently ignoring, is how every JWT CVE in the last decade started.
What we got right
The @more-md/x402 package ships the verifier above as a one-liner with
sensible defaults. If you reach for it, you avoid every footgun in this
post. If you want to write your own verifier, please re-read the section
above before you ship it.
x402 is going to underwrite a lot of agent-to-agent commerce. The verifiers protecting it should not be the weakest link.