openai / openai/codex

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

Open
#39,054 10 comments 8 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

app-server auth bug CLI mcp
Dominant language
Rust
Stars
125k
Forks
19.5k
PR merge metrics
PR metrics pending

Description

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
codex doctor (trimmed)
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.

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.

    stub.mjs — no dependencies
    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`))
    
    node stub.mjs
    
  2. Register it and complete the OAuth flow in an isolated home:

    export CODEX_HOME=/tmp/codexhome-repro
    codex mcp add stub --url http://localhost:8931/mcp
    # → "Successfully logged in."
    
  3. Wait past the access-token TTL so Codex refreshes on its own clock:

    sleep 130
    
  4. Force a connection and observe. Any path that connects works; codex app-server + mcpServerStatus/list is the deterministic one:

    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":{}}
    
  5. 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.

  6. Confirm the credential is still considered good, and that only a manual login clears the state:

    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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in codex-rs/rmcp-client/src/oauth.rs at oauth_tokens_are_usable, oauth_token_status, and persist_if_needed, then trace the AuthorizationRequired handling in auth_status.rs. Run the app-server mcpServerStatus/list reproduction against the supplied stub and verify that a rejected refresh no longer remains usable, repeated attempts stop, and re-authentication is surfaced.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
api, authentication, cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.