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
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