Skip to main content

·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

  • alg check before verifyJWS. Otherwise an attacker downgrades you to alg: none.
  • iss check. Otherwise any signed receipt from any service passes.
  • aud check. Otherwise a $0.01 receipt for a $0.01 resource pays for the $1,000 resource next door.
  • Clock skew. nbf slightly 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 crit header 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.

Tagsx402paymentssecurity