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 hitsinvalid_grant " |
anthropics/claude-code #89862 |
" refresh_token request omitsscope , 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:
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:
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.
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
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 (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.