MCP OAuth: a rejected refresh token stays "usable", so Codex retries it forever and never surfaces re-authentication

Open 💬 7 comments Opened Aug 17, 2026 by rmanalan
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

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_grant is never inspected. grep -i invalid_grant returns zero matches in both oauth.rs and auth_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 its None => arm (oauth.rs:717) — that is, when a refresh succeeded and returned no tokens. A 400 is an Err, 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?

  1. Save the stub server below and start it. It completes authorization_code normally and returns 400 invalid_grant for every refresh_token grant — 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
``

  1. 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."
``

  1. Wait past the access-token TTL so Codex refreshes on its own clock:

``bash
sleep 130
``

  1. Force a connection and observe. Any path that connects works; codex app-server + mcpServerStatus/list is 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":{}}
``

  1. Repeat step 4 a few times. Each attempt sends the same refresh token, gets 400 invalid_grant, and returns "tools":{} with "authStatus":"oAuth". No /authorize is ever attempted.
  1. 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:

  1. Delete the stored credential on invalid_grant. oauth_token_status then returns Missing, 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.
  2. Or record the rejection and return AuthorizationRequired. The existing auth_status.rs:167 arm already maps that to McpLoginRequirement::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_client are 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_grant persisting 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.

View original on GitHub ↗

7 Comments

github-actions[bot] contributor · 10 days ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #38198

Powered by Codex Action

jdcodes1 · 10 days ago

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_grant is correctly classified — twice. The pinned rmcp maps a 400 invalid_grant token response to AuthError::TokenRefreshRejected (rmcp 3.0.0, transport/auth.rs#L2195-L2201), and codex's refresh transaction explicitly distinguishes that from transient failures and converts it to AuthorizationRequired:

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 to McpAuthState::OAuth (authenticated, no affordance) in auth_status.rs#L163-L173. The ironic detail: StoredOAuthTokenStatus::AuthorizationRequired and the McpLoginRequirement::Reauthentication UI 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 TokenRefreshRejected arm of refresh_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 a rejected marker that oauth_tokens_are_usable honors. Either way the very next oauth_token_status read flips to AuthorizationRequired, the existing Reauthentication affordance 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 on invalid_grant is 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, and mcpServerStatus/list reports the re-authentication requirement instead of authenticated-with-zero-tools.

charle-z · 9 days ago

I reproduced this against f97e77569352a2bf5be9955e623edad0a15d9b93 (current main at validation time).

The root cause is confirmed: RMCP already maps OAuth invalid_grant to AuthError::TokenRefreshRejected, and Codex maps that to AuthorizationRequired, 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 both AuthorizationManager and 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) and ReauthenticationRequired.

Local Rust 1.95 validation:

  • focused OAuth matrix: 14/14 passed;
  • auth-status mapping: 1/1 passed;
  • ReauthenticationRequired mapping: 1/1 passed;
  • just fix -p codex-rmcp-client: passed (scoped Clippy);
  • Cargo fmt check: passed;
  • 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 main SHA (two remote-executor timeout fixtures and one test environment missing the codex binary), 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.

rmanalan · 9 days ago

Correction to my own root-cause section, since it is wrong about your code in a way that matters.

I wrote that invalid_grant is never inspected, on the basis that grep -i invalid_grant returns nothing in oauth.rs or auth_status.rs. That grep was accurate; the conclusion I drew from it was not, because I never opened refresh_transaction.rs.

@jdcodes1 is right. The classification already exists and is already correct. Verified at refresh_transaction.rs#L178-L191:

Ok(Err(error @ AuthError::TokenRefreshRejected(_))) => {
    // RMCP 3 distinguishes definitive refresh-token rejection from transient
    // provider failures. Only a rejected token requires a fresh authorization.
    warn!(error = %error, "MCP OAuth refresh token was rejected; reauthorization required");
    return Err(AuthError::AuthorizationRequired).with_context(|| { ... });
}

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_status read derives Usable from 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:

  • I described the ask as "distinguish permanent from transient." @charle-z's invariant is the sharper form and the one I should have written: only a definitive rejection may clear the credential, while 429, 5xx, timeouts, malformed bodies and invalid_client must preserve it. Clearing on any refresh failure would be a worse bug than the one reported.
  • My "Concrete upstream ask" section proposed either deleting the credential or marking it AuthorizationRequired. Given that the AuthorizationRequired return 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 401 from the resource server does not trigger a refresh all stand unchanged.

charle-z · 9 days ago

Thanks for the correction — I revalidated this against current main at d68b85a0978e15c49c6e96bde1f73ddaeac35d79, 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 rejected refresh_token from the authoritative/pinned credential state, persists that sanitized state, and synchronizes both AuthorizationManager and 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 full codex-rmcp-client unit 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: bc51f395d01da04673576ebb9f86ebe07ac42ead
Branch: 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.

CMarcher · 2 days ago

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.

jeet23 · 1 day ago

Facing the same issue.