Enable DPoP end-to-end

TL;DR — Three switches: AS-side (AUTHPLANE_DPOP_ENABLED=true), SDK-side (inbound_dpop: {required: true} in your adapter’s options), and Python authplane-mcp needs install_request_context(mcp). If any one is missing, you either accept unbound Bearer tokens (fine for dev) or fail closed with 401 invalid_dpop_proof (broken for the wrong reason). This guide walks the full setup, gotchas included.

What you get

Regular Bearer tokens are like house keys — whoever has them can use them. DPoP (RFC 9449) binds each token to a public key the client holds and proves possession of on every request. Steal the token from a log, a network tap, or a compromised proxy → useless without the private key.

  • When to enable: production, multi-hop deployments, environments where token leak is a realistic threat, compliance requirements.
  • When to skip: local dev, single-tenant lab, everything behind mTLS on a trusted network. See Concepts: DPoP for the trade-off.
  • Performance: ~1 ms of crypto per request. Proofs are ~500 bytes.

Deep spec detail lives in Security: DPoP. This page is the enablement guide.

The three switches

1. AS-side — issue DPoP-bound tokens

dpop:
  enabled: true
  proof_lifetime: 60s        # max age of a client's DPoP proof (10s..300s)
  nonce_ttl: 60s             # how long a server-issued nonce stays valid
  require_nonce: false       # true → extra round-trip, but tighter replay protection

Or env vars:

AUTHPLANE_DPOP_ENABLED=true
AUTHPLANE_DPOP_PROOF_LIFETIME=60s
AUTHPLANE_DPOP_NONCE_TTL=60s
AUTHPLANE_DPOP_REQUIRE_NONCE=false

Restart AuthPlane. /.well-known/oauth-authorization-server now advertises dpop_signing_alg_values_supported: ["ES256", "RS256", "PS256"]. Any client that sends a DPoP header on /oauth/token will get back a token with token_type: DPoP and a cnf.jkt claim binding it to their key.

Additive — clients that DON’T send a DPoP header still get regular Bearer tokens. Enable AS-side first without touching clients; nothing breaks.

2. SDK-side — verify DPoP proofs on incoming requests

server.py python
from authplane import InboundDPoPOptions
from authplane_mcp import authplane_mcp_auth, install_request_context

auth = await authplane_mcp_auth(
  issuer="https://auth.example.com",
  resource="https://mcp.example.com/mcp",
  scopes=["tools/read"],
  inbound_dpop=InboundDPoPOptions(required=True),
)
mcp = FastMCP("my-server", **auth)
install_request_context(mcp)     # REQUIRED — see gotcha below

required: true means: reject bearer-only requests when the server expects DPoP. required: false (or omitting the option entirely) advertises DPoP support in the PRM but still accepts bearer tokens — useful during migration when some clients don’t support DPoP yet.

3. Python only — install_request_context(mcp)

Only for authplane-mcp on the official MCP Python SDK. Not needed for TS, Go, or Python’s authplane-fastmcp (which has a separate DPoP caveat — see below).

The official MCP Python SDK’s FastMCP class doesn’t expose a middleware hook, so the adapter wraps mcp.streamable_http_app and installs AuthplaneRequestContextMiddleware that publishes the active Starlette Request on a ContextVar before the verifier runs. The verifier reads it to build a DPoPRequestContext and forward it to AuthplaneResource.verify().

Without install_request_context(mcp), DPoP-bound requests fail closed with 401 WWW-Authenticate: DPoP error="invalid_dpop_proof" — the misconfiguration surfaces immediately rather than as a silent bypass. So if you’re on authplane-mcp with inbound_dpop=...(required=True) and every request comes back 401, this is the first thing to check.

The call is idempotent — safe to invoke twice.

Client-side — construct a DPoP proof

Your MCP client is responsible for generating the proof. Every AuthPlane SDK’s outbound client does this automatically when configured with a DPoPProvider; you only need to write proof-construction code if you’re implementing a client from scratch.

Generate a key pair — once, at client startup. ECDSA P-256 recommended (compact + fast):

openssl ecparam -name prime256v1 -genkey -noout -out dpop_key.pem
openssl ec -in dpop_key.pem -pubout -out dpop_pub.pem

Never share the private key. Never include it in a proof — AuthPlane rejects proofs that carry the private key in the jwk header.

Proof shape — a JWT with:

HeaderValue
typdpop+jwt (exact string)
algES256, RS256, or PS256
jwkYour public key as a JWK
ClaimRequiredNotes
jtiAlwaysUUID. Server rejects reused values
htmAlwaysHTTP method (POST, GET, …)
htuAlwaysHTTP URL scheme + authority + path (authority includes non-default port; query stripped; compared after RFC 3986 normalization)
iatAlwaysUnix timestamp, within proof_lifetime of server time
nonceWhen server requires itValue from DPoP-Nonce response header
athAt resource serverbase64url(SHA-256(access_token)) — binds proof to token

Pseudocode:

def dpop_proof(private_key, method, url, nonce=None, access_token=None):
    header = {
        "typ": "dpop+jwt",
        "alg": "ES256",
        "jwk": public_key_jwk(private_key),   # PUBLIC key only
    }
    payload = {
        "jti": uuid4(),
        "htm": method,
        "htu": strip_query(url),
        "iat": int(time.time()),
    }
    if nonce:         payload["nonce"] = nonce
    if access_token:  payload["ath"] = base64url(sha256(access_token))
    return jwt_sign(header, payload, private_key)

At the token endpoint — proof included but no ath (you don’t have the token yet). Response has token_type: DPoP and a cnf.jkt claim binding it to your key.

At the resource server — proof included WITH ath. Authorization: DPoP <token> (not Bearer).

Nonce flow (when require_nonce: true)

Extra round-trip on the first request. The AS and the RS use different mechanisms — RFC 9449 keeps them separate:

  • AS (/oauth/token, per §8) — HTTP 400 with a JSON body {"error":"use_dpop_nonce", …} (the RFC 6749 §5.2 error envelope) plus a DPoP-Nonce: <value> header. No WWW-Authenticate.
  • RS (per §9) — HTTP 401 with WWW-Authenticate: DPoP error="use_dpop_nonce" plus DPoP-Nonce: <value>.

Client behavior is the same on both: read the DPoP-Nonce header from the response, cache it, and include it as the nonce claim in every subsequent proof. SDKs handle both variants automatically.

Also note the resource-server SDKs’ use_dpop_nonce challenge at /mcp uses HTTP 401 + WWW-Authenticate: DPoP — that’s §9’s mechanism, not §8’s.

Verifying it worked

Request a token with DPoP and inspect the response:

$ curl -s -X POST http://localhost:9000/oauth/token \
    -H "DPoP: eyJhbGci..." \
    -d "grant_type=client_credentials&client_id=…&client_secret=…" | jq .
{
  "access_token": "eyJhbGci...",
  "token_type": "DPoP", DPoP, not Bearer
  "expires_in": 3600
}

Decode the access tokencnf.jkt should be present:

$ echo "eyJhbGci..." | cut -d. -f2 | base64 -d | jq .cnf
{
  "jkt": "0iaeMlxbX3MR9k0DBjKBR9HBkfHTwIsuw_2dPjsLwBE"
}

jkt is the base64url-encoded SHA-256 thumbprint of your public JWK (per RFC 7638). If it matches the thumbprint of the key your client is using, you’re DPoP-bound.

Test at the resource server — replay the token WITHOUT a DPoP header:

$ curl -i -X POST http://mcp.example.com/mcp \
    -H "Authorization: DPoP eyJhbGci..." \
    -d '{}'

HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="invalid_dpop_proof",
                  error_description="DPoP proof is invalid",
                  resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

Confirms the SDK is enforcing. A leaked token off the bound key is useless.

Metrics to watch

Once DPoP is in production:

  • authplane_dpop_proofs_validated_total — successful verifications
  • authplane_dpop_proofs_rejected_total — failures. Labels indicate reason (replay, nonce_missing, htu_mismatch, signature_invalid, etc.)
  • authserver_auth_denied_total{reason="dpop_*"} — auth denials attributable to DPoP

rate(authplane_dpop_proofs_rejected_total[5m]) spiking without a corresponding validated spike is the signature of a client bug or an attack — alert on it.

Reverse-proxy gotchas — the htu trap

DPoP’s htu claim is the URL the client thinks it’s calling. The server derives its own htu from the incoming request and compares. A reverse proxy that rewrites scheme or host without setting X-Forwarded-Proto / X-Forwarded-Host correctly breaks this comparison silently — every request comes back 401 invalid_dpop_proof even though the proof looks fine.

  • Symptom: Every DPoP request fails. Non-DPoP (bearer) requests succeed.
  • Fix: Configure your proxy to set X-Forwarded-Proto and X-Forwarded-Host correctly. AuthPlane and the SDKs honor these when reconstructing the request URL.
  • Verify: log the reconstructed URL on your MCP server and compare against the htu claim in the failing proof.

Caddy and nginx example configs are in Operate: Standalone → Reverse proxy.

Migration path — enable without breaking existing clients

Because DPoP is additive, roll it out in stages:

  1. Turn on AS-side. dpop.enabled: true. All existing bearer flows keep working; DPoP-capable clients start requesting bound tokens.
  2. Advertise inbound DPoP in PRM without enforcing. SDK config: inbound_dpop: {required: false} (or omit required). PRM now announces DPoP support; clients that already send DPoP proofs get them verified.
  3. Wait for all clients to migrate. Monitor authplane_dpop_proofs_validated_total per client_id.
  4. Flip to required: true. Now bearer-only requests are rejected. Clients that didn’t migrate see 401.

Skip stages 2-3 for greenfield deployments — required: true from day one.

Troubleshooting

SymptomCauseFix
Every DPoP request → 401 invalid_dpop_proofPython authplane-mcp: no install_request_context(mcp)Add the call after mcp = FastMCP(...)
Every DPoP request → 401 invalid_dpop_proof, non-Python SDKReverse proxy rewriting scheme/host without X-Forwarded-*Fix proxy headers
First DPoP request → 401 use_dpop_nonceServer has require_nonce: trueClient picks up DPoP-Nonce header, includes in next proof’s nonce claim
Second DPoP request from same client → invalid_dpop_proof “jti already used”Client bug: reusing jti across requestsGenerate a fresh UUID per request
Token comes back as Bearer instead of DPoPClient didn’t send a DPoP header on /oauth/tokenVerify client’s outbound DPoPProvider is wired
authplane-fastmcp (PrefectHQ) doesn’t enforce DPoP despite inbound_dpop setKnown caveat: PrefectHQ FastMCP’s BearerAuthBackend rejects Authorization: DPoP before the adapter sees itSee SDKs: Python — DPoP caveat
invalid_dpop_proof on the very first proof, iat looks fineClock skew between client and server exceeds proof_lifetimeSync clocks (NTP), or bump proof_lifetime to 120s

Full failure catalog in Reference: Errors and Security: DPoP.