Codex Desktop does not refresh OAuth-backed MCP access token before startup; keeps sending expired bearer from Keychain

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

Summary

Codex Desktop / bundled Codex CLI fails to refresh an OAuth-backed Streamable HTTP MCP server before startup/tool discovery, even though the stored refresh token is present and valid.

Instead, Codex reads the expired access token from macOS Keychain and sends it to /mcp as Authorization: Bearer .... The MCP server returns 401 Invalid or expired access token. Codex does not call the advertised /oauth/token endpoint, so the MCP tools are not exposed.

This looks closely related to #17265, #26237, #26522, and #26523, but I am adding a concrete end-to-end trace with both Codex logs and server-side logs.

Environment

  • Product: Codex Desktop App on macOS
  • Bundled Codex CLI: codex-cli 0.137.0-alpha.4
  • PATH Codex CLI: codex-cli 0.135.0 (not the failing runtime)
  • Credential store: macOS Keychain, service Codex MCP Credentials
  • MCP server transport: streamable_http
  • MCP server auth: OAuth, no static bearer_token_env_var
  • Access token TTL: 3600 seconds
  • Refresh token TTL: 30 days
  • MCP config shape:
[mcp_servers.dam]
url = "https://direct-alert.ru/mcp"

codex mcp list --json reports this server as OAuth-backed:

{
  "name": "dam",
  "transport": {
    "type": "streamable_http",
    "url": "https://direct-alert.ru/mcp",
    "bearer_token_env_var": null,
    "http_headers": null,
    "env_http_headers": null
  },
  "auth_status": "o_auth"
}

Stored credential state

The local Keychain item exists under service Codex MCP Credentials for this MCP server. It contains both an access token and a refresh token.

Sanitized details:

server_name: dam
url: https://direct-alert.ru/mcp
client_id: mcp_SfV2_SWHRrmg8skr6xFJ51BuYbwdloxe
access_token_sha256_prefix16: 666fece093144e0c
refresh_token_sha256_prefix16: b5a3ba9ca5cfeeb5
expires_at: 2026-06-08T14:38:29.780Z
scope: mcp:tools
token_type: bearer

Server-side DB confirms the same token state:

access  fp=666fece093144e0c user_id=1 expires_at=2026-06-08 14:38:29.777816 revoked_at=null
refresh fp=b5a3ba9ca5cfeeb5 user_id=1 expires_at=2026-07-08 13:38:29.777680 revoked_at=null

There are no newer access tokens for this user/client after 2026-06-08 13:38:29Z.

OAuth metadata is valid

The protected resource metadata advertises the authorization server:

{
  "resource": "https://direct-alert.ru/mcp",
  "authorization_servers": ["https://direct-alert.ru"],
  "bearer_methods_supported": ["header"],
  "resource_name": "direct-alert-mcp",
  "scopes_supported": ["mcp:tools"]
}

The authorization server metadata advertises the token endpoint:

{
  "issuer": "https://direct-alert.ru",
  "authorization_endpoint": "https://direct-alert.ru/oauth/authorize",
  "token_endpoint": "https://direct-alert.ru/oauth/token",
  "registration_endpoint": "https://direct-alert.ru/oauth/register",
  "revocation_endpoint": "https://direct-alert.ru/oauth/revoke"
}

Reproduction

  1. Configure an OAuth-backed Streamable HTTP MCP server.
  2. Run codex mcp login <server> and complete OAuth.
  3. Confirm the Keychain credential contains both access_token and refresh_token.
  4. Wait until the stored access token expires while the refresh token remains valid.
  5. Start a fresh Codex Desktop / bundled CLI run that needs MCP tool discovery.

I reproduced the fresh startup path with:

/Applications/Codex.app/Contents/Resources/codex exec \
  --cd /Users/antonperepecaev/Desktop/projects/direct-alert \
  --sandbox read-only \
  --json \
  'Check whether the DAM MCP is available; do not create or modify files.'

Observed Codex behavior

Bundled Codex stderr shows an auth failure during MCP startup:

ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when AuthRequired(AuthRequiredError { www_authenticate_header: "Bearer resource_metadata=\"https://direct-alert.ru/.well-known/oauth-protected-resource/mcp\"" })

WARN codex_mcp::rmcp_client: failed to initialize MCP client during shutdown: MCP startup failed: handshaking with MCP server failed: Send message error Transport [...] error: Auth required, when send initialize request

The MCP tools are not exposed to the session.

Server-side trace for the same startup window

Fresh startup window: 2026-06-09T10:19:41Z..10:21:00Z.

Server logs:

10:19:50.243 GET  /mcp                                      -> 401, no bearer token
10:19:50.277 GET  /.well-known/oauth-protected-resource/mcp -> 200
10:19:50.310 GET  /.well-known/oauth-authorization-server   -> 200
10:19:50.464 POST /mcp                                      -> 401 Invalid or expired access token, access_token_fingerprint=666fece093144e0c

Critically, there is no /oauth/token request in that window.

So Codex successfully discovers the OAuth metadata, learns the correct token endpoint, but then sends the expired stored access token to /mcp instead of refreshing via /oauth/token.

Why this appears Codex-side

  • The server exposes a valid token_endpoint in OAuth metadata.
  • The refresh token is present, not revoked, and not expired.
  • The access token fingerprint sent by Codex exactly matches the expired access token stored in Keychain.
  • No /oauth/token request reaches the server during startup/tool discovery.
  • Other MCP transport types work because they do not use this path:
  • stdio MCPs do not use OAuth refresh;
  • local Streamable HTTP with bearer_token_env_var uses a static bearer;
  • only OAuth-backed Streamable HTTP MCP fails after access token expiry.

Expected behavior

Before MCP initialize/tool discovery, Codex should detect that the persisted OAuth access token is expired or near expiry, call the advertised token endpoint with grant_type=refresh_token, persist the new token response, and then initialize the MCP server with the fresh access token.

If the server returns 401 invalid_token during initialize, Codex should refresh once using the stored refresh token and retry initialize, or surface a clear reauth-required error if refresh fails.

Actual behavior

Codex does not call /oauth/token; it sends the expired Keychain access token to /mcp, receives 401, and the MCP tools are unavailable.

Manual re-login fixes the problem temporarily until the next access-token expiry.

View original on GitHub ↗

7 Comments

github-actions[bot] contributor · 2 months ago

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

  • #25903

Powered by Codex Action

cawatt-cisco · 2 months ago

I can corroborate this from a Windows Codex Desktop setup with an OAuth-backed Streamable HTTP MCP server that supports Dynamic Client Registration and refresh-token rotation.

Sanitized reproduction/evidence:

  • MCP server: OAuth/DCR-backed Streamable HTTP endpoint.
  • The OAuth discovery metadata advertises a token_endpoint, grant_types_supported includes refresh_token, token_endpoint_auth_methods_supported includes none, and scopes include offline refresh capability.
  • Codex stores an MCP OAuth credential entry with access_token, refresh_token, client_id, expires_at, scopes, server name, and server URL.
  • A direct grant_type=refresh_token request to the advertised token endpoint succeeds and returns a rotated access_token, rotated refresh_token, and expires_in.
  • A direct raw MCP JSON-RPC initialize request using the refreshed access token succeeds with HTTP 200 and returns server info.
  • However, the running Codex Desktop MCP worker can still return Auth required after external refresh/login, which suggests the worker either does not always refresh before initialize, does not recover reactively from 401/AuthRequired, or does not update its in-memory Authorization state after the credential cache changes.

The code appears to have part of the intended mechanism already:

  • codex-rs/rmcp-client/src/oauth.rs has OAuthPersistor::refresh_if_needed() and persists rotated tokens via persist_if_needed().
  • codex-rs/app-server/src/mcp_refresh.rs can queue RefreshMcpServers for active threads.

The remaining product gap seems to be one or both of these:

  1. Before startup/tool discovery/initialize, expired or near-expired OAuth credentials are not reliably refreshed for the worker that will send the MCP initialize request.
  2. After a 401/AuthRequired response, Codex does not reliably perform a single refresh-token recovery, atomically persist the rotated token set, update the live worker Authorization state, and retry initialize/tool call once before asking for browser OAuth.

Desired behavior:

  • If a stored refresh token is present and valid, Codex should refresh without browser OAuth when the access token is expired/near-expired or when initialize/tool call returns 401/AuthRequired/invalid_token.
  • Refresh-token rotation should update access_token, refresh_token, and expires_at atomically in the configured credential store.
  • The running MCP worker should update its in-memory Authorization header/state after refresh, not require a Desktop/thread restart to pick up the credential change.
  • If refresh fails with invalid_grant or equivalent, only then prompt browser OAuth/reconnect.
  • Logging should distinguish: expired access token preflight refresh, reactive 401 refresh, invalid refresh token, server auth failure, and worker/cache mismatch.

Useful tests would cover:

  • startup with expired access token and valid refresh token refreshes before initialize;
  • initialize returns 401 invalid_token, refresh succeeds, initialize is retried once;
  • rotated refresh token is persisted and used for the next refresh;
  • invalid refresh token surfaces reauth rather than looping;
  • concurrent MCP requests single-flight refresh rather than racing token rotation;
  • active Desktop/thread worker uses refreshed credentials without requiring a new chat or app restart.

This is especially important for unattended/overnight agents using OAuth-backed MCP servers where daily browser reauth defeats the purpose of storing refresh tokens.

2sang-min · 2 months ago

Additional sanitized reproduction, in case it helps narrow this down. This looks like the same failure mode, but on Linux with mcp_oauth_credentials_store = "file" and Atlassian Rovo MCP rather than macOS Keychain.

Environment

  • Product/runtime: Codex CLI
  • Version: codex-cli 0.141.0
  • OS: Linux x86_64, Ubuntu 24.04 family, hostname redacted
  • MCP credential store: mcp_oauth_credentials_store = "file"
  • MCP server: Atlassian Rovo MCP, Streamable HTTP
  • MCP URL: https://mcp.atlassian.com/v1/mcp/authv2
  • codex mcp list --json reports this server as OAuth-backed:
{
  "name": "atlassian",
  "enabled": true,
  "transport": "streamable_http",
  "url": "https://mcp.atlassian.com/v1/mcp/authv2",
  "auth_status": "o_auth",
  "startup_timeout_sec": null,
  "tool_timeout_sec": null
}

Timeline

  1. Fresh Codex startup failed with:
MCP client for `atlassian` failed to start: MCP startup failed: handshaking with MCP server failed:
  Send message error Transport
  [rmcp::transport::worker::WorkerTransport<rmcp::transport::streamable_http_client::StreamableHttpClientWorker<rmcp::transport::auth::AuthClient<codex_rmcp_client::http_client_adapter::StreamableHttpClientAdapter>>>] error: Auth error: OAuth authorization required, when send initialize request

MCP startup incomplete (failed: atlassian)
  1. The user then manually ran codex mcp login atlassian. The credential file was updated and Codex could treat the server as configured/authenticated again.
  2. After the newly issued access token expired, a later fresh Codex startup failed again with the same OAuth authorization required, when send initialize request error.

Sanitized credential state after manual login

No token values included. The stored credential contained exactly one Atlassian entry.

{
  "credentials_file_mode": "600",
  "credentials_file_mtime_iso": "2026-06-25T06:01:27.374Z",
  "atlassian_entry_count": 1,
  "server_name": "atlassian",
  "server_url": "https://mcp.atlassian.com/v1/mcp/authv2",
  "access_token_present": true,
  "refresh_token_present": true,
  "stored_expires_at_iso": "2026-06-25T14:01:27.368Z",
  "scopes_count": 21,
  "scopes_include_offline_access": true,
  "access_iat_iso": "2026-06-25T06:01:27.000Z",
  "access_exp_iso": "2026-06-25T14:01:27.000Z",
  "access_expired_at_next_failure": true,
  "access_issuer": "https://auth.atlassian.com",
  "access_type": "ACCESS",
  "refresh_iat_iso": "2026-06-25T06:01:27.000Z",
  "refresh_exp_iso": "2026-09-23T06:01:27.000Z",
  "refresh_expired_at_next_failure": false,
  "refresh_issuer": "https://auth.atlassian.com",
  "refresh_type": "ROTATING_REFRESH"
}

Atlassian OAuth metadata checks

Unauthenticated request to the MCP endpoint returns a proper Bearer challenge with resource metadata:

HTTP/2 401
www-authenticate: Bearer resource_metadata="https://mcp.atlassian.com/.well-known/oauth-protected-resource/v1/mcp/authv2", error="invalid_token", error_description="Missing or invalid access token"

Protected resource metadata also matches the configured resource and advertises offline_access:

{
  "resource": "https://mcp.atlassian.com/v1/mcp/authv2",
  "authorization_servers_count": 1,
  "bearer_methods_supported": ["header"],
  "scopes_include_offline_access": true,
  "scopes_include_read_jira_work": true,
  "scopes_include_write_jira_work": true
}

The advertised authorization server metadata includes a token endpoint and refresh_token grant support:

{
  "issuer": "https://auth.atlassian.com",
  "token_endpoint_present": true,
  "registration_endpoint_present": true,
  "grant_types_supported": [
    "authorization_code",
    "client_credentials",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:token-revoke",
    "urn:ietf:params:oauth:grant-type:jwt-bearer",
    "urn:ietf:params:oauth:grant-type:token-exchange"
  ]
}

Interpretation

This does not appear to be specific to macOS Keychain. In this reproduction:

  • Codex recognizes the MCP server as OAuth-backed (auth_status: "o_auth").
  • File-backed MCP OAuth credentials are present and readable.
  • The access token is expired at the next failure.
  • The refresh token is still present and unexpired.
  • The server advertises offline_access, a token endpoint, and refresh_token grant support.
  • Manual codex mcp login atlassian temporarily fixes the issue until the access token expires again.

I did not manually exchange the stored refresh token because Atlassian uses a rotating refresh token and I did not want to mutate the user's credential state while collecting diagnostics.

Based on the above, this looks consistent with Codex not refreshing the stored OAuth-backed Streamable HTTP MCP access token before startup/tool discovery, or not retrying initialize with a refreshed token after invalid_token.

gentoo9ball · 2 months ago

This is not a duplicate. I'm seeing it on ubuntu with the latest codex cli.

maxim-ge · 1 month ago

Have this on OpenAI Codex (v0.143.0)

urda · 1 month ago

This happens a lot

╭───────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.145.0)                    │
│                                               │
│ model:     gpt-5.6-sol low   /model to change │
│ directory: ~/dev/sandboxes/CodexBar           │
╰───────────────────────────────────────────────╯

  Tip: New Use /fast to enable our fastest inference with increased plan usage.

⚠ MCP client for `atlassian` failed to start: MCP startup failed: failed to refresh OAuth tokens for server atlassian: OAuth token refresh failed:
  Server returned error response: unauthorized_client: refresh_token is invalid

⚠ MCP startup incomplete (failed: atlassian)

My claude does not log out this much, but codex does all the time. It's very annoying.

milind-nair · 2 days ago

Additional current reproduction for Atlassian Rovo MCP.

Environment

  • Codex CLI: codex-cli 0.147.0
  • Platform: Rocky Linux 8.10, Linux x86_64
  • MCP transport: Streamable HTTP with OAuth
  • MCP URL: https://mcp.atlassian.com/v1/mcp/authv2
  • Multiple concurrent Codex sessions share the default keyring

Observed behavior

codex mcp login atlassian restores access temporarily. After the access token expires, Codex requests reauthorization. The Codex logs contain:

MCP OAuth provider refresh failed
OAuth token refresh failed: Server returned error response: unauthorized_client: refresh_token is invalid

The same failure occurs with Codex CLI 0.145.0 and 0.147.0. Immediately before the provider error, the logs report successful loading of the Atlassian credential from Codex MCP Credentials. The current CLI also uses the shared MCP OAuth transaction lock and rereads the authoritative credential store before refreshing.

Expected behavior

Concurrent Codex sessions should refresh the shared Atlassian OAuth credentials and continue operating without daily browser reauthorization. Rotated credentials should update active MCP clients or trigger a clean MCP reconnect.

No OAuth token values or private credentials are included here.