On Saturday morning I was reading a draft standard for human approvals in MCP, the Human Gateway SEP from the HATP project. The idea is a good one. Before an agent runs a tool that matters, the right person signs off, and the tool server can check that signature offline without trusting the agent or the client.
The approval is a signature over a digest of the action: the server, the tool and the arguments. The example in the spec is a charge_card call, and it works because the amount and the payee are arguments. Bob approved this payment, to this payee, for this amount.
Then I tried it with a paid tool. In x402 over MCP the price isn't an argument. The agent calls the tool, the server answers with an error result carrying its terms (amount in atomic units, token contract, network, payTo), and the agent calls again with the same arguments plus a signed payment in params._meta["x402/payment"]. The digest is the same for both calls. So the approval passes at any price and any payee, and the person approving never sees either.
I opened an issue on 31 October and the maintainer shipped a fix in release 0.1.4 three days later. The same gap is easy to have anywhere a human approval sits in front of an agent payment, so this is what I'd compare in yours.
Last week I wrote about where agent payment designs trust the wrong thing and put up a full checklist. This one is only about the approval step.
The key signs more than the person approves
On EVM chains the x402 exact scheme pays with an EIP-3009 transferWithAuthorization. The agent signs typed data with from, to, value, validAfter, validBefore and a nonce, and the EIP-712 domain carries the chain ID and the token contract. So the payment signature is bound to the payee, the amount, the token, the chain and a time window.
The approval in front of it usually covers less, and it's the only part a person looks at. So put the two next to each other, field by field. Anything the key signs that the approval doesn't cover is something the server or the agent gets to pick.
Amount and payee have to be in what gets approved
The fix adds an optional payment member to the approved action:
"payment": {
"network": "eip155:8453",
"asset": "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"maxAmount": "2500000",
"payTo": "0x9a3f…"
}
It's inside the digest and it's shown in the ask, so the person sees 2.50 USDC on Base to that address instead of "premium_report, ticker ACME".
I'd copy two details from it. The amount is in atomic units of a named asset. A { "value": 2.5, "currency": "USD" } field can't tell USDC on Base from a token with the same symbol on another chain, and a CAIP-19 asset ID can. And it's a maximum, not an exact price, so "up to $5" is the same check with a bigger number.
The payee can't be the one checking
In the first version the gated server verified the approval. That makes sense when the agent and the client are the parties being supervised. For a paid tool, though, the server is also the payee, and a check that only the payee runs doesn't protect the payer.
So in 0.1.4 the payer's client or wallet compares the server's terms with the approved payment before it signs: same network, same asset, same payTo, and an amount no higher than maxAmount. If anything differs, it doesn't sign and asks for a new approval with the new terms. The server still checks too, but that catches honest mismatches, not a server that wants more.
The server side has its mirror image. An x402 server should check the payment against its own price list, not against the copy of the terms the client sends back in accepted. The stock x402 server compares the two. A hand-rolled one that forwards the client's accepted to the facilitator lets the client set its own price, and the facilitator will happily confirm it.
The window is the field I rarely see on an approval screen
The x402 client doesn't choose how long its payment signature stays valid. The stock clients set validBefore to now plus the maxTimeoutSeconds from the server's terms (I checked the TypeScript, Python and Go clients in the x402 repo). A server that asks for 604800 gets a signature it can settle for a week.
The approval has its own expiry, HATP's is expiresAt, but that's when the approval stops being accepted. It says nothing about how long the payment it led to can be settled. The amount doesn't change, but the server can hold the signed payment and settle it days after the person who approved it changed their mind.
HATP's payment member doesn't carry a window yet, so this is the one field from the signature the approval still doesn't cover. A maximum window, checked next to maxAmount before signing, would close it.
A limit per payment isn't a limit in total
Standing approvals ("let the agent pay for this tool this week") are what people ask for next, and a per-payment cap doesn't bound them. 2.50 USDC a call is 250 USDC after a hundred calls, and an agent in a retry loop can make a hundred calls in a minute.
The approval needs a running total, kept where the payments are signed. The SEP now says that for standing approvals, and I'd ask the same of any wallet guard: does it count, or does it only look at one payment at a time?
The fee is part of the price
This one isn't x402. Earlier this week I looked at an ERC-4337 smart account where the owner sets per-call caps and a budget, and an agent holds a session key. The caps covered the calls the key could make. But the session key also signs the gas fields of the user operation (maxFeePerGas, maxPriorityFeePerGas, preVerificationGas), and the fee goes to whoever bundles it. Someone holding a leaked key can bundle one allowed operation themselves at an absurd gas price and take most of the account's ETH as the fee, inside every cap.
A cap on the gas fields in validation closes it.
Approve once, pay once
x402 turns a paid call into two tools/call requests with the same arguments. HATP approvals carry a single-use nonce, and the first version didn't say when the server records it. If it records it on the first call, the one it answers with its price, the paid retry comes back as a replay and gets refused. That's not a hole, but it's the kind of bug that gets a check switched off in a hurry. The fix says a nonce is consumed only when the tool actually runs.
The opposite mistake is worse: a nonce that's never consumed lets one approval pay for many calls. Test both. The retry goes through once, and a third call with the same approval doesn't.
Field by field
| What the key signs | What the approval should cover | Checked where |
|---|---|---|
to |
payTo |
payer's client or wallet |
value |
maxAmount in atomic units, plus a running total for standing approvals |
payer's client or wallet |
| token contract (EIP-712 domain) | CAIP-19 asset | payer's client or wallet |
| chain ID (EIP-712 domain) | CAIP-2 network | payer's client or wallet |
validBefore |
a maximum window | payer's client (not in HATP yet) |
| gas fields (ERC-4337) | a gas cap | the account's validation |
The quickest test I know for your own setup: approve a call, then have your test server raise its price on the retry. If the payment still gets signed, the approval doesn't cover the price.
I first wrote a short version of this on X.
I wrote this with help from AI for drafting and editing. The opinions and the review experience are mine.
Top comments (1)
I'd tie the running total to the window, because the total only works if it counts a payment when the key signs it, not when it settles. A server that asks for a week-long window can collect a hundred signed 2.50 USDC authorizations and sit on all of them. A guard that totals settled transfers still sees zero, keeps signing, and the server settles the lot later in the week.
So the guard should reserve the amount at signing and keep it reserved until authorizationState shows the nonce used, validBefore passes, or the wallet burns the nonce with cancelAuthorization. That gives the maximum window a second job. A short window hands unused budget back quickly and a week-long one holds it for a week, which is a good reason for a standing approval to cap the window alongside the total.