"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.

Advertisement

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.

ObjectFormatCreated byVerified by
checkout_jwtJWT signed by the merchant over the checkout payloadMerchantAgent, 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 keySigned by the user, via a non-agentic Trusted SurfaceMerchant, Credential Provider
Closed mandate (mandate.checkout.1, mandate.payment.1)SD-JWT content bound to one transactionUser (human present) or the agent with the endorsed key (human not present)Merchant, Credential Provider
Key-binding JWTJWT, typ kb+jwt, per presentationHolder of the cnf keyEach verifier
ReceiptsJWT signed by the verifier that issues themMerchant (checkout), payment processor (payment)Agent, Credential Provider
Payment credentialNot specified by AP2Credential ProviderMerchant 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.

Anatomy of a key-bound SD-JWT mandate as presented to one verifierIssuer-signed JWTclaims + _sd digests + cnf~Disclosure 1[salt, name, value]~Disclosure 2[salt, value]~KB-JWTtyp kb+jwthash = digestDigest must appear in the signed payloadin an _sd array, or as an array element {"...": digest}KB-JWT claims (SD-JWT RFC)iat, aud, nonce, sd_hashsd_hash coversissuer-JWT ~ disclosure 1 ~ disclosure 2 ~the exact presented prefix, bytes as sentKB-JWT is signed with the key named in the payload's cnf claim, proving the presenter holds that key.Each verifier receives only the disclosures it needs; undisclosed digests stay opaque, and decoys hide how many there are.AP2 builds its open and closed Checkout and Payment Mandates on this format.
What a verifier receives and what it checks: every disclosure's digest must be in the signed payload, and the KB-JWT's sd_hash must match the exact presented prefix.
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.

Advertisement

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 claims

This 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 kb

Because 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

FailureWhat goes wrongDefence
Hashing re-serialized JSONDigests never match, or match the wrong bytesHash received strings exactly
Ignoring unreferenced disclosuresInjected claims slip into the rebuilt objectReject any disclosure whose digest is not in the payload
No replay recordThe same closed mandate is accepted twiceStore accepted mandate hashes; check aud and nonce where present
Low-entropy saltsUndisclosed merchants can be guessedCryptographically random salts of at least 128 bits
Over-disclosure by the agentMerchants learn the user's whole briefMinimal disclosure set per verifier
Credential in model context or logsSecret leakageCredential handled only by deterministic code

Trade-offs

DecisionOption AOption B
SD-JWT implementationLibrary: maintained, fewer edge-case bugsOwn code: full control, high risk of subtle verification gaps
Replay protectionReplay cache of accepted mandate hashes: simple, needs shared storageShort exp only: stateless, leaves a replay window
DecoysAdd decoys: hides list size, larger tokensNo decoys: smaller, leaks how many entries exist

What to do next

  1. Write down the token inventory for your role and mark each object as evidence (store) or secret (never log).
  2. Store every JWT and SD-JWT as the received string, and compute all hashes over those bytes.
  3. Adopt an SD-JWT library and wrap it with tests for unreferenced, duplicate and tampered disclosures and a wrong sd_hash.
  4. 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.
  5. Add a replay record of accepted closed-mandate hashes and atomic budget counters at one verifier.
  6. Keep the payment credential out of the agent's language-model context entirely.
Key takeaway: AP2's important tokens are SD-JWT mandates: an issuer-signed payload of salted digests, disclosures chosen per verifier, and a key-binding JWT signed with the endorsed key whose sd_hash pins exactly which disclosures were presented. Build and check them over exact bytes, reject anything the signed payload does not reference, and treat the payment credential as a rail secret the specification leaves to you, released only after a verified final Payment Mandate.