Dev.to AI ๐Ÿค– Ai ๐Ÿ‘ 0 ๐Ÿ“– 5 min read

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

Five open issues, one bug: your MCP OAuth refresh token is being replayed after rotation I read five open issues across Codex, Claude Code, Gemini CLI and Hermes Agent in one sitting. They look like five different bugs

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

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.

Five symptoms, one mechanism

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:

  1. A refresh attempt returns 400 invalid_grant.
  2. Nobody persists that rejection.
  3. The next attempt reads the same token from disk and tries again.

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.

The bare except that hides the death

This is the shape I keep finding. It parses as defensive. It is the opposite:

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

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:

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.

Three checks that catch it

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.

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:

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

Why this is hard to see in production

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.

What I packaged from this

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

๐Ÿ“ฐ Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.