{"slug": "five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after", "title": "Five open issues, one bug: your MCP OAuth refresh token is being replayed after rotation", "summary": "A developer analyzing five open issues across OpenAI Codex, Anthropic's Claude Code, Google's Gemini CLI and NousResearch's Hermes Agent found they share a single root cause: MCP OAuth clients replay refresh tokens that servers have already rotated, and swallow the resulting invalid_grant error instead of persisting revocation and prompting re-authentication. The writeup traces the bug to a bare except block that returns a stale access token as valid, and to concurrent processes racing on a shared .credentials.json file, and proposes assertions maintainers can test against.", "body_md": "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.**\n\nHere is the pattern, the code that causes it, and the check that catches it before your users do.\n\nFrom the issues themselves:\n\n| Project | Symptom as reported | \n|---|---|\n| `openai/codex` #39054 (22 comments) | \"a rejected refresh token stays usable, so Codex retries it forever and never surfaces re-authentication\" | \n| `openai/codex` #48507 | \"concurrent processes race refresh-token rotation in `.credentials.json` ; next startup hits`invalid_grant` \" | \n| `anthropics/claude-code` #89862 | \" `refresh_token` request omits`scope` , so the refreshed token comes back with the wrong scope and the server 401s\" | \n| `google-gemini/gemini-cli` | \"concurrent MCP OAuth refreshes race, and any refresh failure...\" | \n| `NousResearch/hermes-agent` | \"background agents replay a dead MCP OAuth refresh token\" | \n\nEvery one of these is the same three lines of logic missing:\n\n`400 invalid_grant`.\nOAuth 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.\n\n`except` that hides the death\nThis is the shape I keep finding. It parses as defensive. It is the opposite:\n\n``` python\ndef get_access_token():\n    creds = load_credentials()\n    if creds[\"access_token\"] and not expired(creds[\"access_token\"]):\n        return creds[\"access_token\"]\n    try:\n        new = refresh(creds[\"refresh_token\"])\n        save_credentials(new)\n        return new[\"access_token\"]\n    except HTTPError as e:\n        if e.code == 401:\n            pass          # fall through to return whatever we have\n        raise\n    return creds[\"access_token\"]   # <-- the dead one, returned as if alive\n```\n\nTwo failures live in that `pass`:\n\n**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.\n\n**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.\n\nThe second failure mode needs no server at all. It needs two processes.\n\nCodex #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:\n\n``` php\nproc A: read refresh_token=R1  -> POST /token -> gets R2 -> writes {R2}\nproc B: read refresh_token=R1  -> POST /token -> 400 invalid_grant\n```\n\n`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.\n\nEvery reboot. All servers at once. That is the reported symptom, and it is not a server bug.\n\nIf you maintain an MCP client, these are the assertions worth having. They are cheap and each one maps to a real reported failure.\n\n**1. The rejection must be persisted, not swallowed.**\n\n``` python\ndef refresh_access(creds):\n    try:\n        new = post_token_endpoint(creds, grant=\"refresh_token\")\n    except OAuthError as e:\n        if e.error == \"invalid_grant\":\n            mark_revoked(creds[\"server_url\"])   # persist, do not pass\n            raise ReauthRequired(creds[\"server_url\"])\n        raise\n    return new\n```\n\nThe 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.\n\n**2. Rotation must be atomic across processes.**\n\nWriting 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:\n\n```\nwith file_lock(CRED_PATH):\n    creds = load_credentials()          # re-read AFTER the lock\n    if creds[\"access_token\"] and not expired(creds[\"access_token\"]):\n        return creds[\"access_token\"]    # someone else already refreshed\n    return refresh_access(creds)\n```\n\nThe 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`.\n\n**3. `scope` must be sent on refresh.**\n\nRFC 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:\n\n``` php\ninteractive login  (sends scope)            -> scp=mcp.access  -> 200\nclient refresh     (omits scope)            -> scp={guid}      -> 401\nmanual   refresh   (sends scope)            -> scp=mcp.access  -> 200\n```\n\nSame 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\".\n\nSend the original scope:\n\n```\npayload = {\"grant_type\": \"refresh_token\", \"refresh_token\": token}\nif original_scope:\n    payload[\"scope\"] = original_scope   # do not rely on the server's memory\n```\n\nThe 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.\n\nThe 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.\n\nI 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.\n\nIf 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.\n\nThe 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.\n\n*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.*", "url": "https://wpnews.pro/news/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after", "canonical_source": "https://dev.to/samuelfmedeiros/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after-rotation-5705", "published_at": "2026-11-15 03:07:54+00:00", "updated_at": "2026-11-15 03:21:40.780060+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools"], "entities": ["OpenAI", "Codex", "Anthropic", "Claude Code", "Google", "Gemini CLI", "NousResearch", "Hermes Agent"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after", "markdown": "https://wpnews.pro/news/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after.md", "text": "https://wpnews.pro/news/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after.txt", "jsonld": "https://wpnews.pro/news/five-open-issues-one-bug-your-mcp-oauth-refresh-token-is-being-replayed-after.jsonld"}}