Agent Wranglers

AGT-41Recommended

Bind long-lived agent tokens to a key with DPoP

Accept DPoP proofs at your token endpoint and API. A token issued with a proof carries the key's thumbprint in cnf.jkt, and every call with it needs a fresh proof signed by that key.

How to do it

Agents run on servers for hours, and their tokens land in logs, traces and prompt transcripts. A bearer token works for anyone who copies it. A DPoP token works only with the private key, which never leaves the agent. Check each proof's htm and htu against the request, its iat within 60 seconds, and its jti against a cache of recent ones. Require ath, the hash of the access token, in every proof sent to the API. Answer a missing or wrong proof with 401 and WWW-Authenticate: DPoP. The MCP roadmap lists DPoP as work in progress, so keep plain bearer tokens working for MCP clients until the specification adopts it. Keep the tokens short as well, as Give agent grants short lives says.

Examples

JavaScript example

import { calculateJwkThumbprint, jwtVerify, importJWK, decodeProtectedHeader } from 'jose';
const PROOF_MAX_AGE_S = 60;

export async function checkDpop(proof, token, method, url, seenJti) {
  const { jwk } = decodeProtectedHeader(proof);
  const { payload } = await jwtVerify(proof, await importJWK(jwk), { typ: 'dpop+jwt', maxTokenAge: PROOF_MAX_AGE_S });
  const fresh = await seenJti.addIfAbsent(payload.jti, PROOF_MAX_AGE_S);
  return fresh && payload.htm === method && payload.htu === url
    && payload.ath === sha256Base64url(token.raw) && token.cnf?.jkt === await calculateJwkThumbprint(jwk);
}

Python example

import time
import jwt
from jwt.algorithms import ECAlgorithm

PROOF_MAX_AGE_S = 60

def check_dpop(proof: str, token: dict, method: str, url: str, seen_jti) -> bool:
    jwk = jwt.get_unverified_header(proof)["jwk"]
    claims = jwt.decode(proof, ECAlgorithm.from_jwk(jwk), algorithms=["ES256"])
    fresh = abs(time.time() - claims["iat"]) <= PROOF_MAX_AGE_S and seen_jti.add_if_absent(claims["jti"], PROOF_MAX_AGE_S)
    return (fresh and claims["htm"] == method and claims["htu"] == url
            and claims["ath"] == sha256_base64url(token["raw"]) and token["cnf"]["jkt"] == jwk_thumbprint(jwk))

How to check

Get a DPoP-bound token for a test agent. A call with the token and no proof gets 401. A proof for another URL gets 401. The same proof sent twice works once.

Sources

For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/agents-as-users/delegated-access-and-consent/dpop-bound-agent-tokens">Agent Wranglers agents as users playbook</a>. In code you write, add a comment next to it with the same link.