"Token" is an overloaded word in agent payments. People use it for the signed authorization a user gives an agent, for the network token that replaces a card number, for the JWT a merchant signs over a cart, and for the OAuth access token an agent uses to call an API. If you build an Agent Payments Protocol (AP2) integration without separating them, you end up verifying the wrong signature or logging the one secret that should never leave a vault.
This article sorts out the tokens that appear in an AP2 purchase and then goes down to the bytes of the most important ones, the mandates, which AP2 encodes as SD-JWTs (selective-disclosure JSON Web Tokens). It follows AP2 v0.2 as documented at ap2-protocol.org on 2026-10-01; AP2 is young, so pin the version you implement. What the mandates mean, and who signs them in which flow, is covered in AP2 mandates; this page is about format: how to build, present and check them, and where the specification stops.
The token inventory
An AP2 v0.2 purchase moves six kinds of signed or secret objects. Knowing which party creates, holds and verifies each one is the first design decision, because it decides where keys live and what may be logged.
| Object | Format | Created by | Verified by |
|---|---|---|---|
checkout_jwt | JWT signed by the merchant over the checkout payload | Merchant | Agent, user's Trusted Surface, anyone holding the Checkout Mandate |
Open mandate (mandate.checkout.open.1, mandate.payment.open.1) | SD-JWT with constraints and a cnf key | Signed by the user, via a non-agentic Trusted Surface | Merchant, Credential Provider |
Closed mandate (mandate.checkout.1, mandate.payment.1) | SD-JWT content bound to one transaction | User (human present) or the agent with the endorsed key (human not present) | Merchant, Credential Provider |
| Key-binding JWT | JWT, typ kb+jwt, per presentation | Holder of the cnf key | Each verifier |
| Receipts | JWT signed by the verifier that issues them | Merchant (checkout), payment processor (payment) | Agent, Credential Provider |
| Payment credential | Not specified by AP2 | Credential Provider | Merchant and its processor, via the rail |
The vct values are from the checkout and payment mandate pages. Note what is absent: AP2 defines no bespoke "payment token" structure carrying an amount, a merchant and a nonce. The amount and payee live in the closed Payment Mandate; the money-moving credential is whatever the payment rail uses.
Anatomy of an SD-JWT
An SD-JWT in compact form is a string of parts separated by tildes: an issuer-signed JWT, zero or more disclosures, and finally either an empty string or a key-binding JWT. So <issuer-jwt>~<d1>~<d2>~ is a presentation without key binding and <issuer-jwt>~<d1>~<d2>~<kb-jwt> is one with it. The issuer signs the JWT once; the holder decides later which disclosures to include for each verifier.
A disclosure is the base64url encoding of a small JSON array. For an object property it is [salt, claim_name, value]; for an array element it is [salt, value]. The signed payload does not contain the value, only its digest: the base64url-encoded hash of the disclosure string's ASCII bytes, using the algorithm named in _sd_alg (SHA-256 when absent). Property digests sit in an _sd array in the object that would have held the claim; array-element digests replace the element with {"...": digest}. AP2's open Payment Mandate example uses exactly that shape inside the allowed list of a payment.allowed_payees constraint, so each permitted merchant is individually disclosable.
import base64, hashlib, json, secrets
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def make_disclosure(*parts):
'''parts = (name, value) for a property, (value,) for an array element.'''
salt = b64url(secrets.token_bytes(16)) # salt MUST have enough entropy
disclosure = b64url(json.dumps([salt, *parts], separators=(",", ":")).encode())
return disclosure, digest(disclosure)
def digest(disclosure: str, alg: str = "sha-256") -> str:
if alg != "sha-256":
raise ValueError("unsupported _sd_alg: " + alg)
return b64url(hashlib.sha256(disclosure.encode("ascii")).digest())
# Holder side: an allowed-payees constraint with two merchants, each disclosable alone.
d_a, h_a = make_disclosure({"id": "merchant-a", "name": "Grocer A"})
d_b, h_b = make_disclosure({"id": "merchant-b", "name": "Grocer B"})
constraint = {"type": "payment.allowed_payees", "allowed": [{"...": h_a}, {"...": h_b}]}Two details trip implementations. The digest is over the disclosure string exactly as encoded, so a verifier must hash the received text, never a re-serialization of the decoded JSON. And the salt is what keeps an undisclosed merchant private: without it, anyone could hash candidate merchant objects and test them against the digests.
Checking a presentation
A verifier splits the presentation, verifies the issuer JWT's signature, decodes each disclosure, recomputes its digest, and requires every digest to appear exactly once in the signed payload. Disclosures that match nothing are rejected, not ignored. Then it rebuilds the claims, replacing each disclosed digest with its value and dropping the rest.
def check_disclosures(payload: dict, disclosures: list) -> dict:
alg = payload.get("_sd_alg", "sha-256")
by_digest = {}
for d in disclosures:
h = digest(d, alg)
if h in by_digest:
raise ValueError("duplicate disclosure")
by_digest[h] = json.loads(b64url_decode(d))
used = set()
def walk(node):
if isinstance(node, dict):
out = {k: walk(v) for k, v in node.items() if k not in ("_sd", "_sd_alg")}
for h in node.get("_sd", []):
if h in by_digest: # property disclosure
_, name, value = by_digest[h]
used.add(h)
out[name] = walk(value)
return out
if isinstance(node, list):
out = []
for item in node:
if isinstance(item, dict) and set(item) == {"..."}:
h = item["..."]
if h in by_digest: # array-element disclosure
used.add(h)
out.append(walk(by_digest[h][1]))
continue # undisclosed or decoy: drop
out.append(walk(item))
return out
return node
claims = walk(payload)
if used != set(by_digest):
raise ValueError("disclosure not referenced by the signed payload")
return claimsThis is a teaching sketch: production code must also reject a property disclosure whose name collides with a visible claim, and must verify the issuer signature before trusting payload at all. Use a maintained SD-JWT library where one exists for your stack, and keep tests like these around it.
Key binding: cnf, KB-JWT and sd_hash
Selective disclosure alone proves only that someone holds a validly issued token. Key binding proves the presenter holds a specific private key. The issuer puts the holder's public key in a cnf claim, as a JWK, in the signed payload. At presentation time the holder signs a key-binding JWT with that key. The SD-JWT RFC (RFC 9901) defines its header typ as kb+jwt and its claims iat, aud, nonce and sd_hash, the base64url hash of everything before it: the issuer JWT and the chosen disclosures, each followed by a tilde.
def check_key_binding(presentation: str, payload: dict, verify_jws, expected_aud, expected_nonce):
prefix, kb_jwt = presentation.rsplit("~", 1)
prefix += "~"
if not kb_jwt:
raise ValueError("key binding required but missing")
header, kb = verify_jws(kb_jwt, payload["cnf"]["jwk"]) # your JOSE library
if header.get("typ") != "kb+jwt":
raise ValueError("wrong typ")
if kb.get("aud") != expected_aud or kb.get("nonce") != expected_nonce:
raise ValueError("KB-JWT not addressed to this verifier or this request")
if kb.get("sd_hash") != digest(prefix, payload.get("_sd_alg", "sha-256")):
raise ValueError("sd_hash does not match the presented disclosures")
return kbBecause sd_hash covers the prefix, an attacker cannot add or remove disclosures without invalidating the signature, and in plain SD-JWT presentations, where aud and nonce are chosen by the verifier, a presentation captured from one verifier fails at another. The AP2 pages examined describe key binding as proof of possession and transaction binding but do not specify how AP2 populates those two claims, so check the Delegate SD-JWT specification AP2 references before relying on them.
AP2 uses this machinery for delegation. In the human-not-present flow the user signs an open mandate whose cnf endorses the agent's key; later the agent uses that key to bind closed mandate content to a specific checkout. The agent authorization page calls the result a chain and requires verifiers to verify it, check that claims fixed in the open mandate are unchanged in the closed content, and evaluate every constraint, treating unknown constraints as failing. Consult that page for the exact nesting; the important point here is that the agent never holds the user's key, only a key the user endorsed under constraints.
Disclose per verifier, hash exact bytes
Selective disclosure is not optional decoration in AP2: the security page says it MUST be used to preserve user privacy, the Trusted Surface MAY insert decoy digests so a verifier cannot count undisclosed entries, and digests MUST include a salt with sufficient entropy. In practice the merchant, which verifies the Checkout Mandate, receives the disclosure naming itself in the allowed-merchants constraint and nothing about the other merchants; the Credential Provider, which verifies the Payment Mandate, receives the payee, instrument and amount it needs without seeing the basket.
Three identifiers are hashes over exact bytes: checkout_hash and the Payment Mandate's transaction_id are both the base64url hash of the checkout_jwt value, and a receipt's reference is the hash of the closed mandate it answers. Store tokens as received strings, hash those strings, and never reconstruct them from parsed JSON. How these hashes key purchases and attempts is covered in AP2 payment flow.
The payment credential is not an AP2 token
The pages examined do not define the format of the payment credential the Credential Provider produces. They do set a release rule: the payment credential or token MUST ONLY be released to the merchant upon receipt and verification of a final Payment Mandate. Everything else depends on the rail. For cards that is commonly a network token with a per-transaction cryptogram, and for other rails a processor token or an initiation through a payment initiation service provider; that is engineering practice, not AP2 text. See tokenization for how network tokens work.
Treat this object differently from mandates. Mandates are evidence: store them for disputes, show them to the user. The payment credential is a secret: never log it, never pass it through the language model's context, and scope it to one merchant and one transaction where the rail allows.
Lifetimes and replay: what you get and what you build
Mandates may carry iat and exp. In SD-JWT, the KB-JWT's aud and nonce can bind a presentation to one verifier and one request. The AP2 pages examined do not, however, define a single-use rule or a replay cache for mandates. Build it: record the hash of each closed mandate a verifier accepts and refuse a second presentation of the same one, reject expired mandates with a small clock-skew allowance, check audience and nonce wherever your deployment populates them, and enforce payment.budget and payment.agent_recurrence counters atomically at one verifier rather than trusting the agent's count.
Worked example: one weekly-grocery presentation
The user signs two open mandates on the Trusted Surface. The open Checkout Mandate carries a checkout.allowed_merchants constraint naming Grocer A and Grocer B, each as an array-element disclosure. The open Payment Mandate carries a payment.allowed_payees list with the same two grocers, one allowed instrument, a payment.amount_range of 0 to 80 euros and a budget. Both cnf claims hold the agent's P-256 public key, and the Trusted Surface adds one decoy digest to each list.
On Thursday the agent checks out at Grocer B for 19.90 euros. The merchant's checkout_jwt is hashed to give the checkout_hash and the transaction_id, and the agent binds closed mandate content to that checkout with its key. The merchant verifies the Checkout Mandate: it receives the open mandate's issuer JWT with only the Grocer B disclosure, recomputes that digest, finds it among three entries in allowed (it cannot tell that one of the others is a decoy), checks the key binding, and confirms the closed checkout names Grocer B.
The Credential Provider verifies the Payment Mandate: it receives the Grocer B payee disclosure plus the instrument and amount, evaluates every constraint against the closed content, and never sees Grocer A or the basket. It reserves budget and only then releases the payment credential. If the same closed mandate is presented again, the provider's replay record rejects it. Whether your deployment also asks each verifier for its own nonce is a choice to settle with your counterparties; AP2's pages examined do not prescribe one.
Failure modes
| Failure | What goes wrong | Defence |
|---|---|---|
| Hashing re-serialized JSON | Digests never match, or match the wrong bytes | Hash received strings exactly |
| Ignoring unreferenced disclosures | Injected claims slip into the rebuilt object | Reject any disclosure whose digest is not in the payload |
| No replay record | The same closed mandate is accepted twice | Store accepted mandate hashes; check aud and nonce where present |
| Low-entropy salts | Undisclosed merchants can be guessed | Cryptographically random salts of at least 128 bits |
| Over-disclosure by the agent | Merchants learn the user's whole brief | Minimal disclosure set per verifier |
| Credential in model context or logs | Secret leakage | Credential handled only by deterministic code |
Trade-offs
| Decision | Option A | Option B |
|---|---|---|
| SD-JWT implementation | Library: maintained, fewer edge-case bugs | Own code: full control, high risk of subtle verification gaps |
| Replay protection | Replay cache of accepted mandate hashes: simple, needs shared storage | Short exp only: stateless, leaves a replay window |
| Decoys | Add decoys: hides list size, larger tokens | No decoys: smaller, leaks how many entries exist |
What to do next
- Write down the token inventory for your role and mark each object as evidence (store) or secret (never log).
- Store every JWT and SD-JWT as the received string, and compute all hashes over those bytes.
- Adopt an SD-JWT library and wrap it with tests for unreferenced, duplicate and tampered disclosures and a wrong
sd_hash. - Require key binding on every open mandate, and agree with your counterparties how audience and nonce are populated, following the Delegate SD-JWT specification AP2 references.
- Add a replay record of accepted closed-mandate hashes and atomic budget counters at one verifier.
- Keep the payment credential out of the agent's language-model context entirely.