DPoP
DPoP (Demonstrating Proof-of-Possession, RFC 9449) binds an access token to a key the client holds. A stolen token is inert without the matching private key — the single most important property for agent tokens that travel between services.
Plain Bearer still works for clients that do not opt in. DPoP is per-request opt-in by the client,
not a mode the server has to be put into.
Request a bound token
Section titled “Request a bound token”Send a DPoP proof with the token request. The issued token comes back with token_type: "DPoP" and
a confirmation claim naming your key:
POST /tokenDPoP: <proof JWT>Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=tool:search&resource=https://tools.acme.com/mcp{ "access_token": "eyJ…", "token_type": "DPoP", "expires_in": 3600, "scope": "tool:search"}The access token carries the SHA-256 thumbprint of your public key:
{ "sub": "did:key:z6Mk…", "scope": "tool:search", "aud": "https://tools.acme.com/mcp", "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }, "exp": 1750003600}The proof
Section titled “The proof”A proof is a short-lived JWT signed by your private key. Supported algorithms: ES256, EdDSA,
RS256.
Header — typ must be dpop+jwt, and the public key travels in jwk:
{ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }}Payload — the method, the URL, a fresh timestamp, and a unique id:
{ "htm": "POST", "htu": "https://tools.acme.com/mcp/tools/search", "iat": 1750000000, "jti": "b3f1c0a2-…"}Sign a new proof for every request. It is bound to that request’s method and URL, so it cannot be reused for another.
Presenting it
Section titled “Presenting it”POST /mcp/tools/search HTTP/1.1Authorization: DPoP eyJ…DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…The scheme changes from Bearer to DPoP. Presenting a bound token as Bearer is rejected.
Validation rules
Section titled “Validation rules”Every one of these must hold, or the request fails with invalid_dpop_proof:
| Check | Detail |
|---|---|
typ | Must be dpop+jwt |
alg | One of ES256, EdDSA, RS256 |
| Signature | Verifies against the jwk in the proof header |
| Thumbprint | The jwk thumbprint must equal the token’s cnf.jkt |
htm | Matches the HTTP method (case-insensitive) |
htu | Matches scheme, host, and path. Query string and fragment are excluded |
iat | No older than 60 seconds |
jti | Single-use — a (key, jti) pair is accepted once within a 90-second window |
Two of these trip people up regularly. htu excludes the query string, so
https://api.example.com/search?q=x proves as https://api.example.com/search — including the
query will fail. And jti really is single-use, so a client that retries a failed request must
generate a fresh proof rather than replaying the one it just sent.
The 60-second iat window means client clock skew shows up as an authentication failure. If proofs
are being rejected intermittently, check the clock before checking the code.
Refresh tokens inherit the binding
Section titled “Refresh tokens inherit the binding”A refresh token issued under a DPoP-bound request inherits the key thumbprint (RFC 9449 §5). Refreshing then requires a proof from the same key:
{ "error": "invalid_grant", "error_description": "refresh token is bound to a different DPoP key" }So the binding survives rotation: an attacker with a stolen refresh token cannot mint fresh access tokens without the key either.
Where proofs are required
Section titled “Where proofs are required”/userinfo, /credentials/*, and /mcp/tools/* all require a fresh, matching proof when the
presented token is DPoP-bound. The demo MCP server advertises
dpop_bound_access_tokens_required: false in its protected-resource metadata, meaning it accepts
both — your own resource server can require them.
Defence in depth
Section titled “Defence in depth”DPoP stops a stolen token from being used. It pairs with two other mechanisms:
- Refresh reuse detection (RFC 9700) — if a rotated refresh token is replayed, the whole family is revoked. Sender-binding prevents theft from paying off; reuse detection cleans up after it.
- Resource indicators (RFC 8707) — even a valid, key-bound token is refused by a service it was not minted for.
For an agent, all three together mean a leaked token is bound to a key it does not have, scoped to a service it is not calling, and detectable if it tries.