MCP OAuth: a rejected refresh token stays "usable", so Codex retries it forever and never surfaces re-authentication
What version of Codex CLI is running?
Reproduced on 0.140.0, 0.144.0, 0.146.0, 0.147.0 (current release) and 0.148.0-alpha.20 (current alpha). All five behave identically.
What subscription do you have?
Not applicable — the reproduction below drives codex app-server against a local stub OAuth/MCP server and never makes a model call.
Which model were you using?
None. The repro uses the app-server JSON-RPC method mcpServerStatus/list, which forces an MCP connection without a model turn.
What platform is your computer?
macOS 26.5.2, arm64 (macos-aarch64).
What terminal emulator and version are you using (if applicable)?
Not applicable — driven programmatically over stdio.
Codex doctor report
<details><summary><code>codex doctor</code> (trimmed)</summary>
Codex Doctor v0.147.0 · macos-aarch64
Environment
✓ system en-US
os Mac OS 26.5.2 [64-bit]
✓ runtime npm, version 0.147.0
✗ auth no Codex credentials were found
⚠ websocket Responses WebSocket failed; HTTPS fallback may still work
The install/updates/auth findings are expected: each version under test was installed into its own scratch directory with an isolated CODEX_HOME, and no Codex login is needed to reproduce this.
</details>
What issue are you seeing?
When an MCP server rejects a refresh with a spec-correct 400 invalid_grant, Codex does not discard the refresh token. It re-sends the identical token on every subsequent attempt, indefinitely, and never falls back to a fresh authorization flow. The app layer is simultaneously told the server is still authenticated, so no re-authenticate affordance appears.
The user sees the MCP server present but with zero tools, and nothing indicates that logging in again would fix it.
Observed, server side
Three consecutive attempts against the stub, with an expired access token:
POST /mcp -> 401 (no bearer)
GET /.well-known/oauth-protected-resource
GET /.well-known/oauth-authorization-server
POST /token grant_type=refresh_token -> 400 invalid_grant refresh_token=rt-ae49f761…
POST /mcp -> 401 (no bearer)
GET /.well-known/oauth-protected-resource
GET /.well-known/oauth-authorization-server
POST /token grant_type=refresh_token -> 400 invalid_grant refresh_token=rt-ae49f761…
POST /mcp -> 401 (no bearer)
GET /.well-known/oauth-protected-resource
GET /.well-known/oauth-authorization-server
POST /token grant_type=refresh_token -> 400 invalid_grant refresh_token=rt-ae49f761…
The same refresh_token value each time. No POST /register, no GET /authorize.
Note that Codex does honour WWW-Authenticate and re-fetches the protected-resource metadata on every attempt, then still chooses refresh_token. So a server cannot steer it into a fresh consent by advertising metadata.
Observed, client side
The failure appears only on stderr:
ERROR codex_rmcp_client::oauth::refresh_transaction: error=failed to refresh OAuth
tokens for server stub: OAuth token refresh failed: Server returned error response:
invalid_grant: Refresh token not found or already used
But the JSON-RPC response to the app is a success:
{"data":[{"name":"stub","serverInfo":null,"tools":{},"resources":[],
"resourceTemplates":[],"authStatus":"oAuth"}]}
authStatus still reports oAuth. There is no error field. Only tools is empty and serverInfo is null. That combination is why no re-auth prompt is offered — the app has nothing to trigger one from.
Root cause
Line references pinned to d7d526b81db92eb0ee6a47dfed9cee9f92b1935f.
codex-rs/rmcp-client/src/oauth.rs:278-291:
fn oauth_tokens_are_usable(tokens: &StoredOAuthTokens) -> bool {
if tokens.client_id.trim().is_empty() {
return false;
}
let token_response = &tokens.token_response.0;
if token_needs_refresh(tokens.expires_at) {
return token_response
.refresh_token()
.is_some_and(|token| !token.secret().trim().is_empty());
}
!token_response.access_token().secret().trim().is_empty()
}
When the access token needs refreshing, the credential is judged usable purely because a non-empty refresh-token string is present. Whether the authorization server accepted that token never enters the decision, and nothing records that it was rejected.
That feeds oauth_token_status (oauth.rs:203-220), which returns StoredOAuthTokenStatus::Usable, and auth_status.rs:163-175 maps it:
match oauth_token_status(server_name, url, store_mode, keyring_backend_kind)? {
StoredOAuthTokenStatus::Usable => {
return Ok(AuthStatusCheck::Complete(McpAuthState::OAuth));
}
StoredOAuthTokenStatus::AuthorizationRequired => {
return Ok(AuthStatusCheck::Complete(McpAuthState::LoggedOut(
McpLoginRequirement::Reauthentication,
)));
}
StoredOAuthTokenStatus::Missing => {}
}
The recovery path already exists — it is the AuthorizationRequired arm. A rejected refresh token simply never reaches that state.
Two supporting details:
invalid_grantis never inspected.grep -i invalid_grantreturns zero matches in bothoauth.rsandauth_status.rs. The OAuth error code is not read, so a permanent rejection and a transient network failure are treated identically.- The only credential-clearing path cannot fire here.
persist_if_needed(oauth.rs:679) deletes the stored credential in itsNone =>arm (oauth.rs:717) — that is, when a refresh succeeded and returned no tokens. A400is anErr, so it never reaches that arm.
Production impact
Our MCP server (mcp.arcade.software) saw 206 failed /token calls over the 3.6 days after we started returning 400 invalid_grant instead of 500. 163 of those 206 came from Codex clients that had no successful token exchange in the window — three machines retrying a dead grant every 5 to 9 minutes, continuously, for four days. Each user was signed out with no prompt and no way to notice.
We had switched to 400 invalid_grant precisely so clients would stop retrying and re-authorize. For Codex that made no difference.
What steps can reproduce the bug?
- Save the stub server below and start it. It completes
authorization_codenormally and returns400 invalid_grantfor everyrefresh_tokengrant — the exact response a server gives for a genuinely dead token.
<details><summary><code>stub.mjs</code> — no dependencies</summary>
```js
import http from 'node:http'
import { randomUUID } from 'node:crypto'
const PORT = 8931, BASE = http://localhost:${PORT}
const TTL = 120 // seconds; must exceed Codex's refresh margin
const access = new Map()
const json = (res, code, body, headers = {}) => {
const s = JSON.stringify(body)
res.writeHead(code, { 'content-type': 'application/json', ...headers })
res.end(s)
}
http.createServer(async (req, res) => {
const url = new URL(req.url, BASE)
let raw = ''
for await (const c of req) raw += c
const ct = req.headers['content-type'] ?? ''
const body = ct.includes('json')
? (() => { try { return JSON.parse(raw) } catch { return {} } })()
: Object.fromEntries(new URLSearchParams(raw))
console.log(new Date().toISOString(), req.method, url.pathname,
body.grant_type ? grant=${body.grant_type} : '',
body.refresh_token ? rt=${String(body.refresh_token).slice(0, 12)}… : '')
if (url.pathname.startsWith('/.well-known/oauth-protected-resource'))
return json(res, 200, { resource: ${BASE}/mcp, authorization_servers: [BASE] })
if (url.pathname.startsWith('/.well-known/oauth-authorization-server'))
return json(res, 200, {
issuer: BASE,
authorization_endpoint: ${BASE}/authorize,
token_endpoint: ${BASE}/token,
registration_endpoint: ${BASE}/register,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
})
if (url.pathname === '/register' && req.method === 'POST')
return json(res, 201, {
client_id: stub-${randomUUID().slice(0, 8)},
redirect_uris: body.redirect_uris ?? [],
token_endpoint_auth_method: 'none',
})
if (url.pathname === '/authorize') {
const loc = new URL(url.searchParams.get('redirect_uri'))
loc.searchParams.set('code', code-${randomUUID().slice(0, 12)})
const state = url.searchParams.get('state')
if (state) loc.searchParams.set('state', state)
res.writeHead(302, { location: loc.toString() })
return res.end()
}
if (url.pathname === '/token' && req.method === 'POST') {
if (body.grant_type === 'authorization_code') {
const t = at-${randomUUID()}
access.set(t, Date.now() + TTL * 1000)
return json(res, 200, {
access_token: t, token_type: 'Bearer', expires_in: TTL,
refresh_token: rt-${randomUUID()}, scope: 'mcp',
})
}
// Every refresh is rejected, exactly as a server does for a dead token.
return json(res, 400, {
error: 'invalid_grant',
error_description: 'Refresh token not found or already used',
})
}
if (url.pathname === '/mcp') {
const t = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '')
const exp = access.get(t)
if (!exp || exp <= Date.now())
return json(res, 401, { error: 'invalid_token' }, {
'www-authenticate': Bearer realm="mcp", error="invalid_token", resource_metadata="${BASE}/.well-known/oauth-protected-resource",
})
const m = body?.method
if (m === 'initialize') return json(res, 200, { jsonrpc: '2.0', id: body.id, result: {
protocolVersion: '2025-06-18', capabilities: { tools: {} },
serverInfo: { name: 'stub', version: '1.0.0' } } })
if (m === 'tools/list') return json(res, 200, { jsonrpc: '2.0', id: body.id, result: {
tools: [{ name: 'stub_ping', description: 'pong', inputSchema: { type: 'object', properties: {} } }] } })
if (String(m).startsWith('notifications/')) { res.writeHead(202); return res.end() }
return json(res, 200, { jsonrpc: '2.0', id: body?.id ?? null, result: {} })
}
return json(res, 404, { error: 'not_found' })
}).listen(PORT, () => console.log(stub on ${BASE}/mcp — refresh always 400 invalid_grant))
```
</details>
``bash``
node stub.mjs
- Register it and complete the OAuth flow in an isolated home:
``bash``
export CODEX_HOME=/tmp/codexhome-repro
codex mcp add stub --url http://localhost:8931/mcp
# → "Successfully logged in."
- Wait past the access-token TTL so Codex refreshes on its own clock:
``bash``
sleep 130
- Force a connection and observe. Any path that connects works;
codex app-server+mcpServerStatus/listis the deterministic one:
``bash``
codex app-server
# {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"repro","version":"1"}}}
# {"jsonrpc":"2.0","id":2,"method":"mcpServerStatus/list","params":{}}
- Repeat step 4 a few times. Each attempt sends the same refresh token, gets
400 invalid_grant, and returns"tools":{}with"authStatus":"oAuth". No/authorizeis ever attempted.
- Confirm the credential is still considered good, and that only a manual login clears the state:
``bash``
codex mcp login stub # succeeds; tools return immediately afterwards
A note on the TTL. TTL must be comfortably above Codex's refresh margin. With a 10s or 25s access token, Codex refreshes about 2.6s after a successful login, which makes step 6 look like it failed when it actually did not. 120s works; the margin sits somewhere between 25s and 120s.
What is the expected behavior?
Treat invalid_grant as what RFC 6749 §5.2 defines it to be — the refresh token is invalid, expired, or revoked — and stop reusing it.
Concretely, either:
- Delete the stored credential on
invalid_grant.oauth_token_statusthen returnsMissing, discovery runs, and the user gets a normal authorization flow. This also fixes the case where the credential lives in the keychain and survives restarts. - Or record the rejection and return
AuthorizationRequired. The existingauth_status.rs:167arm already maps that toMcpLoginRequirement::Reauthentication, so the re-auth affordance appears with no new UI work.
Either way, two smaller things would help a lot:
- Distinguish permanent from transient.
invalid_grant/invalid_clientare terminal; a 5xx or a network error is not. Right now they all take the same path, which is why an unparseable response latches the same way (#38198). - Surface the failure to the app layer. A refresh that failed should not present as a successful status response with an empty tool list and
authStatus: "oAuth". That combination is indistinguishable from "connected, server genuinely has no tools."
Additional information
A 401 from the resource server does not trigger a refresh. I also tried having /mcp reject every access token as expired, expecting the 401 to drive the refresh path. It does not — Codex sends the bearer, takes the 401, and stops without attempting a refresh. Combined with it reading and ignoring the protected-resource metadata, there is no server-side signal that can prompt a refresh, a re-auth, or a credential reset. The fix has to be client side.
Related issues. These describe the same latching behaviour from different angles; I am filing separately because this one has a spec-correct trigger and a specific root cause, but they may be worth consolidating:
- #38198 — same "connected but toolless, no Authenticate button" outcome; trigger there is an unparseable refresh response rather than a well-formed
400. - #14144 —
invalid_grantpersisting after re-auth (open since March, 15 👍). - #29630 — no re-registration on
invalid_client/ expired refresh token. - #32590 — broader MCP OAuth session-lifecycle umbrella.
If a maintainer would prefer this folded into #38198 as a comment, happy to move it.
7 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Confirmed on
main@ 1f41cc5d92 — every stage of the loop you captured is identifiable in code, and the interesting part is that all the classification machinery already exists; the one missing step is persisting the rejection.1.
invalid_grantis correctly classified — twice. The pinned rmcp maps a400 invalid_granttoken response toAuthError::TokenRefreshRejected(rmcp 3.0.0,transport/auth.rs#L2195-L2201), and codex's refresh transaction explicitly distinguishes that from transient failures and converts it toAuthorizationRequired:https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/rmcp-client/src/oauth/refresh_transaction.rs#L178-L191
2. …but the rejection arm never touches the stored credential. It logs, wraps the error, and returns. No delete, no marker — the refresh token that the server just definitively rejected remains in the credential store byte-for-byte.
3. The status layer only reads stored bytes, so the loop closes. "Usable" is derived purely structurally — expired access token + non-empty refresh token =
Usable(oauth.rs#L278-L291), which maps toMcpAuthState::OAuth(authenticated, no affordance) inauth_status.rs#L163-L173. The ironic detail:StoredOAuthTokenStatus::AuthorizationRequiredand theMcpLoginRequirement::ReauthenticationUI state both already exist — they're just unreachable for this case, because they only trigger when the stored tokens are structurally broken (e.g. empty refresh token), never when they were rejected by the server. So every new connection sees "usable" → refreshes →400 invalid_grant→ error → nothing persisted → repeat, while the app layer keeps reporting authenticated. Exactly your wire capture, and it also explains why all five versions behave identically — this gap predates them all.Fix shape (small and localized): in the
TokenRefreshRejectedarm ofrefresh_transaction, persist the invalidation before returning — either clear the refresh token from the stored credential (keeping the client registration so re-auth can skip DCR), or store arejectedmarker thatoauth_tokens_are_usablehonors. Either way the very nextoauth_token_statusread flips toAuthorizationRequired, the existingReauthenticationaffordance appears, and connection attempts stop replaying the dead token. The transaction already holds the serialized store-lock machinery, so the write has a natural home; per RFC 6749 §5.2, discarding oninvalid_grantis also simply the spec-mandated client behavior.Your stub-server harness is the regression test nearly verbatim: expired access + refresh →
400 invalid_grant→ assert exactly one refresh attempt, stored status flips to reauth-required, andmcpServerStatus/listreports the re-authentication requirement instead of authenticated-with-zero-tools.I reproduced this against
f97e77569352a2bf5be9955e623edad0a15d9b93(currentmainat validation time).The root cause is confirmed: RMCP already maps OAuth
invalid_granttoAuthError::TokenRefreshRejected, and Codex maps that toAuthorizationRequired, but the rejected refresh token currently remains in durable storage and live authorization state. Startup/reconnect can therefore load and retry the same rejected capability indefinitely.The invariant I used is: a definitively rejected refresh capability must no longer be reusable. Non-definitive failures—including 429, 5xx, timeouts, malformed responses, network failures, and
invalid_client—must preserve the credential.The focused fix is in the refresh transaction: remove only the rejected
refresh_token, persist the sanitized state to the authoritative/pinned store, and synchronize bothAuthorizationManagerand the live credential snapshot. If that save fails, fall back to deleting the credential from the same pinned store and clear the live state. Concurrent persistors reread and adopt the authoritative sanitized state rather than replaying a stale token.No app-server or protocol-state change appears necessary. Existing status propagation already reaches
LoggedOut(Reauthentication)andReauthenticationRequired.Local Rust 1.95 validation:
ReauthenticationRequiredmapping: 1/1 passed;just fix -p codex-rmcp-client: passed (scoped Clippy);git diff --check: passed;just bench-smoke: passed.The three remaining failures in the crate-wide run reproduced identically on a clean checkout of the same
mainSHA (two remote-executor timeout fixtures and one test environment missing thecodexbinary), so they are not regressions from this change.Adversarial review also found and covered additional persistence/concurrency edges, including sanitized-save failure with deletion fallback, double storage failure reporting, stale-snapshot convergence, reconnect behavior, rotation followed by rejection, and cancellation-safe persistence.
I am not claiming GitHub CI passed: upstream/multiplatform CI remains pending because I have not opened a PR without maintainer authorization. Could a maintainer confirm whether this direction matches the intended OAuth semantics? If so, I can open a focused PR when invited/enabled.
Correction to my own root-cause section, since it is wrong about your code in a way that matters.
I wrote that
invalid_grantis never inspected, on the basis thatgrep -i invalid_grantreturns nothing inoauth.rsorauth_status.rs. That grep was accurate; the conclusion I drew from it was not, because I never openedrefresh_transaction.rs.@jdcodes1 is right. The classification already exists and is already correct. Verified at
refresh_transaction.rs#L178-L191:So the defect is narrower than I described. It is not a missing classification — it is a classification that is never persisted. That arm logs and returns without touching the credential store, so the next
oauth_token_statusread derivesUsablefrom unchanged bytes and the loop closes. That also explains the identical behaviour across all five versions I tested better than my version did: the gap is in persistence, which none of those releases changed.Two further corrections while I am here:
invalid_clientmust preserve it. Clearing on any refresh failure would be a worse bug than the one reported.AuthorizationRequired. Given that theAuthorizationRequiredreturn already happens, the useful half of that suggestion is only the persistence, not the classification.The reproduction, the wire captures, the five-version sweep, and the observation that a
401from the resource server does not trigger a refresh all stand unchanged.Thanks for the correction — I revalidated this against current
mainatd68b85a0978e15c49c6e96bde1f73ddaeac35d79, and that matches what I’m seeing: classification is already correct; the remaining invariant break is persistence.The validated patch only invalidates a refresh capability on
TokenRefreshRejected: it removes the rejectedrefresh_tokenfrom the authoritative/pinned credential state, persists that sanitized state, and synchronizes bothAuthorizationManagerand the live credential snapshot. The legacy persistence hook is also fenced against stale writers so it cannot resurrect a refresh token after another refresher has invalidated it. If persisting the sanitized state fails, it falls back to removing the credential from the same authoritative store and clears live state; if both save and delete fail, the storage failure is surfaced rather than reported as a clean reauthorization state.Non-definitive failures — including 429, 5xx, timeout, network failure, malformed provider response, and
invalid_client— preserve the credential.On current
main, the focused adversarial matrix is 17/17 green and the fullcodex-rmcp-clientunit binary is 163 passed / 0 failed / 1 ignored. That covers concurrent rejected refreshes, stale-persistor resurrection, restart/reconnect, persistence failure, rotated-token rejection, retryable failures, and successful reauthorization.Validated commit:
bc51f395d01da04673576ebb9f86ebe07ac42eadBranch: https://github.com/charle-z/codex/tree/fix/mcp-oauth-rejected-refresh-persistence
Happy to open this as a PR if a maintainer wants to take this approach forward; otherwise the exact patch is there for review/cherry-pick.
This and the other already open issues related to refresh token use, and not honouring permitted scopes, are causing disruption to the use of our MCP servers and the use of third-party plugins that require auth. It would be nice to see more attention paid to these matters.
Facing the same issue.