Skip to the content.

Your agent’s spending mandate is not being audience-checked

An agent that can spend money holds a mandate: who to pay, how much, in which currency, until when. Google handed that mandate a real specification — AP2, Agent Payments Protocol — which went to the FIDO Alliance on 28 April 2026.

The interesting question is not whether the specification is good. It is whether the reference implementation enforces it.

The claim in the code

verify_chain in the AP2 Python SDK documents:

enforcing expected_aud / expected_nonce on the terminal hop when provided

So the chain verifier says it checks the audience on the last hop. That is exactly the hop where a redirect would happen.

What the code does

kb_sd_jwt.verify calls verify_expected_claims only under this condition:

if typ in TYP_TERMINAL:
    verify_expected_claims(...)

And here is the problem. A kb+sd-jwt+kb token — the key binding format, which the SDK itself produces whenever the payload carries a cnf claim — is classified as TYP_INTERMEDIATE, not TYP_TERMINAL.

So for any mandate that binds a key, the audience check is skipped.

Reproducer

hop type      : kb+sd-jwt+kb
aud in token  : https://attacker.example/ap2
expected_aud  : https://honest-merchant.example/ap2

result        : ACCEPTED

A token minted for attacker.example is accepted by a verifier that was told to expect honest-merchant.example. No error. No warning. The verification returns success.

Why this matters more than a normal bug

Most protocol bugs are wrong arithmetic. This one is a missing branch on the security path, in the reference implementation, in the language with the only working verifier.

A verifier that accepts the wrong audience cannot detect a redirected mandate. The attacker does not need to forge a signature. They need the merchant’s public key, which is public by design, and a mandate that was never bound to them.

The scope, stated honestly

What to do about it

  1. Implementers: do not treat verify_chain as an audience check. Check aud yourself on every hop, in every language.
  2. Protocol authors: the gap is in classification, not in cryptography. Either kb+sd-jwt+kb counts as terminal, or expected_aud is enforced unconditionally.
  3. Agent builders: if your agent holds a spending mandate, verify the mandate continuously, not once at signature. That is what drift does — six rules, deny by default, checked before each action.
  4. FIDO members: this is an implementation-review item for the working group.

Verify it yourself

The finding came from reading one file and writing one script, not from a research budget:

pip install drift
python3 test_drift.py

The negative vectors in the contribution reproduce the acceptance. The Go verifier exists so the claim does not rest on a single implementation.