DPoP
TL;DR — Complete spec-level reference for AuthPlane’s DPoP (RFC 9449) implementation: proof JWT structure, allowed algorithms + always-rejected ones,
htu/htm/ath/jtisemantics, nonce flow, replay detection, HTU derivation traps (reverse-proxy pitfall), and configuration knobs. If you’re enabling DPoP, start at Guides: Enable DPoP end-to-end — this page is the security depth.
Why DPoP
A regular Bearer token is like a house key — whoever holds it can use it. DPoP binds each token to a specific key pair the client holds and proves possession of on every request. Steal the token from a log, network tap, or compromised proxy → useless without the private key.
Enable when: tokens transit multiple services, high-security environments, compliance requires proof-of-possession, network traffic might be observable.
Skip when: local dev, everything behind mTLS on trusted internal network, tokens have very short expiry (< 5 min), added complexity isn’t worth the residual-risk reduction.
Performance: ~1 ms crypto per request. Proof ~500 bytes.
Proof JWT — headers
Proof JWT — claims
ath is required at the resource server (your MCP server verifying the token), but omitted at the token endpoint (client doesn’t have the token yet).
Allowed algorithms
Always rejected (security invariants):
alg: none— allows unsigned proofs (trivially forgeable)HS256,HS384,HS512— symmetric algorithms; verifier would need signing key, defeating proof-of-possession- Private key in
jwkheader — would leak private key to the server
HTU derivation — the reverse-proxy trap
The server derives htu from the incoming request, applies RFC 3986 syntax/scheme normalization (lowercased scheme, lowercased host, percent-encoding decode where reserved chars aren’t affected, default-port stripped), and only then compares against the proof’s normalized htu. This is where reverse proxies silently break DPoP.
Server derivation:
htu = <scheme>://<authority><path> # authority = host[:port]; port dropped when default
scheme— fromX-Forwarded-Protoheader (honored) or the connection schemeauthority— host (and non-default port) from the incomingHostheader (AuthPlane does not consultX-Forwarded-Host)path— from the request line (query + fragment stripped)
Reverse-proxy gotcha: a proxy that strips or forgets to set X-Forwarded-Proto: https when TLS-terminating in front causes the derived htu to be http://... while the client’s proof says https://.... A proxy that rewrites Host — replacing the public hostname with the upstream — likewise causes host-mismatch on every request. Either way, every DPoP request → 400 invalid_dpop_proof. Symptom looks identical to a client bug.
Fix: configure your reverse proxy to set X-Forwarded-Proto for the scheme AND to preserve the original Host header. Nginx default rewrites Host to the upstream server, so add proxy_set_header Host $host;. Caddy preserves Host by default. Full configs in Operate: Standalone → Reverse proxy.
Test: log the normalized derived htu on your MCP server; compare against the normalized proof htu. A mismatch survives normalization — the two forms must be structurally different, not just differently punctuated.
JTI replay detection
Every proof’s jti is stored in dpop_nonces on first successful verification. A second request with the same jti → invalid_dpop_proof "DPoP proof jti has already been used".
Retention: dpop.proof_lifetime (default 60 s). Proofs older than that are rejected on the iat check anyway, so their JTIs no longer need to be tracked.
The dpop_nonces table grows unbounded without scheduled purging — schedule authserver purge --only=dpop-nonces externally. See Operate: Backup, upgrade, purge.
Server-issued nonces (require_nonce)
Optional extra layer. When dpop.require_nonce: true, every proof must include a nonce claim matching a server-issued value.
Flow — note the AS uses the RFC 6749 §5.2 error envelope (not WWW-Authenticate: DPoP, which is the resource-server form from RFC 9449 §9):
1. Client → AS proof without nonce
2. AS → Client HTTP 400 Bad Request (RFC 9449 §8)
DPoP-Nonce: <server-generated>
Content-Type: application/json
{ "error": "use_dpop_nonce",
"error_description": "Authorization server requires nonce in DPoP proof" }
3. Client → AS fresh proof, this time with nonce=<server-generated>
4. AS → Client 200 (proof accepted)
Server nonce = HMAC-signed random value with dpop.nonce_ttl expiry (default 60 s). SDKs read DPoP-Nonce from the response, cache the current value, and include it in every subsequent proof.
Trade-off: extra round trip on first request per session. Worth it for high-security deployments where you want proofs bound to server-controlled time windows narrower than the client’s clock resolution.
Access-token-hash (ath)
At resource servers, proofs include ath = base64url(SHA-256(access_token)). Binds the proof to the specific token being presented — even if an attacker captures both proof and token, they can’t substitute a different token behind the same proof.
Missing ath at a resource server → invalid_dpop_proof. Missing at the token endpoint is expected (client doesn’t have the token yet).
cnf.jkt in access tokens
DPoP-bound access tokens carry a confirmation claim:
{
...
"cnf": { "jkt": "0iaeMlxbX3MR9k0DBjKBR9HBkfHTwIsuw_2dPjsLwBE" }
}
jkt = base64url-encoded SHA-256 thumbprint of the client’s public JWK, per RFC 7638. Resource-server verification: jkt in the token must match SHA-256(jwk) in the proof.
Configuration
dpop:
enabled: true # default false — additive when off
proof_lifetime: 60s # 10s..300s
nonce_ttl: 60s # server nonce validity
require_nonce: false # true = extra round-trip, tighter replay window
Env-var equivalents in Reference: Configuration → DPoP.
dpop_signing_alg_values_supported: ["ES256", "RS256", "PS256"] is advertised in /.well-known/oauth-authorization-server when dpop.enabled: true.
Metrics
authplane_dpop_proofs_validated_total— successful verificationsauthplane_dpop_proofs_rejected_total— rejections, labelled by reason (replay,nonce_missing,nonce_invalid,htu_mismatch,htm_mismatch,iat_out_of_window,signature_invalid,disallowed_alg,private_key_in_header)
rate(authplane_dpop_proofs_rejected_total{reason="replay"}[5m]) > 0 in normal ops = client bug or attack. Alert.
Security invariants (T14, T15)
From threat model:
- T14 · DPoP proof replay — mitigated by
jtistore +proof_lifetime+ optional nonce. - T15 · DPoP algorithm confusion — mitigated by hardcoded allowlist of
ES256/RS256/PS256;alg: none, symmetric algorithms, and private-key-in-header always rejected.
Related
- Guides: Enable DPoP end-to-end — actionable enablement guide
- Concepts: DPoP — mental model + when to enable
- SDKs: Python — the PrefectHQ fastmcp DPoP caveat
- Reference: Configuration → DPoP
- Reference: Errors → DPoP codes
- Security: Threat model → T14/T15
- Full source in authserver repo — extended text