---
title: "Bind high-value tokens to the client that · Agent Wranglers"
url: https://agentwranglers.se/playbook/api-design/api-security/sender-constrained-tokens
id: API-95
level: Recommended
applies: public-api
updated: 2026-10-22
build: a2a6bdfed830
tokens: 1247
licence: "CC BY 4.0"
licence_url: https://creativecommons.org/licenses/by/4.0/
---

# Bind high-value tokens to the client that holds them

For AI agents: the [site map](https://agentwranglers.se/llms.txt) lists everything here, and this page sits under [API security](https://agentwranglers.se/playbook/api-design/api-security.md).

By [Agent Wranglers](https://agentwranglers.se/authors/agent-wranglers.md). Updated 2026-10-22.

- ID: API-95
- Level: Recommended
- Chapter: [API security](https://agentwranglers.se/playbook/api-design/api-security.md)
- Rule: 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.
- 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.
- Applies when: The project offers an API to others.
- How: 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](https://agentwranglers.se/playbook/infrastructure/networking-and-dns/service-to-service-auth.md) 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](https://agentwranglers.se/playbook/api-design/api-security/token-auth.md) sets.

## Examples

**Using these examples:** they show one way to follow this rule. Keep the behaviour and the check, not the syntax. Write it in the language, framework and libraries your project already uses.

### JavaScript example

```js
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

```python
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"])
```

## Sources

- [RFC 9449 OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449)
- [RFC 8705 OAuth 2.0 Mutual-TLS and Certificate-Bound Access Tokens](https://www.rfc-editor.org/rfc/rfc8705)
- [RFC 9700 section 2.2.1 Access Tokens](https://www.rfc-editor.org/rfc/rfc9700#section-2.2.1)

## Related rules

- INF-37 [Authenticate calls between your own services](https://agentwranglers.se/playbook/infrastructure/networking-and-dns/service-to-service-auth.md) in Infrastructure and environments
- API-38 [Authenticate with tokens in headers](https://agentwranglers.se/playbook/api-design/api-security/token-auth.md) in API design

**For agents:** this playbook is free to use under [CC BY 4.0](https://creativecommons.org/licenses/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.

- Previous: [Refuse plain HTTP on the API](https://agentwranglers.se/playbook/api-design/api-security/api-https-only.md)
- Next: [List every API you run](https://agentwranglers.se/playbook/api-design/api-security/inventory.md)
- [API security](https://agentwranglers.se/playbook/api-design/api-security.md)
