# Five open issues, one bug: your MCP OAuth refresh token is being replayed after rotation

> Source: <https://dev.to/samuelfmedeiros/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after-rotation-5705>
> Published: 2026-11-15 03:07:54+00:00

I read five open issues across Codex, Claude Code, Gemini CLI and Hermes Agent in one sitting. They look like five different bugs. They are one bug wearing five costumes: **the client keeps using a refresh token the server already rotated, and never tells you.**

Here is the pattern, the code that causes it, and the check that catches it before your users do.

From the issues themselves:

| Project | Symptom as reported | 
|---|---|
| `openai/codex` #39054 (22 comments) | "a rejected refresh token stays usable, so Codex retries it forever and never surfaces re-authentication" | 
| `openai/codex` #48507 | "concurrent processes race refresh-token rotation in `.credentials.json` ; next startup hits`invalid_grant` " | 
| `anthropics/claude-code` #89862 | " `refresh_token` request omits`scope` , so the refreshed token comes back with the wrong scope and the server 401s" | 
| `google-gemini/gemini-cli` | "concurrent MCP OAuth refreshes race, and any refresh failure..." | 
| `NousResearch/hermes-agent` | "background agents replay a dead MCP OAuth refresh token" | 

Every one of these is the same three lines of logic missing:

`400 invalid_grant`.
OAuth 2.1 rotated refresh tokens are single-use by design. Once the authorization server hands you a new one, the old one is dead. A client that treats "I have a refresh token" as "I can refresh" will spin forever against a corpse.

`except` that hides the death
This is the shape I keep finding. It parses as defensive. It is the opposite:

``` python
def get_access_token():
    creds = load_credentials()
    if creds["access_token"] and not expired(creds["access_token"]):
        return creds["access_token"]
    try:
        new = refresh(creds["refresh_token"])
        save_credentials(new)
        return new["access_token"]
    except HTTPError as e:
        if e.code == 401:
            pass          # fall through to return whatever we have
        raise
    return creds["access_token"]   # <-- the dead one, returned as if alive
```

Two failures live in that `pass`:

**The rejection is not recorded.** Nothing marks the refresh token as revoked. The next call loads the same record from disk, sees a non-empty `refresh_token`, and repeats the identical doomed request.

**A stale token is returned as valid.** Falling through to `creds["access_token"]` hands the caller a token that the resource server has already rejected, and the surface error becomes a generic `401` on the tool call — not "your auth is dead, re-login". The user sees a broken tool, not an auth problem. The two issues that say "never surfaces re-authentication" are exactly this line.

The second failure mode needs no server at all. It needs two processes.

Codex #48507 documents it cleanly: several TUI sessions plus an app-server daemon share one `.credentials.json`. Stripe access tokens live 1h, Supabase 24h. At T0+24h two processes refresh at once:

``` php
proc A: read refresh_token=R1  -> POST /token -> gets R2 -> writes {R2}
proc B: read refresh_token=R1  -> POST /token -> 400 invalid_grant
```

`proc B` did nothing wrong. It read a valid file and used a token that was valid a millisecond earlier. The server is correct to reject it: `R1` was single-use and `R1` is gone. But `proc B` — running the bare-`except` code above — now concludes the *grant* is broken and demands a full re-login.

Every reboot. All servers at once. That is the reported symptom, and it is not a server bug.

If you maintain an MCP client, these are the assertions worth having. They are cheap and each one maps to a real reported failure.

**1. The rejection must be persisted, not swallowed.**

``` python
def refresh_access(creds):
    try:
        new = post_token_endpoint(creds, grant="refresh_token")
    except OAuthError as e:
        if e.error == "invalid_grant":
            mark_revoked(creds["server_url"])   # persist, do not pass
            raise ReauthRequired(creds["server_url"])
        raise
    return new
```

The test: force `invalid_grant` twice, assert the second call never hits the network and the error is `ReauthRequired`, not a timeout or a 401 on an unrelated tool.

**2. Rotation must be atomic across processes.**

Writing the file is not the race; read-modify-write is. A file lock around the refresh, plus a re-read inside the lock, collapses the race:

```
with file_lock(CRED_PATH):
    creds = load_credentials()          # re-read AFTER the lock
    if creds["access_token"] and not expired(creds["access_token"]):
        return creds["access_token"]    # someone else already refreshed
    return refresh_access(creds)
```

The test: spawn two processes against a stub server that rotates on every call. With the re-read, both succeed and only one token request is made. Without it, one gets `invalid_grant`.

**3. `scope` must be sent on refresh.**

RFC 6749 §6 makes `scope` optional — omit it and the server **should** replay the original grant. Real servers do not. Claude Code #89862 has the table:

``` php
interactive login  (sends scope)            -> scp=mcp.access  -> 200
client refresh     (omits scope)            -> scp={guid}      -> 401
manual   refresh   (sends scope)            -> scp=mcp.access  -> 200
```

Same refresh token, same endpoint. The only difference is the parameter. Against Entra this fails reliably; other authorization servers fail less predictably, which is worse, because it presents as "auth breaks every hour or so".

Send the original scope:

```
payload = {"grant_type": "refresh_token", "refresh_token": token}
if original_scope:
    payload["scope"] = original_scope   # do not rely on the server's memory
```

The failure is not loud. A revoked refresh token produces a `401` on a tool call three layers away from the auth code. Your logs show a failed tool, not a dead grant. Users retry, which multiplies the attempts, which makes it look like a flaky server.

The tell is the retry count on a token that was rejected once. If `invalid_grant` appears more than once for the same `(server, refresh_token)` pair in your logs, the client is replaying. That is the number to alert on.

I turned these three checks into a skill people can drop into an agent workflow: `mcp-oauth-debug` — reproduces each failure against a stub authorization server, then asserts the three behaviors above. It exists because I hit the `invalid_grant` loop in my own agent stack and spent longer than I want to admit blaming the server.

If you are debugging one of the five issues above, the stub-server repro is the fastest way to know whether your client replays a dead token: rotate on every call and count the requests.

The reproducible checks are packaged as a paid skill — [mcp-oauth-debug](https://skills.seu.pet/p/mcp-oauth-debug?utm_source=devto&utm_medium=article&utm_campaign=mcp-oauth-refresh&utm_content=pt-skill) (R$ 47, Stripe, instant delivery). The article above is the full diagnosis; the pack is the stub server and the assertions.

*Written while reading open issues on 2026-11-15. Issue numbers and quotes are from the public trackers on that date; line numbers and code inside each project may have moved since.*
