dpop in your MCP config nothing changes.
Turn it on
Both metadata documents then list
dpop_signing_alg_values_supported. The protected resource document also sets dpop_bound_access_tokens_required when required is on.
What the token endpoint does
When a request carries aDPoP header, the endpoint verifies the proof (typ, algorithm, public jwk, signature, htm, htu, iat, jti replay) and issues an access token with cnf: { jkt } and token_type: "DPoP". Without a header you get a normal bearer token, unless required is set.
The proof is checked before the authorization code is consumed. When requireNonce is on and the proof has no valid nonce, the endpoint answers 400 use_dpop_nonce with a DPoP-Nonce header and leaves the code alone, so the client retries with the same code and a fresh proof.
Refresh tokens inherit the binding. A refresh request signed by a different key is refused before the token rotates, so a thief cannot burn the real client’s token. Your storeToken callback should persist dpopJkt from the stored record, otherwise the binding is lost on refresh.
The built-in createMcpResponseHelpers(...).tokenResponse and the Hono and Express adapters copy the nonce into the DPoP-Nonce header. If you write your own token route, read result.error.details?.dpopNonce.
Protect a handler
requireMcpAuth wraps any (Request) => Response function. It accepts Authorization: Bearer and, with DPoP enabled, Authorization: DPoP.
Authorization: DPoP <token> plus DPoP: <proof> where the proof is signed for the exact method and URL and includes ath, the base64url SHA-256 of the token. Use a new jti every time.
What gets checked
- Token: signature, issuer, expiry, audience against
expectedAudienceorMcpConfig.resource, the jti denylist. - Proof:
htm,htu(query and fragment ignored),iatwindow,ath, nonce,jtinot seen before, and the proof key matchingcnf.jkt. - Bearer downgrade: a bound token sent as
Beareris refused. This also coversvalidateToken,middleware,withMcpAuthandrequireScopes. - A
DPoPscheme request carrying an unbound token is refused. - Scopes: a shortfall returns 403
insufficient_scope.
requiredScopes, expectedAudience, resourceUrl (the public URL clients sign, for proxies), resourceMetadataUrl, requireDpop, requireNonce.
Challenges
With no credentials the response is 401 withBearer resource_metadata="..." and, if DPoP is on, a second DPoP algs="ES256 ...", resource_metadata="..." header. Bad tokens return error="invalid_token", bad proofs error="invalid_dpop_proof", and a missing nonce error="use_dpop_nonce" with a DPoP-Nonce header. The resource_metadata URL points at the RFC 9728 document so MCP clients can find your authorization server.
The principal
Limits
- The replay cache is only as strict as its store. Cloudflare KV is not atomic, so two simultaneous replays can both pass. Use Redis or the database store when that matters.
- The
dpop_jktauthorization request parameter (binding the code to a key up front) is not implemented. The binding starts at the token request. - Unbound refresh tokens issued before you enabled DPoP are bound to whatever key first refreshes them. Rotate them out by setting
requiredafter a refresh cycle.