An x402 payer signature does not cover the URL. It commits to the amount, the recipient, the token contract and the chain, and to nothing that says what you are paying for. I mutated 18 leaf fields of the payment payload published in the x402 spec: 8 changes left the signature verifying, 10 broke it.

I rebuilt the EIP-712 digest from that example, recovered the signer with my own secp256k1 code, then changed one field at a time. The 8 that still verify include the entire resource object.

Change the resource URL to a different host. The signature still verifies. Nothing in the payment path notices.

AI disclosure: I wrote x402_intent_gate.py with an AI assistant and ran it myself, offline, on Python 3.13.5, standard library only, no network, no keys, no wallet, no funds. Every number and every hex string below is pasted from a real local run. Three runs produced byte-identical STDOUT with sha256 6cfe746ec64d8a497b1cafe27ed351dab1f56f690d3195959f0993a6e57888a6. The spec text I quote is other people's work, linked inline.

In short:

  • The signed structure in the x402 exact EVM scheme is EIP-3009's TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce), wrapped in an EIP-712 domain that adds the token name, version, chain id and contract address. That is the whole list. There is no slot for what you are buying.
  • I enumerated every leaf field of the PaymentPayload example in the x402 v2 spec, mutated one at a time, and re-ran real ECDSA recovery each time. 8 of 18 mutations left the signature verifying. The 10 that broke it are money, token, chain and clock.
  • Nothing the payer signed can be checked afterwards either. A settled transferWithAuthorization leaves an ERC-20 Transfer and AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce), and neither names a resource. The SettlementResponse has seven fields, and exactly one of them, extensions, can carry the resource, because that is where the optional offer-and-receipt extension parks a receipt. That receipt is signed by the server, not by you.
  • The repair costs zero protocol changes. The nonce is 32 bytes the payer chooses, it is inside the signature, and it is emitted indexed on-chain. So stop wasting it on randomness: nonce = keccak256(canonical_intent || salt).
  • The gate runs before the signature exists. Of 17 constructed cases, 10 pass the facilitator's own verification steps, including a real validity-window check, and get refused by the gate anyway. All 3 legitimate ones still pass.

This is not a hypothetical protocol that nobody ships. Cloudflare announced a monetization gateway for x402 on 2026-07-01, which puts a 402 in front of anything sitting behind their edge. The number of agents that will sign one of these grew a lot faster than the number of people asking what the signature says.

What does an x402 payer signature actually commit to?

Start with the anchor, because everything after it depends on my arithmetic being right.

The x402 v2 specification publishes a complete PaymentPayload example in section 5.2.1, including a real 65-byte signature. The exact/EVM scheme spec republishes the same payload with one extra field, and defines what gets signed. I implemented keccak256 and secp256k1 from scratch, rebuilt the EIP-712 digest from that example, and ran public key recovery against that signature.

  [PASS] keccak256("") matches the published vector
  [PASS] keccak256("abc") matches the published vector
  [PASS] keccak256(TransferWithAuthorization type string) equals the TYPEHASH
         constant published in EIP-3009
  [PASS] secp256k1 base point is on the curve
  [PASS] n*G is the point at infinity
  [PASS] the signature published in the x402 spec example recovers to the
         payer address published in that same example
  [PASS] sign then recover round-trips on the throwaway demo key

  keccak256("")    c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
  keccak256("abc") 4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45
  TWA typehash      0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267
  recovered signer  0x857b06519e91e3a54538791bdbb0e22373e36b66
  authorization.from 0x857b06519e91e3a54538791bdbb0e22373e36b66

Enter fullscreen mode Exit fullscreen mode

That last line is the part I care about. The address my code recovers from their signature equals the from address in their example. So the 32 bytes I am reconstructing are the 32 bytes that were actually signed, not a plausible-looking reimplementation of them.

The digest is 0xf256992871671abcb27ff92885a7afa46218724e5fc0bac35d050115aa1d22e6, and it is built from exactly this:

def eip712_digest(payload):
    """Rebuild exactly the 32 bytes an x402 exact/EVM payer signs."""
    acc = payload["accepted"]
    auth = payload["payload"]["authorization"]
    chain_id = int(acc["network"].split(":")[1])
    ds = domain_separator(acc["extra"]["name"], acc["extra"]["version"],
                          chain_id, acc["asset"])
    struct_hash = keccak256(keccak256(TWA_TYPE) + _addr(auth["from"]) + _addr(auth["to"])
                            + _u256(auth["value"]) + _u256(auth["validAfter"])
                            + _u256(auth["validBefore"]) + _b32(auth["nonce"]))
    return keccak256(b"\x19\x01" + ds + struct_hash)

Enter fullscreen mode Exit fullscreen mode

Read the inputs. from, to, value, two timestamps, a nonce, and a domain made of the token name, version, chain id and contract. Count the fields that describe what you are buying: zero.

The Permit2 path in the same spec is stricter, not looser. Its witness type is keccak256("Witness(address to,uint256 validAfter)"), and the spec carries the comment post-audit: extra removed from Witness. The one place context could have been smuggled in got taken out by an audit.

Which x402 payload fields can change without breaking the signature?

I walked that same v2 section 5.2.1 example as an object tree, collected every leaf, and mutated them one at a time with a minimal type-preserving change. After each mutation the digest gets recomputed and the original 65 bytes get re-verified by full recovery. The signature field itself is excluded, since it is the artifact under test.

leaf field                             | minimally changed to   | signature
------------------------------------------------------------------------------
x402Version                            | 3                      | STILL VERIFIES
resource.url                           | value + "-mutated"     | STILL VERIFIES
resource.description                   | value + "-mutated"     | STILL VERIFIES
resource.mimeType                      | value + "-mutated"     | STILL VERIFIES
accepted.scheme                        | value + "-mutated"     | STILL VERIFIES
accepted.network                       | eip155:8453            | fails
accepted.amount                        | 10001                  | STILL VERIFIES
accepted.asset                         | 0x00000000000000000... | fails
accepted.payTo                         | 0x00000000000000000... | STILL VERIFIES
accepted.maxTimeoutSeconds             | 61                     | STILL VERIFIES
accepted.extra.name                    | value + "-mutated"     | fails
accepted.extra.version                 | 3                      | fails
payload.signature                      | not mutated            | the artifact under test
payload.authorization.from             | 0x00000000000000000... | fails
payload.authorization.to               | 0x00000000000000000... | fails
payload.authorization.value            | 10001                  | fails
payload.authorization.validAfter       | 1740672090             | fails
payload.authorization.validBefore      | 1740672155             | fails
payload.authorization.nonce            | 0xababababababababa... | fails
------------------------------------------------------------------------------
leaf fields enumerated                    : 19
excluded (the signature itself)           : 1
mutated                                   : 18
signature STILL VERIFIES after the change : 8
signature fails after the change          : 10

Enter fullscreen mode Exit fullscreen mode

Two notes on that table before the counts, because the printout carries them and I would rather you read them from me than find them yourself. The example's extensions is an empty object, so it has zero leaves and gets no row, and it happens to be the one place a resource identifier could ever ride. Section 3 comes back to it. And accepted.network is the single field I gave a hand-written mutation, eip155:84532 to eip155:8453, because a malformed CAIP-2 string crashes the chain id parser instead of testing anything.

The 10 failures are the negative control, and they matter more than the 8 passes. If everything had come back STILL VERIFIES, the honest conclusion would have been that my checker was broken. It is not: touch the payer, the recipient, the amount, either timestamp, the nonce, the token contract, the chain id, or the token name or version, and recovery lands on a different address.

So the line is clean, and it is not an accident of my field ordering. Everything describing where the money goes is inside the signature. Everything describing what the money is for is outside it.

The 8 survivors split in two. Four of them say what is being bought: resource.url, resource.description, resource.mimeType and the scheme name. The other four carry no payment authority at all: the protocol version, the timeout hint, and the display copies of the amount and the recipient.

Two of those 8 deserve a note, because I do not want to overclaim. accepted.amount and accepted.payTo are display copies. They do not move money, since the money follows authorization.to and authorization.value. What they do is decide what your client renders and what your logs keep. A client builds the authorization from the requirements the server sent, so the unsigned copy is the input, and afterwards only one of the two versions can be proven. That is a smaller problem than the URL, and I am flagging it as smaller.

The row worth sitting with

Minimal mutations prove coverage. They understate severity. So I re-ran the uncovered fields with values chosen to be obnoxious, using the same 65 bytes:

change to the envelope                       | signature
------------------------------------------------------------------------------
resource.url -> a different host entirely    | STILL VERIFIES
resource.url -> a different path             | STILL VERIFIES
resource.description -> unrelated            | STILL VERIFIES
resource.mimeType -> unrelated               | STILL VERIFIES
accepted.amount display copy, 100x           | STILL VERIFIES
accepted.payTo display copy -> burn address  | STILL VERIFIES
accepted.scheme -> another scheme name       | STILL VERIFIES
accepted.maxTimeoutSeconds -> one hour       | STILL VERIFIES
add accepted.extra.assetTransferMethod       | STILL VERIFIES
------------------------------------------------------------------------------
adversarial envelope changes tried  : 9
signature still verifies after      : 9

Enter fullscreen mode Exit fullscreen mode

api.example.com to evil.example.net, same signature, still valid. The money is pinned to the last atomic unit. The name of the thing the money bought is a free text field sitting next to it.

Why can't you reconcile this afterwards?

Because the data does not exist. This is the part that surprised me, and it is why I stopped looking for a post-hoc answer.

A settled transferWithAuthorization leaves two records on chain: the ERC-20 Transfer the token contract emits, and EIP-3009's own event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce). Neither names a resource. Two purchases at the same price to the same recipient for different resources look like this:

  buy A
    resource                  https://api.example.com/premium-data
    Transfer.from             0x857b06519e91e3a54538791bdbb0e22373e36b66
    Transfer.to               0x209693bc6afc0c5328ba36faf03c514ef312287c
    Transfer.value            10000
    AuthorizationUsed.nonce   0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480
  buy B
    resource                  https://api.example.com/cheap-data
    Transfer.from             0x857b06519e91e3a54538791bdbb0e22373e36b66
    Transfer.to               0x209693bc6afc0c5328ba36faf03c514ef312287c
    Transfer.value            10000
    AuthorizationUsed.nonce   0x63e1985efb2feb72dfaa78debef5dc246d15984f7895fb86294e43d9153d476d

  ERC-20 Transfer args identical between A and B : True
  AuthorizationUsed differs only in the nonce    : True
  fields naming the resource in either record    : 0

Enter fullscreen mode Exit fullscreen mode

Those records are reconstructions from my fixtures using the field lists the spec and the EIP define. I did not query a chain, and nothing in that script touches money.

Then there is the SettlementResponse, and here I have to correct myself. My first draft of this post listed five fields and said flatly that none of them names the resource. Section 5.3.2 of the v2 spec has seven, and the two I dropped were the two that mattered:

The SettlementResponse the server hands back has seven fields in the x402
v2 spec, section 5.3.2, and here is the whole list:
    success      required   errorReason  optional
    transaction  required   payer        optional
    network      required   amount       optional
                            extensions   optional

Enter fullscreen mode Exit fullscreen mode

Six of those seven cannot name a resource. The seventh can. extensions is exactly where the offer-and-receipt extension parks a receipt, at extensions["offer-receipt"].info.receipt, and that receipt carries a resourceUrl. The spec prints a worked example with "resourceUrl": "https://api.example.com/premium-data" sitting right there in the settlement response. So "no field names the resource" is false as an absolute, and I am glad it got caught before this went out.

The true claim is the narrower one, and it is the one this whole post is about: nothing the payer signed names the resource. The single slot that can name it is opt-in, and it is filled by the server. That extension is genuinely useful and I would turn it on. But it is signed by the service rather than the payer, its own text calls it an audit layer "without changing payment execution or settlement semantics", and the receipt is "privacy-minimal by default and intentionally omits transaction references to reduce correlation risk". A merchant-signed artifact that by default does not link to the transaction is a merchant's statement, not the payer's proof of what the payer decided to buy.

PaymentPayload.resource has the same shape of problem: it exists, the v2 spec marks it Optional, and it sits outside the signature.

Which leaves one place to stand. Not after the settlement. Before the signature.

This is not the stale mandate problem

I want to keep two failures apart, because they look alike and they are not.

In the mandate freshness gate the axis is time. The signature was honest when it was made, and the authority behind it walked out afterwards: revoked, expired, limit lowered. Everything cryptographic holds, and the question is whether the yes is still standing at execution.

Here the axis is content. The authority is perfectly live. The signature is fresh, valid, and inside every limit. It simply never said what it was for. A freshness check passes this case with full marks, and so does a signature check, because both are answering questions that have correct answers.

This is the same shape as tracking is not control, pushed one level down. Your spend cap counts tokens you can compute. In an x402 flow the counterparty names the price in the 402 response, so a sliding window guard is watching a number it did not choose. And a receipt read after the fact cannot recover a field that was never recorded.

The fix that needs no protocol change

The nonce is 32 bytes. EIP-3009 says they are random and payer-chosen. They sit inside the signed struct, and the contract emits them indexed on-chain in AuthorizationUsed.

That is a 32-byte payer-controlled channel that is already signed and already published, and we currently fill it with noise.

I expected to be arguing that this is merely protocol-legal. It turns out the spec argues it for me. EIP-3009's own Security Considerations say that where cross-use is a risk, "the app developer could dedicate some leading bytes of the nonce as an identifier to prevent cross-use". Putting meaning in those bytes is a sanctioned use, not a loophole I found. The one caveat worth keeping: the spec says the nonce is randomly generated, and what preserves that property here is the 32-byte salt, not the intent. Hash a bare intent with no salt and you get a nonce that repeats and that anyone can grind.

def canonical_intent(intent):
    """One line per field, fixed order, newline separated. No JSON ambiguity."""
    return ("x402-intent/1\n"
            "method: %s\n"
            "url: %s\n"
            "body-sha256: %s\n"
            "class: %s\n"
            "max-atomic: %d\n"
            "asset: %s/%s\n"
            "payTo: %s\n" % (
                intent["method"].upper(),
                normalize_url(intent["url"]),
                intent.get("body_sha256") or "-",
                intent["resource_class"],
                int(intent["max_atomic"]),
                intent["network"], intent["asset"].lower(),
                intent["payTo"].lower())).encode()


def intent_nonce(intent, salt32):
    if len(salt32) != 32:
        raise ValueError("salt must be 32 bytes")
    return "0x" + keccak256(canonical_intent(intent) + salt32).hex()

Enter fullscreen mode Exit fullscreen mode

From the run:

canonical_intent for buy A, exactly the bytes that get hashed:
    | x402-intent/1
    | method: GET
    | url: https://api.example.com/premium-data
    | body-sha256: -
    | class: market-data
    | max-atomic: 20000
    | asset: eip155:84532/0x036cbd53842c5426634e7929541ec2318f3dcf7e
    | payTo: 0x209693bc6afc0c5328ba36faf03c514ef312287c

  committed nonce   0xce1d40f2e8ccbe52ec6f127abb9752a42f0c469669bebd856a16649c558d4711
  same intent, same salt, recomputed                : MATCH
  host swapped to evil.example.net, same salt       : MISMATCH
  cap raised from 20000 to 30000, same salt         : MISMATCH
  different salt, same intent                       : MISMATCH
  nonce length in bytes                             : 32

Enter fullscreen mode Exit fullscreen mode

It is still a valid 32-byte nonce, still unique, still opaque to everyone without the salt. Nothing changes on the wire. What changes is that the signature becomes impossible to produce without first having written the decision down, which is the property I actually wanted: the control produces the audit trail, instead of the audit trail being offered as a substitute for control.

Two things it does not do, stated plainly. It does not make the server deliver the resource you named. And it is a commitment, not a receipt: it proves what you decided, not what you received. If someone shows me a way to bind delivery from the payer side without an extension the merchant has to opt into, I would like to see it, because I could not find one.

The gate, run before anything is signed

Same family as the pre-execution gate and the pre-send transaction canary, aimed at the payment decision. decide() collects every reason instead of bailing on the first, and there is no code path that returns ALLOW on an error.

I built 17 cases and ran each one twice: once through the facilitator's own verification steps, once through the client gate. Every case is really signed with a throwaway key derived from a fixed string in the file, using RFC 6979 deterministic nonces so the bytes come out the same every run. The facilitator column is doing real recovery, not trusting an asserted boolean.

Step 3 of that verification list reads "Verify the authorization parameters (Amount, Validity Window) meet the PaymentRequirements", and the validity window is the part it is easy to quietly skip, since checking it needs a clock and a clock breaks determinism. My first version skipped it, checked only that validBefore was greater than validAfter, and still printed a column labelled offline-checkable. So the run pins a clock instead:

    FIXED_NOW = 1740672100  (inside the spec example's window 1740672089..1740672154)

Enter fullscreen mode Exit fullscreen mode

That instant sits inside the window of the spec's own example, so freshness gets checked for real while the output never touches the wall clock. Falsifier F5 exists purely to prove the check fires: an authorization whose window closed before FIXED_NOW gets flagged outside-validity-window, and the spec example does not.

constructed case                                      | gate   | facilitator
------------------------------------------------------------------------------
legit: market data, 10000, everything allowlisted     | ALLOW  | ACCEPT
legit: image gen, 4000, inside its own class cap      | ALLOW  | ACCEPT
legit: second market-data buy, budget still fits      | ALLOW  | ACCEPT
quote is for a different URL than we asked for        | REFUSE | ACCEPT
resource host is not on the allowlist                 | REFUSE | ACCEPT
payTo is not on the allowlist                         | REFUSE | ACCEPT
chain is not the one we fund                          | REFUSE | ACCEPT
token contract is not the one we fund                 | REFUSE | ACCEPT
25000 for a class capped at 20000                     | REFUSE | ACCEPT
fits the cap, but 45000 already signed and unsettled  | REFUSE | ACCEPT
resource class the policy never heard of              | REFUSE | ACCEPT
plain random nonce, no commitment to any intent       | REFUSE | ACCEPT
nonce commits to a different intent                   | REFUSE | ACCEPT
control: signed value contradicts the quote           | REFUSE | REJECT
control: signed destination contradicts the quote     | REFUSE | REJECT
control: validity window closed at decision time      | REFUSE | REJECT
unusable input, required field missing                | REFUSE | n/a
------------------------------------------------------------------------------
cases constructed                                  : 17
gate ALLOW                                         : 3
gate REFUSE                                        : 14
facilitator ACCEPT (offline-checkable steps)       : 13
facilitator REJECT                                 : 3
pass the facilitator, refused by the gate          : 10

Enter fullscreen mode Exit fullscreen mode

Ten cases sail through the facilitator's verification list and get stopped by the gate. That is not a criticism of facilitators, and it is worth being fair here: the spec is explicit that "the Facilitator cannot modify the amount or destination", and my run agrees, because both of those are inside the signature. The facilitator is doing its job correctly. Its job is to check the authorization against the server's requirements. Both of those come from the server. Your decision is not an input to that comparison anywhere in the verification list.

The three REJECT rows are there so you can see the facilitator checker is capable of saying no on each axis it claims to check: amount, destination and freshness.

One thing I should not let myself round off. Two of those ten, intent-nonce-missing and intent-nonce-mismatch, are refused for not using a convention I invented four paragraphs ago. Every x402 payment on earth today would trip them. That is a proposal, not a finding, and if you strip those two out the gate still catches eight cases with nothing more exotic than an allowlist and a cap.

One reason code is mine and I have not seen it elsewhere: budget-would-exceed-with-outstanding. An EIP-3009 authorization is a liability from the moment it is signed, not from the moment it settles. It carries validAfter and validBefore, and until one of those windows closes or the nonce is consumed, the money is committed. A cap that counts settled spend will happily sign the payment that puts you over, and then watch it land.

What would prove me wrong

Here is the single counterexample that ends this post: show me a field, in what the payer authorizes under the exact EVM scheme, that identifies the resource. One field and I am wrong.

Be clear about which part of that I ran and which part I read. The scheme defines three asset transfer methods, and my tool exercises one. EIP-3009 is the one measured above. Permit2 signs Witness(address to,uint256 validAfter), with the spec's own comment post-audit: extra removed from Witness. ERC-7710 sends delegationManager, permissionContext and delegator, and the spec says its verification "is performed entirely through simulation" of an ERC-20 transfer(payTo, amount). Two addresses and an amount. So the claim holds across all three, but only the first is a measurement and the other two are me reading the spec, which is a weaker kind of evidence and I would rather label it than launder it.

The tool ships six falsifiers, all PASS on the run above. F1 is the negative control on the checker. F2 checks the commitment is a function and not a coincidence. F3 requires the gate to allow every case built to be legitimate and refuse every case built to be wrong, which stops a gate that refuses everything from scoring well. F4 feeds it garbage, an empty object and a null, and requires REFUSE with bad-input on all of them. F5 proves the validity-window check actually fires. F6 re-runs the sweep and demands identical verdicts.

Then I tried to break it on purpose, four times, and it exited 1 every time: flipping one Keccak round constant, forcing decide() to return no reasons, turning the fail-closed branch into fail-open, and dropping the intent from the nonce so it hashed only the salt. A gate that cannot fail its own tests is decoration.

What this is not

It is not a conformance suite, and it does not implement upto, deferred or any Solana scheme. It does not talk to a chain, a facilitator or a wallet, so balance and simulation are not run at all and are never reported as passed. Those are steps 2 and 5 of the exact/EVM EIP-3009 list specifically; the Permit2 list numbers them 3 and 7, so the numbers are not portable even inside one document. The counts are counts of cases I constructed in one file. They are not frequencies, not samples, not rates observed anywhere in production, and no standard errors apply because nothing here is an estimate. Recount every one of them from the printout.

I also have no idea how common any of this is in the wild. I have not measured a single real x402 payment, and I am not going to pretend a count of seventeen constructed cases tells you anything about how often an agent overpays for the wrong URL. What the run does establish is structural: the field is not in the signature, so the check cannot be done later, no matter how careful your logging is.

Run it yourself

Standard library only, offline, no keys, no funds, about ten seconds. run_all.sh runs the self-test, then three full runs, compares them byte for byte and prints the sha256 of each:

interpreter: Python 3.13.5

self-test: PASS
run 1: exit=0 sha256=6cfe746ec64d8a497b1cafe27ed351dab1f56f690d3195959f0993a6e57888a6
run 2: exit=0 sha256=6cfe746ec64d8a497b1cafe27ed351dab1f56f690d3195959f0993a6e57888a6
run 3: exit=0 sha256=6cfe746ec64d8a497b1cafe27ed351dab1f56f690d3195959f0993a6e57888a6
determinism: 3 runs byte-identical

Enter fullscreen mode Exit fullscreen mode

The report itself ends with report-sha256: 49cc227bc3cb64316dbea77387ea304f14b9ca3c677c9c707538bc0fe6bc3ccc, so you can tell at a glance whether your run matches mine.

The question I have not answered: the intent-committed nonce binds my decision to my money, and it does that with no protocol change and no cooperation from anyone. It still cannot prove the server gave me what I paid for. Every payer-side scheme I sketched for that ends up needing the merchant to sign something, which means it needs adoption, which means it is not something I can ship on my own next week. If you have found a payer-side way to bind delivery, I want to read it.

Follow along if you want the numbers from the next teardown in this series. And if you are running x402 in anything resembling production, tell me in the comments what your client does with the resource URL after it signs, because I suspect the honest answer for most of us is "logs it, unsigned, next to the amount".