{"slug": "x402-signs-the-money-not-the-url-i-checked-18-fields", "title": "x402 Signs the Money, Not the URL. I Checked 18 Fields.", "summary": "An engineer discovered that the x402 payment protocol's payer signature does not cover the URL, allowing the resource field to be mutated without invalidating the signature. Testing 18 field mutations on the published PaymentPayload example, 8 changes left the signature verifying, including altering the resource URL to a different host. The finding raises concerns about the protocol's security, especially after Cloudflare announced a monetization gateway for x402.", "body_md": "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.\n\nI 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`\n\nobject.\n\nChange the resource URL to a different host. The signature still verifies. Nothing in the payment path notices.\n\nAI disclosure:I wrote`x402_intent_gate.py`\n\nwith 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`\n\n. The spec text I quote is other people's work, linked inline.\n\n**In short:**\n\n`exact`\n\nEVM scheme is `TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)`\n\n, 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.`PaymentPayload`\n\nexample 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.`transferWithAuthorization`\n\nleaves an ERC-20 `Transfer`\n\nand `AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)`\n\n, and neither names a resource. The `SettlementResponse`\n\nhas seven fields, and exactly one of them, `extensions`\n\n, 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.`nonce = keccak256(canonical_intent || salt)`\n\n.This is not a hypothetical protocol that nobody ships. Cloudflare announced [a monetization gateway for x402](https://blog.cloudflare.com/monetization-gateway/) 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.\n\nStart with the anchor, because everything after it depends on my arithmetic being right.\n\nThe [x402 v2 specification](https://github.com/coinbase/x402/blob/main/specs/x402-specification-v2.md) publishes a complete `PaymentPayload`\n\nexample in section 5.2.1, including a real 65-byte signature. The [exact/EVM scheme spec](https://github.com/coinbase/x402/blob/main/specs/schemes/exact/scheme_exact_evm.md) 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.\n\n```\n  [PASS] keccak256(\"\") matches the published vector\n  [PASS] keccak256(\"abc\") matches the published vector\n  [PASS] keccak256(TransferWithAuthorization type string) equals the TYPEHASH\n         constant published in EIP-3009\n  [PASS] secp256k1 base point is on the curve\n  [PASS] n*G is the point at infinity\n  [PASS] the signature published in the x402 spec example recovers to the\n         payer address published in that same example\n  [PASS] sign then recover round-trips on the throwaway demo key\n\n  keccak256(\"\")    c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470\n  keccak256(\"abc\") 4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45\n  TWA typehash      0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267\n  recovered signer  0x857b06519e91e3a54538791bdbb0e22373e36b66\n  authorization.from 0x857b06519e91e3a54538791bdbb0e22373e36b66\n```\n\nThat last line is the part I care about. The address my code recovers from their signature equals the `from`\n\naddress 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.\n\nThe digest is `0xf256992871671abcb27ff92885a7afa46218724e5fc0bac35d050115aa1d22e6`\n\n, and it is built from exactly this:\n\n``` python\ndef eip712_digest(payload):\n    \"\"\"Rebuild exactly the 32 bytes an x402 exact/EVM payer signs.\"\"\"\n    acc = payload[\"accepted\"]\n    auth = payload[\"payload\"][\"authorization\"]\n    chain_id = int(acc[\"network\"].split(\":\")[1])\n    ds = domain_separator(acc[\"extra\"][\"name\"], acc[\"extra\"][\"version\"],\n                          chain_id, acc[\"asset\"])\n    struct_hash = keccak256(keccak256(TWA_TYPE) + _addr(auth[\"from\"]) + _addr(auth[\"to\"])\n                            + _u256(auth[\"value\"]) + _u256(auth[\"validAfter\"])\n                            + _u256(auth[\"validBefore\"]) + _b32(auth[\"nonce\"]))\n    return keccak256(b\"\\x19\\x01\" + ds + struct_hash)\n```\n\nRead the inputs. `from`\n\n, `to`\n\n, `value`\n\n, 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.\n\nThe Permit2 path in the same spec is stricter, not looser. Its witness type is `keccak256(\"Witness(address to,uint256 validAfter)\")`\n\n, and the spec carries the comment `post-audit: extra removed from Witness`\n\n. The one place context could have been smuggled in got taken out by an audit.\n\nI 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.\n\n```\nleaf field                             | minimally changed to   | signature\n------------------------------------------------------------------------------\nx402Version                            | 3                      | STILL VERIFIES\nresource.url                           | value + \"-mutated\"     | STILL VERIFIES\nresource.description                   | value + \"-mutated\"     | STILL VERIFIES\nresource.mimeType                      | value + \"-mutated\"     | STILL VERIFIES\naccepted.scheme                        | value + \"-mutated\"     | STILL VERIFIES\naccepted.network                       | eip155:8453            | fails\naccepted.amount                        | 10001                  | STILL VERIFIES\naccepted.asset                         | 0x00000000000000000... | fails\naccepted.payTo                         | 0x00000000000000000... | STILL VERIFIES\naccepted.maxTimeoutSeconds             | 61                     | STILL VERIFIES\naccepted.extra.name                    | value + \"-mutated\"     | fails\naccepted.extra.version                 | 3                      | fails\npayload.signature                      | not mutated            | the artifact under test\npayload.authorization.from             | 0x00000000000000000... | fails\npayload.authorization.to               | 0x00000000000000000... | fails\npayload.authorization.value            | 10001                  | fails\npayload.authorization.validAfter       | 1740672090             | fails\npayload.authorization.validBefore      | 1740672155             | fails\npayload.authorization.nonce            | 0xababababababababa... | fails\n------------------------------------------------------------------------------\nleaf fields enumerated                    : 19\nexcluded (the signature itself)           : 1\nmutated                                   : 18\nsignature STILL VERIFIES after the change : 8\nsignature fails after the change          : 10\n```\n\nTwo 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`\n\nis 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`\n\nis the single field I gave a hand-written mutation, `eip155:84532`\n\nto `eip155:8453`\n\n, because a malformed CAIP-2 string crashes the chain id parser instead of testing anything.\n\nThe 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.\n\nSo 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.\n\nThe 8 survivors split in two. Four of them say what is being bought: `resource.url`\n\n, `resource.description`\n\n, `resource.mimeType`\n\nand 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.\n\nTwo of those 8 deserve a note, because I do not want to overclaim. `accepted.amount`\n\nand `accepted.payTo`\n\nare display copies. They do not move money, since the money follows `authorization.to`\n\nand `authorization.value`\n\n. 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.\n\nMinimal mutations prove coverage. They understate severity. So I re-ran the uncovered fields with values chosen to be obnoxious, using the same 65 bytes:\n\n```\nchange to the envelope                       | signature\n------------------------------------------------------------------------------\nresource.url -> a different host entirely    | STILL VERIFIES\nresource.url -> a different path             | STILL VERIFIES\nresource.description -> unrelated            | STILL VERIFIES\nresource.mimeType -> unrelated               | STILL VERIFIES\naccepted.amount display copy, 100x           | STILL VERIFIES\naccepted.payTo display copy -> burn address  | STILL VERIFIES\naccepted.scheme -> another scheme name       | STILL VERIFIES\naccepted.maxTimeoutSeconds -> one hour       | STILL VERIFIES\nadd accepted.extra.assetTransferMethod       | STILL VERIFIES\n------------------------------------------------------------------------------\nadversarial envelope changes tried  : 9\nsignature still verifies after      : 9\n```\n\n`api.example.com`\n\nto `evil.example.net`\n\n, 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.\n\nBecause the data does not exist. This is the part that surprised me, and it is why I stopped looking for a post-hoc answer.\n\nA settled `transferWithAuthorization`\n\nleaves two records on chain: the ERC-20 `Transfer`\n\nthe token contract emits, and EIP-3009's own `event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)`\n\n. Neither names a resource. Two purchases at the same price to the same recipient for different resources look like this:\n\n```\n  buy A\n    resource                  https://api.example.com/premium-data\n    Transfer.from             0x857b06519e91e3a54538791bdbb0e22373e36b66\n    Transfer.to               0x209693bc6afc0c5328ba36faf03c514ef312287c\n    Transfer.value            10000\n    AuthorizationUsed.nonce   0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480\n  buy B\n    resource                  https://api.example.com/cheap-data\n    Transfer.from             0x857b06519e91e3a54538791bdbb0e22373e36b66\n    Transfer.to               0x209693bc6afc0c5328ba36faf03c514ef312287c\n    Transfer.value            10000\n    AuthorizationUsed.nonce   0x63e1985efb2feb72dfaa78debef5dc246d15984f7895fb86294e43d9153d476d\n\n  ERC-20 Transfer args identical between A and B : True\n  AuthorizationUsed differs only in the nonce    : True\n  fields naming the resource in either record    : 0\n```\n\nThose 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.\n\nThen there is the `SettlementResponse`\n\n, 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:\n\n```\nThe SettlementResponse the server hands back has seven fields in the x402\nv2 spec, section 5.3.2, and here is the whole list:\n    success      required   errorReason  optional\n    transaction  required   payer        optional\n    network      required   amount       optional\n                            extensions   optional\n```\n\nSix of those seven cannot name a resource. The seventh can. `extensions`\n\nis exactly where the [offer-and-receipt extension](https://github.com/coinbase/x402/blob/main/specs/extensions/extension-offer-and-receipt.md) parks a receipt, at `extensions[\"offer-receipt\"].info.receipt`\n\n, and that receipt carries a `resourceUrl`\n\n. The spec prints a worked example with `\"resourceUrl\": \"https://api.example.com/premium-data\"`\n\nsitting 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.\n\nThe 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.\n\n`PaymentPayload.resource`\n\nhas the same shape of problem: it exists, the v2 spec marks it Optional, and it sits outside the signature.\n\nWhich leaves one place to stand. Not after the settlement. Before the signature.\n\nI want to keep two failures apart, because they look alike and they are not.\n\nIn [the mandate freshness gate](https://finops.spinov.online/blog/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.\n\nHere 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.\n\nThis is the same shape as [tracking is not control](https://finops.spinov.online/blog/a-47k-agent-loop-spend-cap/), 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](https://finops.spinov.online/blog/sliding-window-spend-guard/) is watching a number it did not choose. And a [receipt read after the fact](https://finops.spinov.online/blog/model-receipt-probe/) cannot recover a field that was never recorded.\n\nThe 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`\n\n.\n\nThat is a 32-byte payer-controlled channel that is already signed and already published, and we currently fill it with noise.\n\nI 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.\n\n``` python\ndef canonical_intent(intent):\n    \"\"\"One line per field, fixed order, newline separated. No JSON ambiguity.\"\"\"\n    return (\"x402-intent/1\\n\"\n            \"method: %s\\n\"\n            \"url: %s\\n\"\n            \"body-sha256: %s\\n\"\n            \"class: %s\\n\"\n            \"max-atomic: %d\\n\"\n            \"asset: %s/%s\\n\"\n            \"payTo: %s\\n\" % (\n                intent[\"method\"].upper(),\n                normalize_url(intent[\"url\"]),\n                intent.get(\"body_sha256\") or \"-\",\n                intent[\"resource_class\"],\n                int(intent[\"max_atomic\"]),\n                intent[\"network\"], intent[\"asset\"].lower(),\n                intent[\"payTo\"].lower())).encode()\n\ndef intent_nonce(intent, salt32):\n    if len(salt32) != 32:\n        raise ValueError(\"salt must be 32 bytes\")\n    return \"0x\" + keccak256(canonical_intent(intent) + salt32).hex()\n```\n\nFrom the run:\n\n```\ncanonical_intent for buy A, exactly the bytes that get hashed:\n    | x402-intent/1\n    | method: GET\n    | url: https://api.example.com/premium-data\n    | body-sha256: -\n    | class: market-data\n    | max-atomic: 20000\n    | asset: eip155:84532/0x036cbd53842c5426634e7929541ec2318f3dcf7e\n    | payTo: 0x209693bc6afc0c5328ba36faf03c514ef312287c\n\n  committed nonce   0xce1d40f2e8ccbe52ec6f127abb9752a42f0c469669bebd856a16649c558d4711\n  same intent, same salt, recomputed                : MATCH\n  host swapped to evil.example.net, same salt       : MISMATCH\n  cap raised from 20000 to 30000, same salt         : MISMATCH\n  different salt, same intent                       : MISMATCH\n  nonce length in bytes                             : 32\n```\n\nIt 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.\n\nTwo 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.\n\nSame family as [the pre-execution gate](https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/) and [the pre-send transaction canary](https://finops.spinov.online/blog/grok-tx-canary/), aimed at the payment decision. `decide()`\n\ncollects every reason instead of bailing on the first, and there is no code path that returns ALLOW on an error.\n\nI 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](https://datatracker.ietf.org/doc/html/rfc6979) deterministic nonces so the bytes come out the same every run. The facilitator column is doing real recovery, not trusting an asserted boolean.\n\nStep 3 of that verification list reads \"Verify the authorization parameters (Amount, Validity Window) meet the `PaymentRequirements`\n\n\", 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`\n\nwas greater than `validAfter`\n\n, and still printed a column labelled offline-checkable. So the run pins a clock instead:\n\n```\n    FIXED_NOW = 1740672100  (inside the spec example's window 1740672089..1740672154)\n```\n\nThat 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`\n\ngets flagged `outside-validity-window`\n\n, and the spec example does not.\n\n```\nconstructed case                                      | gate   | facilitator\n------------------------------------------------------------------------------\nlegit: market data, 10000, everything allowlisted     | ALLOW  | ACCEPT\nlegit: image gen, 4000, inside its own class cap      | ALLOW  | ACCEPT\nlegit: second market-data buy, budget still fits      | ALLOW  | ACCEPT\nquote is for a different URL than we asked for        | REFUSE | ACCEPT\nresource host is not on the allowlist                 | REFUSE | ACCEPT\npayTo is not on the allowlist                         | REFUSE | ACCEPT\nchain is not the one we fund                          | REFUSE | ACCEPT\ntoken contract is not the one we fund                 | REFUSE | ACCEPT\n25000 for a class capped at 20000                     | REFUSE | ACCEPT\nfits the cap, but 45000 already signed and unsettled  | REFUSE | ACCEPT\nresource class the policy never heard of              | REFUSE | ACCEPT\nplain random nonce, no commitment to any intent       | REFUSE | ACCEPT\nnonce commits to a different intent                   | REFUSE | ACCEPT\ncontrol: signed value contradicts the quote           | REFUSE | REJECT\ncontrol: signed destination contradicts the quote     | REFUSE | REJECT\ncontrol: validity window closed at decision time      | REFUSE | REJECT\nunusable input, required field missing                | REFUSE | n/a\n------------------------------------------------------------------------------\ncases constructed                                  : 17\ngate ALLOW                                         : 3\ngate REFUSE                                        : 14\nfacilitator ACCEPT (offline-checkable steps)       : 13\nfacilitator REJECT                                 : 3\npass the facilitator, refused by the gate          : 10\n```\n\nTen 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.\n\nThe 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.\n\nOne thing I should not let myself round off. Two of those ten, `intent-nonce-missing`\n\nand `intent-nonce-mismatch`\n\n, 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.\n\nOne reason code is mine and I have not seen it elsewhere: `budget-would-exceed-with-outstanding`\n\n. An EIP-3009 authorization is a liability from the moment it is signed, not from the moment it settles. It carries `validAfter`\n\nand `validBefore`\n\n, 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.\n\nHere is the single counterexample that ends this post: show me a field, in what the payer authorizes under the `exact`\n\nEVM scheme, that identifies the resource. One field and I am wrong.\n\nBe 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)`\n\n, with the spec's own comment `post-audit: extra removed from Witness`\n\n. ERC-7710 sends `delegationManager`\n\n, `permissionContext`\n\nand `delegator`\n\n, and the spec says its verification \"is performed entirely through simulation\" of an ERC-20 `transfer(payTo, amount)`\n\n. 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.\n\nThe 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`\n\non all of them. F5 proves the validity-window check actually fires. F6 re-runs the sweep and demands identical verdicts.\n\nThen I tried to break it on purpose, four times, and it exited 1 every time: flipping one Keccak round constant, forcing `decide()`\n\nto 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.\n\nIt is not a conformance suite, and it does not implement `upto`\n\n, `deferred`\n\nor 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.\n\nI 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.\n\nStandard library only, offline, no keys, no funds, about ten seconds. `run_all.sh`\n\nruns the self-test, then three full runs, compares them byte for byte and prints the sha256 of each:\n\n```\ninterpreter: Python 3.13.5\n\nself-test: PASS\nrun 1: exit=0 sha256=6cfe746ec64d8a497b1cafe27ed351dab1f56f690d3195959f0993a6e57888a6\nrun 2: exit=0 sha256=6cfe746ec64d8a497b1cafe27ed351dab1f56f690d3195959f0993a6e57888a6\nrun 3: exit=0 sha256=6cfe746ec64d8a497b1cafe27ed351dab1f56f690d3195959f0993a6e57888a6\ndeterminism: 3 runs byte-identical\n```\n\nThe report itself ends with `report-sha256: 49cc227bc3cb64316dbea77387ea304f14b9ca3c677c9c707538bc0fe6bc3ccc`\n\n, so you can tell at a glance whether your run matches mine.\n\nThe 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.\n\nFollow 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\".", "url": "https://wpnews.pro/news/x402-signs-the-money-not-the-url-i-checked-18-fields", "canonical_source": "https://dev.to/alex_spinov/x402-signs-the-money-not-the-url-i-checked-18-fields-429a", "published_at": "2026-07-29 01:12:59+00:00", "updated_at": "2026-07-29 02:00:00.820621+00:00", "lang": "en", "topics": ["ai-infrastructure", "developer-tools"], "entities": ["Cloudflare", "Coinbase", "x402"], "alternates": {"html": "https://wpnews.pro/news/x402-signs-the-money-not-the-url-i-checked-18-fields", "markdown": "https://wpnews.pro/news/x402-signs-the-money-not-the-url-i-checked-18-fields.md", "text": "https://wpnews.pro/news/x402-signs-the-money-not-the-url-i-checked-18-fields.txt", "jsonld": "https://wpnews.pro/news/x402-signs-the-money-not-the-url-i-checked-18-fields.jsonld"}}