Why
The MCP OAuth flow issues each client two credentials: a short-lived access token (sent on every /mcp call) and a longer-lived refresh token (used only to mint a new access token when the old one expires). Today the refresh token is stable: every refresh mints a new access token but returns the same refresh token unchanged.
A stable refresh token is a long-lived bearer credential. If it leaks (a log, disk, a stolen laptop) an attacker can mint access tokens for its full 30 day lifetime, and nothing signals the theft, because the real client and the attacker present the identical token and both succeed. RFC 9700 (OAuth Security BCP) section 4.14.2 recommends that public clients rotate refresh tokens with replay detection instead:
- Rotation: each refresh returns a new refresh token and invalidates the old one, so a refresh token is single use.
- Replay detection: if an already rotated-out refresh token is presented again, two copies exist (a likely theft), so the whole token family (the original grant and everything derived from it) is revoked and the client must re-authorize. A leaked token is then usable only until the next legitimate refresh, and the collision surfaces the breach.
The race this has to handle
Naive rotation breaks legitimate clients, because two refreshes with the same token happen normally:
- Concurrent: a client fires several tool calls at once; if the access token just expired they all try to refresh together with the current refresh token. The first rotates it, the rest now hold a used token and get falsely flagged as replay.
- Retried: a refresh response is lost to a timeout and the client retries with the same token; the server already rotated on the first attempt, so the retry looks like replay.
Either false positive would revoke the family and eject a well-behaved user. The stable design shipped first precisely to avoid this. Rotation must therefore keep a short grace window: the immediately previous refresh token stays valid for a few seconds and returns the current tokens, and only a token used after that window counts as a genuine replay.
Proposal
- Rotate the refresh token on each successful refresh and mark the presented one as rotated.
- Accept the immediately previous refresh token within a short grace window, returning the current tokens, so concurrent and retried refreshes still succeed.
- Treat a refresh token presented after its grace window as a replay: revoke the entire token family and require re-authorization.
Acceptance criteria
- A successful refresh returns a new refresh token; the previous one stops working once its grace window elapses.
- Two near-simultaneous refreshes with the same token both succeed and neither triggers a revocation.
- A rotated-out token presented after the grace window revokes the family and returns an OAuth error, and that family's access tokens stop authenticating.
- Token types without their own refresh lifetime (for example editor sessions) are unchanged.
- Rotation and grace-window state lives in the shared cache, so it works on a single replica (docker compose) and multiple replicas (Helm).
Notes
- Fifth PR in the epic, stacked on the consent-page PR; depends on the refresh-lifetime and coalescing changes already merged.
- If rotation, grace window, and family revocation are too large for one review, split into a rotation PR and a replay-detection PR under this issue.
Part of #7423
Why
The MCP OAuth flow issues each client two credentials: a short-lived access token (sent on every /mcp call) and a longer-lived refresh token (used only to mint a new access token when the old one expires). Today the refresh token is stable: every refresh mints a new access token but returns the same refresh token unchanged.
A stable refresh token is a long-lived bearer credential. If it leaks (a log, disk, a stolen laptop) an attacker can mint access tokens for its full 30 day lifetime, and nothing signals the theft, because the real client and the attacker present the identical token and both succeed. RFC 9700 (OAuth Security BCP) section 4.14.2 recommends that public clients rotate refresh tokens with replay detection instead:
The race this has to handle
Naive rotation breaks legitimate clients, because two refreshes with the same token happen normally:
Either false positive would revoke the family and eject a well-behaved user. The stable design shipped first precisely to avoid this. Rotation must therefore keep a short grace window: the immediately previous refresh token stays valid for a few seconds and returns the current tokens, and only a token used after that window counts as a genuine replay.
Proposal
Acceptance criteria
Notes
Part of #7423