Agent Wranglers

API-95Recommended

Bind high-value tokens to the client that holds them

For tokens that move money, read personal data in bulk or act for another party, bind each token to a key the client holds. Use DPoP or mutual TLS, and refuse a bound token sent without proof of that key.

How to do it

A plain bearer token works for anyone who copies it from a log, a proxy or a leaked device. With DPoP (RFC 9449), the client signs a short proof for each request with its own private key. The token carries a hash of the public key in its cnf claim. With mutual TLS (RFC 8705), the token is bound to the client certificate instead. That suits calls between servers, and authenticate calls between your own services covers your own. Reject a DPoP proof whose htm, htu or iat do not match the request, or whose jti you saw before. Keep plain bearer tokens with short lives for low-risk scopes, as Authenticate with tokens in headers sets.

Examples

JavaScript example

import { jwtVerify, calculateJwkThumbprint, importJWK, decodeProtectedHeader } from 'jose';

export async function checkDpop(req, accessTokenClaims, seenProofIds) {
  const proof = req.get('DPoP');
  if (!proof) throw new Unauthorized('DPoP proof missing');
  const { jwk } = decodeProtectedHeader(proof);
  const { payload } = await jwtVerify(proof, await importJWK(jwk), { typ: 'dpop+jwt', maxTokenAge: '60s' });
  const sameRequest = payload.htm === req.method && payload.htu === `https://api.example.com${req.path}`;
  const sameKey = (await calculateJwkThumbprint(jwk)) === accessTokenClaims.cnf?.jkt;
  if (!sameRequest || !sameKey || seenProofIds.has(payload.jti)) throw new Unauthorized('DPoP proof refused');
  seenProofIds.add(payload.jti);
}

Python example

import time

import jwt
from jwt.algorithms import ECAlgorithm

MAX_PROOF_AGE_SECONDS = 60

def check_dpop(method: str, url: str, proof: str | None, token_claims: dict, seen_proof_ids: set[str]) -> None:
    if not proof:
        raise Unauthorized("DPoP proof missing")
    jwk = jwt.get_unverified_header(proof)["jwk"]
    claims = jwt.decode(proof, ECAlgorithm.from_jwk(jwk), algorithms=["ES256"], options={"require": ["iat", "jti"]})
    same_request = claims["htm"] == method and claims["htu"] == url
    same_key = jwk_thumbprint(jwk) == token_claims.get("cnf", {}).get("jkt")
    fresh = abs(time.time() - claims["iat"]) <= MAX_PROOF_AGE_SECONDS
    if not (same_request and same_key and fresh) or claims["jti"] in seen_proof_ids:
        raise Unauthorized("DPoP proof refused")
    seen_proof_ids.add(claims["jti"])

How to check

Call a payment endpoint with a valid DPoP-bound token and no DPoP header. The answer is 401. Send the same proof twice, and the second call gets 401. A proof signed by another key gets 401.

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/api-design/api-security/sender-constrained-tokens">Agent Wranglers API design playbook</a>. In code you write, add a comment next to it with the same link.