Repository navigation
Conversation
6 of 9 tasks
figitaki
commented
Oct 16, 2026
RFC 9449 section 11.1 says a server SHOULD refuse a proof whose jti it has seen before. Without that, a captured proof and token work again for the whole iat window. The gate had a TODO for it. DpopAuthOptions gets an optional `replay` with one method, `consume(key, ttlSeconds)`, which returns false when the key was seen. verifyDpopToken calls it last, after every other check, keyed by "<jkt>.<jti>" with a TTL of twice the iat skew rounded up to whole seconds (at least 1), the longest a proof can still pass the iat check. A seen key answers 401 invalid_dpop_proof. requireDpopAuth and the Express middleware pass it through. Why a hook and not the proof claims on the result: returning the claims would change the return type of verifyDpopToken and of the gate (AuthInfo | Response), and every host would still have to write the same check. One optional method keeps the public shape as it is, mirrors the existing `nonce` option, and leaves storage and atomicity to the host. No store ships with the SDK. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fetch gate compared the proof's htu with request.url. Behind a reverse proxy or TLS termination that is the internal URL, not the one the client signed, so every such host had to skip the gate and call verifyDpopToken itself. The Express middleware does not have this problem: it rebuilds the URL through Express's `trust proxy` setting. requireDpopAuth now takes `requestUrl`, a function of the Request for hosts that read Forwarded headers they trust. It is a function only: a fixed string would pin every route behind one gate to one htu. It defaults to request.url. The option lives on the gate only; verifyDpopToken already takes the URL in its request argument, and the Express adapter keeps its own reconstruction. A requestUrl function that throws answers 500, like a throwing verifier. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A nonce store shared between server instances (Redis, a database) cannot answer synchronously. `verify` may now return `boolean | Promise<boolean>`; verifyDpopProof awaits it. A synchronous `verify` keeps working unchanged. `issue()` stays synchronous: dpopAuthChallengeResponse returns a Response, not a Promise, and a host can mint the nonce value ahead of time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The gate issued a nonce only inside a use_dpop_nonce challenge. A client's first request has no proof, so it got a plain 401, retried with a proof, got use_dpop_nonce, and retried again. RFC 9449 section 8.2 lets a nonce ride on any response, and the SDK client already remembers a DPoP-Nonce from any response. With a nonce option set, dpopAuthChallengeResponse now puts DPoP-Nonce on every 401 (invalid_token, invalid_dpop_proof, use_dpop_nonce). 403, 400 and 500 answers do not carry it. The Express middleware also sets it on accepted requests before calling next(). A fetch host already holds the nonce object it configured, so it sets `DPoP-Nonce: nonce.issue()` on its own response; no new export for that. When to rotate stays the host's choice, expressed by what issue() returns. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
verifyBearerToken on main copies the gate's resourceMetadataUrl onto the AuthInfo it returns when the verifier left it unset, so scope challenges built later advertise the same document. verifyDpopToken now does the same, so a DPoP request gets the same scope challenge as a Bearer one. A verifier-set value still wins. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
figitaki
force-pushed
the
dpop-server-followups
branch
from
October 16, 2026 17:40
5c57ba1 to
49dccb0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
These are the four follow-ups from the modelcontextprotocol#2630 thread, as separate commits on top of
ccd9f583, per your comment asking for them against this branch instead of a rebase PR. They come from running a resource server on the preview build with a 2.3.1 client. The design discussion is in modelcontextprotocol#2630.Each commit has its own changeset, so any one of them can be dropped without touching the others. Your
.changeset/dpop-server-validation.mdis unchanged. A fifth, separate commit fixes a doc contractverifyDpopTokeninherits fromVerifyBearerTokenOptions; drop it if you would rather keep that out of modelcontextprotocol#2630.What changed
replayonDpopAuthOptions: one optional method,consume(key, ttlSeconds), keyed<jkt>.<jti>, called after every other check. The TTL is theiatwindow (twiceiatSkewSeconds) rounded up to whole seconds, at least 1, so a fractional or zero skew never reaches a store that takes integers. A seen key answers 401invalid_dpop_proof. This closes the jti TODO without changing the return type ofverifyDpopTokenor the gate. No store ships. The fetch gate and the Express middleware pass it through.requestUrlon the fetchrequireDpopAuth: a function of theRequestthat derives the URL the proof'shtumust match, for a server behind a reverse proxy or TLS termination. It is a function only: a fixed string would pin every route behind one gate to onehtu. Defaults torequest.url. The Express adapter keeps itstrust proxyreconstruction. A throwing function answers 500, like a throwing verifier.DpopNonceState.verifymay returnPromise<boolean>, for a nonce store shared between instances.verifyDpopProofalways awaits it, so both forms behave the same.issue()stays synchronous becausedpopAuthChallengeResponsereturns aResponse.DPoP-Nonce, not onlyuse_dpop_nonce, so a client's first token-less request already learns the nonce.issue()therefore runs for unauthenticated requests; its JSDoc now says to return the current value and keep no per-call state. The Express middleware also sets the header on accepted requests. The fetch gate does not: a fetch host setsDPoP-Nonce: nonce.issue()on its own response from the nonce object it configured. Rotation policy stays with the host.verifyDpopTokenstamps the gate'sresourceMetadataUrlonto the returnedAuthInfowhen the verifier left it unset.VerifyBearerTokenOptions.resourceMetadataUrl, whichDpopAuthOptionsextends, documents that hand-off, andverifyBearerTokendoes it onmain;verifyDpopTokendid not, so a later scope challenge on a DPoP request advertised no document. A verifier-set value still wins.How Has This Been Tested?
pnpm lint:allis clean on the branch head.pnpm test:allpasses in every package except two cases intest/e2e,protocol:timeout:max-total [sse …], which fail the same way on a cleanmaincheckout on my machine and pass in CI on the base commit; they do not touch auth.@modelcontextprotocol/server(dpop.test.ts,dpopAuth.test.ts) and 14 in@modelcontextprotocol/express. Each commit adds the tests for its own change: replayedjtirefused on second use throughverifyDpopToken, the fetch gate and Express; TTL rounding for skew2.25(→ 5) and0(→ 1);requestUrlfunction and its 500 path; async nonceverify;DPoP-Nonceoninvalid_tokenandinvalid_dpop_proof401s and on an accepted Express request;resourceMetadataUrlstamped and not overwritten.DpopSessionend to end: discovery through the challenge, nonce challenge and retry, nonce rotation, bound tool calls, and the negative vectors from tkhq/mcp-dpop-fixtures.Breaking Changes
None.
DpopNonceState,DpopAuthOptionsandrequireDpopAuthare new in modelcontextprotocol#2630 and not in any release, so wideningverifyand adding optional members changes no released API.Types of changes
Checklist
AI assistance: Claude Code was used for the implementation, tests and this description, with human review. Commits carry a
Co-Authored-Bytrailer.🤖 Generated with Claude Code