Repository navigation
Resource server cannot accept DPoP-bound access tokens (SEP-1932) #3728
Description
Description
Release line: 2.x (main). SEP-1932, the DPoP profile for MCP (modelcontextprotocol/modelcontextprotocol#1932), is still a draft, so this is for tracking and design discussion, not a request to merge now. Related: #3671 covers the OAuth client side, and ROADMAP.md lists DPoP only in the OAuth client. This issue is the other half: an MCP server built on this SDK cannot verify a DPoP-bound token or send a DPoP challenge. The two are independent; a server needs this even when its clients use another SDK.
Why it matters to us: we run MCP servers behind an authorization server that issues DPoP-bound tokens. On this SDK there is no way to accept them, so those servers have to fall back to bearer tokens.
What is missing
The server auth stack is Bearer-only:
TokenVerifier.verify_token(token)insrc/mcp/server/auth/provider.pyreceives the token string only. DPoP verification also needs the request method, the request URL, and theDPoPheader, so a verifier cannot do it from here.BearerAuthBackendinsrc/mcp/server/auth/middleware/bearer_auth.pyaccepts only theBearerscheme.RequireAuthMiddleware._send_auth_errorbuilds only aBearerchallenge.AccessTokenhas no typed field forcnf. A verifier can put it inclaims, but nothing reads it.- The metadata fields
dpop_signing_alg_values_supportedanddpop_bound_access_tokens_requiredexist on the models insrc/mcp/shared/auth.py, but nothing on the server side acts on them.
Today a cnf-bound token sent as Bearer is accepted if the verifier does not refuse it itself. RFC 9449 section 7.2 and the SEP-1932 draft say a server that supports DPoP must refuse it.
What SEP-1932 asks of a server
From the draft text and two open PRs against it (modelcontextprotocol/ext-auth#33, #36), a server that supports the extension:
- validates proofs per RFC 9449 section 4.3, including the proof key against the token's
cnf.jkt, and refusesiatoutside plus or minus 5 minutes; - answers failures with 401 and a
DPoPchallenge, and should useinvalid_dpop_prooffor proof defects andinvalid_tokenfor token defects; - refuses a DPoP-bound token presented as
Bearer; - publishes
dpop_signing_alg_values_supported, anddpop_bound_access_tokens_required: truewhen it does not accept bearer tokens; - when it requires DPoP, includes a
DPoPchallenge in everyWWW-Authenticateresponse; - may require a server nonce (
use_dpop_nonce,DPoP-Nonceheader) and may trackjtifor replay.
The conformance suite has a server scenario for this, auth/dpop-server-validation, in modelcontextprotocol/conformance#395 and its stack (#527, #528, #529). Those PRs are still open, so the scenario is not in a released harness yet (this repo pins 0.2.0-alpha.11).
Shape we would propose
Smallest change that fits the current design, for discussion before any code:
- A DPoP-aware backend beside, or in place of,
BearerAuthBackend, taking the sameTokenVerifierplus a proof verifier. It readsAuthorization: DPoP <token>and theDPoPheader from the connection, verifies the proof against method, URL, token hash andcnf.jkt, and returns theAccessToken. Starlette'sAuthenticationMiddlewaretakes a single backend, so this is probably one backend that handles both schemes. - Either
AccessToken.cnf: dict[str, str] | None, or readcnffrom the existingclaimswith no new field. Either way, the Bearer path refuses a token withcnfset. RequireAuthMiddlewareemits aDPoPchallenge withalgs(andDPoP-Noncewhen nonces are on) alongside theBearerone. Today a failing backend returnsNoneand the middleware always answersinvalid_token, so the backend needs a way to pass the error code through forinvalid_dpop_proof.- Optional
NonceStoreandReplayStoreprotocols with in-memory defaults, for the nonce andjtichecks. AuthSettingsgains the two metadata fields so the Protected Resource Metadata document advertises them.
Proof verification can be done on pyjwt[crypto], which the SDK already requires, so no new dependency.
Filing now so the gap is on record next to #3671 and the shape can be discussed while the SEP settles.
I drafted this with AI assistance (Claude Code) and checked each claim against main myself. I'll answer questions here.
References
- SEP-1932, DPoP Profile for MCP: SEP-1932: DPoP Profile for MCP modelcontextprotocol#1932
- Draft text and open PRs: DPoP: Update DPoP profile with server requirements from conformance implementation feedback ext-auth#33 (server requirements, metadata, challenges), Harmonizing requriements across documents and the conformance suite ext-auth#36 (nonces,
iatwindow, token endpoint) - Conformance server scenario
auth/dpop-server-validation: Server Auth: DPoP Proof Validation (SEP-1932) conformance#395, with python fastMCP sse_client how to use subscribe_resource and handle NotificationHandler #527, Underspecified Tool Type #528, Examples: add MCP simple chatbot with SSE #529 stacked on it - TypeScript SDK server side, open PR: feat(server): add DPoP (RFC 9449) proof validation typescript-sdk#2630 (
requireDpopAuthbeside the Bearer gate,verifyBearerTokenrefusingcnf-bound tokens,AuthInfo.cnf). Its client side, DCR registration accepts redirect_uris with non-HTTPS / non-loopback / fragmented schemes #2629, is merged. A resource server built on that PR's branch, with server nonces andjtireplay checks added on top, passes the conformance scenario 28 of 28 at both the 2026-07-28 and 2025-11-25 wires, so the list above is implementable on a gate of that shape. - RFC 9449 (DPoP), RFC 9728 (Protected Resource Metadata), RFC 6750 section 3.1 (error codes)
Activity
Ben-Home commentedon Oct 27, 2026
Good breakdown, and the interface problem is the real blocker. Two further constraints worth designing around, both from RFC 9449 rather than from the SEP draft:
The proof must be bound to the request, so a verifier that only receives the token string cannot validate
htuandhtm. Any workable shape passes a small request-scoped context: method, the full URL as the client saw it, and theDPoPheader value. A backward-compatible route is an optional method on the verifier protocol (something likeverify_token_request(context) -> AccessToken) whose default implementation callsverify_token(context.token), withBearerAuthBackendpreferring the contextual method when the verifier implements it. That keeps every existing verifier working while leaving room for DPoP.The refusal case matters more than the acceptance case. Today a
cnf-bound token presented asBeareris accepted when the verifier does not reject it itself, and RFC 9449 section 7.2 calls that a MUST refuse. Making that a default in the backend, independent of whether DPoP support is enabled, closes a downgrade path where an attacker strips the proof and replays the token as bearer.If you need conformance before the SDK moves, a middleware in front of the app gets you most of the way: read the
DPoPheader, checkhtmagainst the method andhtuagainst the request URL minus query and fragment, checkiatinside the window, compute the JWK thumbprint and compare withcnf.jkt, keepjtiin a shared cache for replay detection, then hand the request to the SDK as if it were bearer. Two things bite in production:jtitracking needs a shared store, since a per-process set fails as soon as there is more than one worker, anduse_dpop_nonceneeds a stable server secret or every worker mints its own nonce and clients see intermittentuse_dpop_nonceerrors.Worth noting for the challenge path: when
dpop_bound_access_tokens_requiredis true theWWW-Authenticateresponse should carry theDPoPchallenge on every response, not just the first 401. Clients that cache the challenge will otherwise fail on token expiry rather than at initial connect, which is why this class of bug shows up as intermittent auth failures under load.We run MCP servers of our own (business data connectors), and bearer-only is the part that keeps us off DPoP: proof-of-possession is the difference between a leaked token being a revocation event and being a full impersonation.