Skip to content

Resource server cannot accept DPoP-bound access tokens (SEP-1932) #3728

Open

Description

@figitaki

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) in src/mcp/server/auth/provider.py receives the token string only. DPoP verification also needs the request method, the request URL, and the DPoP header, so a verifier cannot do it from here.
  • BearerAuthBackend in src/mcp/server/auth/middleware/bearer_auth.py accepts only the Bearer scheme.
  • RequireAuthMiddleware._send_auth_error builds only a Bearer challenge.
  • AccessToken has no typed field for cnf. A verifier can put it in claims, but nothing reads it.
  • The metadata fields dpop_signing_alg_values_supported and dpop_bound_access_tokens_required exist on the models in src/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 refuses iat outside plus or minus 5 minutes;
  • answers failures with 401 and a DPoP challenge, and should use invalid_dpop_proof for proof defects and invalid_token for token defects;
  • refuses a DPoP-bound token presented as Bearer;
  • publishes dpop_signing_alg_values_supported, and dpop_bound_access_tokens_required: true when it does not accept bearer tokens;
  • when it requires DPoP, includes a DPoP challenge in every WWW-Authenticate response;
  • may require a server nonce (use_dpop_nonce, DPoP-Nonce header) and may track jti for 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:

  1. A DPoP-aware backend beside, or in place of, BearerAuthBackend, taking the same TokenVerifier plus a proof verifier. It reads Authorization: DPoP <token> and the DPoP header from the connection, verifies the proof against method, URL, token hash and cnf.jkt, and returns the AccessToken. Starlette's AuthenticationMiddleware takes a single backend, so this is probably one backend that handles both schemes.
  2. Either AccessToken.cnf: dict[str, str] | None, or read cnf from the existing claims with no new field. Either way, the Bearer path refuses a token with cnf set.
  3. RequireAuthMiddleware emits a DPoP challenge with algs (and DPoP-Nonce when nonces are on) alongside the Bearer one. Today a failing backend returns None and the middleware always answers invalid_token, so the backend needs a way to pass the error code through for invalid_dpop_proof.
  4. Optional NonceStore and ReplayStore protocols with in-memory defaults, for the nonce and jti checks.
  5. AuthSettings gains 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

Activity

  1. added
    enhancementRequest for a new feature that's not currently supported
    on Oct 18, 2026
  2. Ben-Home commented on Oct 27, 2026

    @Ben-Home

    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 htu and htm. Any workable shape passes a small request-scoped context: method, the full URL as the client saw it, and the DPoP header value. A backward-compatible route is an optional method on the verifier protocol (something like verify_token_request(context) -> AccessToken) whose default implementation calls verify_token(context.token), with BearerAuthBackend preferring 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 as Bearer is 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 DPoP header, check htm against the method and htu against the request URL minus query and fragment, check iat inside the window, compute the JWK thumbprint and compare with cnf.jkt, keep jti in a shared cache for replay detection, then hand the request to the SDK as if it were bearer. Two things bite in production: jti tracking needs a shared store, since a per-process set fails as soon as there is more than one worker, and use_dpop_nonce needs a stable server secret or every worker mints its own nonce and clients see intermittent use_dpop_nonce errors.

    Worth noting for the challenge path: when dpop_bound_access_tokens_required is true the WWW-Authenticate response should carry the DPoP challenge 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementRequest for a new feature that's not currently supported

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions