Skip to content

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.

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 /token
DPoP: <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
}

A proof is a short-lived JWT signed by your private key. Supported algorithms: ES256, EdDSA, RS256.

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

POST /mcp/tools/search HTTP/1.1
Authorization: DPoP eyJ…
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…

The scheme changes from Bearer to DPoP. Presenting a bound token as Bearer is rejected.

Every one of these must hold, or the request fails with invalid_dpop_proof:

CheckDetail
typMust be dpop+jwt
algOne of ES256, EdDSA, RS256
SignatureVerifies against the jwk in the proof header
ThumbprintThe jwk thumbprint must equal the token’s cnf.jkt
htmMatches the HTTP method (case-insensitive)
htuMatches scheme, host, and path. Query string and fragment are excluded
iatNo older than 60 seconds
jtiSingle-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.

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.

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

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.