What happened?
At main (e1ea56db72a6385bce3e5c1112b3a56ce60acb43, spec v0.2), no verifier is required to remember that a closed Payment Mandate has already been accepted. Presenting the same user-signed mandate twice yields two payment credentials, two completed checkouts, and two MPP-signed Payment Receipts whose reference is the same mandate hash. No signature is forged; the second presentation is a byte-identical copy of the first.
This is distinct from #313, #326, #342 and #319, which make aud/nonce checking mandatory on KB hops. Those checks establish that a presentation was made for this verifier with the nonce it expected. They do not establish that it is the first presentation: in the reproduction below the Credential Provider verifies with expected_aud="credential-provider" and the correct expected_nonce both times, and accepts both times. In AP2 the nonce is chosen by the Shopping Agent (it constructs the OpenID4VP Authorization Request, docs/ap2/agent_authorization.md, "Delegation using OpenID4VP"), so a nonce check cannot distinguish a first presentation from a later one made by that same agent. #308 is about the used flag on the payment token; here the attacker never reuses a token, it reuses the mandate that mints tokens.
Where the specification leaves it
docs/ap2/security_and_privacy_considerations.md:5-8: "all LLMs and Agents MUST be considered potential attackers and are explicitly included in the threat model."
docs/ap2/specification.md:237-240 and security_and_privacy_considerations.md:109-111: the only mandatory double-spend control is on the Shopping Agent ("MUST NOT present any subsequent open Payment or Checkout Mandates without receiving a rejection receipt"; "MUST avoid signing multiple, overlapping closed Mandates").
security_and_privacy_considerations.md:113-114: "Credential Provider, Networks or MPPs MAY reject multiple overlapping Mandates, or invalidate previously issued payment tokens."
specification.md:292-342 (Verification): chain verification and constraint evaluation only; no rule records an accepted mandate or refuses a repeat.
code/sdk/schemas/ap2/payment_mandate.json: required fields are vct, transaction_id, payee, payment_amount, payment_instrument. Nothing is unique per presentation; iat/exp are optional.
agent_authorization.md:521-535: the Mandate Receipt error list has no "already used" code.
Where the reference implementation leaves it
code/samples/python/src/roles/credentials_provider_mcp/server.py:217-241: issue_payment_credential verifies the chain and then issues unconditionally, storing under both the fresh token and the deterministic mandate hash with store.update(dict.fromkeys([token, reference], token_data)). A second presentation overwrites the record with a fresh "used": False and mints a second token.
code/samples/python/src/roles/merchant_agent_mcp/server.py:843-861: the only duplicate check keys on the opaque payment_token, which is new on every replay.
code/samples/python/src/roles/merchant_payment_processor_mcp/server.py:207-302: initiate_payment requires used to be true, mints a receipt with a fresh payment_id, and writes no state, so a used token presented again is paid again.
code/samples/go/pkg/roles/merchant_payment_processor_agent/executor.go:56-85: no signature verification; returns SUCCESS for any parsed mandate.
Reproduction
Run from code/samples/python with uv run python repro_ap2_amplification.py (script attached). Every verification, issuance, checkout-completion and receipt step is the sample code's own function, called in-process. The only substitutions are the Merchant-to-MPP HTTP POST (direct call to initiate_payment) and the MPP-to-CP receipt POST (a recorder).
- Open mandates signed; Merchant assembles a cart (2500 minor units) and signs a Checkout; the Shopping Agent presents one closed Payment Mandate, SHA-256 (base64url)
Qef8RJ3tG1QHlAoeiSEe-QWewmd2eVOzayPCVZ7VdlU.
issue_payment_credential called twice with identical arguments: both succeed; two tokens, both "used": false, same reference.
complete_checkout with token 1: status: success, order c5d78046.... Again with token 2, same checkout mandate, same nonce: status: success, order 9f3c3a2f....
- Two MPP-signed Payment Receipts (ES256,
kid: merchant-payment-processor-key-1), both reference = Qef8RJ3t..., distinct payment_id.
A Tamarin model of the Direct payment flow (attached) gives the same result: every charge is preceded by a user consent on the exact transaction, payee and amount (the signature does its job), and "at most one charge per mandate" is falsified in 14 steps. With an atomic consume at the processor keyed on transaction_id it verifies.
Proposed fix (PR to follow)
Verification section, Credential Provider and Network: record, in durable state with atomic single-writer semantics, the hash of each accepted closed Payment Mandate (the value already used as the receipt reference), and reject a closed Payment Mandate whose hash is already recorded with a new terminal error mandate_already_used. Merchant Payment Processor: record transaction_id before initiating payment and reject repeats the same way. Double Spend section: the verifier bullet becomes MUST. Reference implementation: refuse in issue_payment_credential when reference is already present; consume transaction_id in initiate_payment before minting a receipt. With that patch applied the reproduction refuses at step 2 (mandate_already_used), and a used token replayed straight at the MPP is refused with one receipt minted.
If freshness is also wanted, the Key Binding nonce must be issued and stored by the verifier that checks it (OpenID4VP section 5.2), not by the Shopping Agent. Consumption is needed either way: checking a nonce is not remembering it.
Version
AP2 spec v0.2 (2026-04-28); repo commit e1ea56db72a6385bce3e5c1112b3a56ce60acb43. Reported through g.co/vulnz on 2026-09-01; the security team classified it as intended behavior for program purposes and invited a public issue.
Attachments
Reproduction script, transcript, Tamarin theory and prover output: https://gist.github.com/FutureEnterprises/65fc07c1c5e7c8ec6977933aa702381b
What happened?
At
main(e1ea56db72a6385bce3e5c1112b3a56ce60acb43, spec v0.2), no verifier is required to remember that a closed Payment Mandate has already been accepted. Presenting the same user-signed mandate twice yields two payment credentials, two completed checkouts, and two MPP-signed Payment Receipts whosereferenceis the same mandate hash. No signature is forged; the second presentation is a byte-identical copy of the first.This is distinct from #313, #326, #342 and #319, which make
aud/noncechecking mandatory on KB hops. Those checks establish that a presentation was made for this verifier with the nonce it expected. They do not establish that it is the first presentation: in the reproduction below the Credential Provider verifies withexpected_aud="credential-provider"and the correctexpected_nonceboth times, and accepts both times. In AP2 the nonce is chosen by the Shopping Agent (it constructs the OpenID4VP Authorization Request,docs/ap2/agent_authorization.md, "Delegation using OpenID4VP"), so a nonce check cannot distinguish a first presentation from a later one made by that same agent. #308 is about theusedflag on the payment token; here the attacker never reuses a token, it reuses the mandate that mints tokens.Where the specification leaves it
docs/ap2/security_and_privacy_considerations.md:5-8: "all LLMs and Agents MUST be considered potential attackers and are explicitly included in the threat model."docs/ap2/specification.md:237-240andsecurity_and_privacy_considerations.md:109-111: the only mandatory double-spend control is on the Shopping Agent ("MUST NOT present any subsequent open Payment or Checkout Mandates without receiving a rejection receipt"; "MUST avoid signing multiple, overlapping closed Mandates").security_and_privacy_considerations.md:113-114: "Credential Provider, Networks or MPPs MAY reject multiple overlapping Mandates, or invalidate previously issued payment tokens."specification.md:292-342(Verification): chain verification and constraint evaluation only; no rule records an accepted mandate or refuses a repeat.code/sdk/schemas/ap2/payment_mandate.json: required fields arevct,transaction_id,payee,payment_amount,payment_instrument. Nothing is unique per presentation;iat/expare optional.agent_authorization.md:521-535: the Mandate Receipt error list has no "already used" code.Where the reference implementation leaves it
code/samples/python/src/roles/credentials_provider_mcp/server.py:217-241:issue_payment_credentialverifies the chain and then issues unconditionally, storing under both the fresh token and the deterministic mandate hash withstore.update(dict.fromkeys([token, reference], token_data)). A second presentation overwrites the record with a fresh"used": Falseand mints a second token.code/samples/python/src/roles/merchant_agent_mcp/server.py:843-861: the only duplicate check keys on the opaquepayment_token, which is new on every replay.code/samples/python/src/roles/merchant_payment_processor_mcp/server.py:207-302:initiate_paymentrequiresusedto be true, mints a receipt with a freshpayment_id, and writes no state, so a used token presented again is paid again.code/samples/go/pkg/roles/merchant_payment_processor_agent/executor.go:56-85: no signature verification; returnsSUCCESSfor any parsed mandate.Reproduction
Run from
code/samples/pythonwithuv run python repro_ap2_amplification.py(script attached). Every verification, issuance, checkout-completion and receipt step is the sample code's own function, called in-process. The only substitutions are the Merchant-to-MPP HTTP POST (direct call toinitiate_payment) and the MPP-to-CP receipt POST (a recorder).Qef8RJ3tG1QHlAoeiSEe-QWewmd2eVOzayPCVZ7VdlU.issue_payment_credentialcalled twice with identical arguments: both succeed; two tokens, both"used": false, samereference.complete_checkoutwith token 1:status: success, orderc5d78046.... Again with token 2, same checkout mandate, same nonce:status: success, order9f3c3a2f....kid: merchant-payment-processor-key-1), bothreference = Qef8RJ3t..., distinctpayment_id.A Tamarin model of the Direct payment flow (attached) gives the same result: every charge is preceded by a user consent on the exact transaction, payee and amount (the signature does its job), and "at most one charge per mandate" is falsified in 14 steps. With an atomic consume at the processor keyed on
transaction_idit verifies.Proposed fix (PR to follow)
Verification section, Credential Provider and Network: record, in durable state with atomic single-writer semantics, the hash of each accepted closed Payment Mandate (the value already used as the receipt
reference), and reject a closed Payment Mandate whose hash is already recorded with a new terminal errormandate_already_used. Merchant Payment Processor: recordtransaction_idbefore initiating payment and reject repeats the same way. Double Spend section: the verifier bullet becomes MUST. Reference implementation: refuse inissue_payment_credentialwhenreferenceis already present; consumetransaction_idininitiate_paymentbefore minting a receipt. With that patch applied the reproduction refuses at step 2 (mandate_already_used), and a used token replayed straight at the MPP is refused with one receipt minted.If freshness is also wanted, the Key Binding nonce must be issued and stored by the verifier that checks it (OpenID4VP section 5.2), not by the Shopping Agent. Consumption is needed either way: checking a nonce is not remembering it.
Version
AP2 spec v0.2 (2026-04-28); repo commit
e1ea56db72a6385bce3e5c1112b3a56ce60acb43. Reported through g.co/vulnz on 2026-09-01; the security team classified it as intended behavior for program purposes and invited a public issue.Attachments
Reproduction script, transcript, Tamarin theory and prover output: https://gist.github.com/FutureEnterprises/65fc07c1c5e7c8ec6977933aa702381b