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 Pythonauthplane-mcpneedsinstall_request_context(mcp). If any one is missing, you either accept unbound Bearer tokens (fine for dev) or fail closed with401 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
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 belowimport { authplaneFastMcpAuth } from "@authplane/fastmcp";
const auth = await authplaneFastMcpAuth({
issuer: "https://auth.example.com",
resource: "https://mcp.example.com/mcp",
scopes: ["tools/read"],
inboundDPoP: { required: true },
});import (
"github.com/authplane/go-sdk/core/resource/verifier"
"github.com/authplane/go-sdk/mcp/pkg/authplanemcp"
)
adapter, err := authplanemcp.NewAdapter(ctx, authplanemcp.Options{
Issuer: "https://auth.example.com",
Resource: "https://mcp.example.com/mcp",
Scopes: []string{"tools/read"},
VerifierOptions: []verifier.Option{
verifier.WithInboundDPoP(verifier.InboundDPoPOptions{Required: true}),
},
})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:
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 aDPoP-Nonce: <value>header. NoWWW-Authenticate. - RS (per §9) — HTTP 401 with
WWW-Authenticate: DPoP error="use_dpop_nonce"plusDPoP-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 token — cnf.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 verificationsauthplane_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-ProtoandX-Forwarded-Hostcorrectly. AuthPlane and the SDKs honor these when reconstructing the request URL. - Verify: log the reconstructed URL on your MCP server and compare against the
htuclaim 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:
- Turn on AS-side.
dpop.enabled: true. All existing bearer flows keep working; DPoP-capable clients start requesting bound tokens. - Advertise inbound DPoP in PRM without enforcing. SDK config:
inbound_dpop: {required: false}(or omitrequired). PRM now announces DPoP support; clients that already send DPoP proofs get them verified. - Wait for all clients to migrate. Monitor
authplane_dpop_proofs_validated_totalper client_id. - Flip to
required: true. Now bearer-only requests are rejected. Clients that didn’t migrate see401.
Skip stages 2-3 for greenfield deployments — required: true from day one.
Troubleshooting
Full failure catalog in Reference: Errors and Security: DPoP.
Related
- Concepts: DPoP — mental model, when to enable
- Security: DPoP — full spec detail (§4.3 proof structure, allowed algs, nonce semantics)
- SDKs: Python — the
install_request_contextrequirement + fastmcp caveat - SDKs: TypeScript —
inboundDPoP+ AsyncLocalStorage double-invocation handling - SDKs: Go —
verifier.WithInboundDPoP - Reference: Configuration → DPoP — every DPoP knob
- Reference: Errors — DPoP codes — full error catalog