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/jti semantics, 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

HeaderValueNotes
typdpop+jwtExact string. Any other typ → rejected.
algES256, RS256, or PS256See allowed algorithms below.
jwkClient’s public key as JWKPrivate key in jwk → always rejected.

Proof JWT — claims

ClaimRequiredSemantics
jtiAlwaysUnique UUID. Server rejects reused jti.
htmAlwaysHTTP method (POST, GET, …). Exact match against actual request.
htuAlwaysHTTP target URL — the request URI with query and fragment stripped (scheme + authority + path; authority includes port when non-default). Compared after RFC 3986 syntax/scheme normalization. See HTU derivation below.
iatAlwaysUnix timestamp. Server checks |now − iat| ≤ proof_lifetime.
nonceWhen server requires itValue from DPoP-Nonce response header on prior 401.
athAt resource serverbase64url(SHA-256(access_token)) — binds proof to token.

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

AlgorithmKey typeStatus
ES256ECDSA P-256Recommended — compact proofs (~200 bytes), fast signing
RS256RSA 2048+Widely supported, larger proofs (~350 bytes)
PS256RSA-PSS 2048+PSS-padding variant

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 jwk header — 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 — from X-Forwarded-Proto header (honored) or the connection scheme
  • authority — host (and non-default port) from the incoming Host header (AuthPlane does not consult X-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 jtiinvalid_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 verifications
  • authplane_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 jti store + 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.