Repository navigation
MCP OAuth: DCR client_secret is not persisted across restarts, causing refresh failures #43216
Description
What version of Codex CLI is running?
Investigation: official codex-cli 0.153.4; desktop-bundled Codex 0.152.1 before a subsequent app update. Post-update refresh not revalidated.
What subscription do you have?
Not verified for this report
Which model were you using?
Not model-specific: MCP OAuth refresh / initialization
What platform is your computer?
macOS 26.4.1 (25E253), Apple Silicon
What terminal emulator and version are you using (if applicable)?
No response
Codex doctor report
Not collected for this report. No unreviewed diagnostic dump is attached.What issue are you seeing?
Summary
Codex's MCP OAuth credential persistence path does not retain the client_secret issued during dynamic client registration (DCR) in the inspected revision. A later process therefore cannot restore the same client authentication material for token refresh.
This is a client-side persistence/restoration gap for DCR clients whose authorization server requires that secret at the token endpoint. It is not inherently specific to one MCP service. However, the real-provider evidence below is limited to Supabase; other providers and public clients that do not require a secret have NOT been shown to be affected.
Codex-side evidence
Source inspected: rust-v0.153.4, commit 3d2ee51ca2d5db578f328aa75e20aa22c0197c9a; dependencies rmcp 3.1.3 and oauth2 5.0.0.
- RMCP retains the DCR-issued secret in its OAuth client configuration for the initial authorization-code exchange.
- Codex persists
client_idandtoken_response, butStoredOAuthTokenshas noclient_secretfield. - On restoration, the OAuth client is initialized without the DCR secret. This loses client authentication material even though the token credentials themselves were stored.
Pinned source references:
codex/codex-rs/rmcp-client/src/oauth.rs
Lines 89 to 99 in 3d2ee51
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StoredOAuthTokens { pub server_name: String, pub url: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub issuer: Option<String>, pub client_id: String, pub token_response: WrappedOAuthTokenResponse, #[serde(default)] pub expires_at: Option<u64>, } codex/codex-rs/rmcp-client/src/perform_oauth_login.rs
Lines 704 to 724 in 3d2ee51
.get_credentials() .await .context("failed to retrieve OAuth credentials")?; let credentials = credentials_opt .ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; let expires_at = compute_expires_at_millis(&credentials); let stored = StoredOAuthTokens { server_name: self.server_name.clone(), url: self.server_url.clone(), issuer: self.authorization_server_issuer.clone(), client_id, token_response: WrappedOAuthTokenResponse(credentials), expires_at, }; save_oauth_tokens( &self.server_name, &stored, self.store_mode, self.keyring_backend_kind, )?;
Observed example: hosted Supabase MCP
An actual DCR response contained a nonempty client_secret (value not logged), while the affected existing macOS Keychain credential lacked that field. Login initially succeeded, but later official-client refresh attempts produced:
OAuth token refresh failed: Failed to parse server response
A controlled refresh request using an expired affected credential captured:
HTTP 422
{"message":"Required parameter: client_secret"}
This rejection is consistent with the missing client authentication material. The HTTP status and body above are specific to this provider observation, not claimed as a universal response. Its body contains message rather than the standard OAuth error field expected by the decoder, so the generic parse error also hides the underlying rejection.
What steps can reproduce the bug?
Relevant conditions
The MCP OAuth flow uses DCR, the authorization server issues a client secret and requires it for refresh, and a new Codex process restores the saved credentials. This is the failure scope inferred from the inspected client code; cross-provider reproduction remains unverified.
Reproduction outline using the observed provider
- Configure a project-scoped hosted Supabase remote MCP endpoint using the actual URL locally.
- Run
codex mcp login <server-name>with the official CLI and complete browser authorization. - Exit the client process.
- After access-token expiry, start a fresh client process and use the MCP server.
- Observe the refresh parse error. For diagnosis, inspect sanitized HTTP status and field-presence information only; do not log credentials.
The observed provider returned expires_in: 86400. A newly instrumented natural 24-hour expiry observation was NOT completed. The evidence is the repeated official-client failures, controlled provider rejection, and source/storage inspection; the outline above is not a claim of a completed fresh 24-hour end-to-end test.
What is the expected behavior?
Codex should securely persist and restore the client authentication material required by the registered OAuth client, including a DCR-issued secret when applicable, alongside the token credentials. Restoration should preserve issuer/resource binding and the applicable client authentication method. A process restart should not lose material required to refresh an otherwise refreshable session.
This does not mean requiring secrets for public clients that do not use them.
Separately, if an OAuth error response cannot be decoded, Codex should expose a sanitized HTTP status/provider error classification rather than only a generic parse error, without logging credentials or successful token responses.
Additional information
Scope and related report
The primary issue is Codex's provider-independent DCR credential persistence/restoration path. Supabase is the concrete observed example, not a claim that the defect belongs to Supabase or that every MCP provider is affected.
Related: #13852 reports the same user-visible refresh parse error and repeated reauthentication with Supabase. This report focuses on the specific client-secret persistence gap and captured rejection. It does not establish that every case in #13852 shares this cause.
Supporting experiment, not an official fix
An isolated experimental build retaining and restoring the DCR secret completed two real Supabase provider refreshes, each followed by a fresh process and a read-only get_project_url MCP call. Only the isolated store's local expiry hint was changed to trigger these checks. This supports the proposed cause in that tested case, but is NOT a natural 24-hour expiry test, an official fix, or a supported workaround. The experiment has been abandoned.
Version and validation limits
The investigation used official CLI 0.153.4 and a desktop-bundled Codex 0.152.1 environment. The desktop app was subsequently updated, resolving an unrelated computer-use configuration-loading problem. MCP OAuth refresh has NOT been revalidated after that update. No claim is made that the updated app is fixed or still affected, or that other providers were tested.
No access tokens, refresh tokens, client secrets, client IDs, project refs, account identifiers, or authorization URLs are included. Please confirm whether a newer official release addresses this DCR persistence gap.
Activity
LSGoCards7 commentedon Sep 10, 2026
Follow-up verification on 2026-09-10:
An isolated patch against Codex
rust-v0.153.4(3d2ee51ca2d5db578f328aa75e20aa22c0197c9a) and RMCP 3.1.3 passed a real Supabase natural-expiry refresh, followed by another process restart and read.The separate test grant expired at
2026-09-10T14:30:43.290Z. The first process checked it after expiry, initialized MCP, and successfully called onlyget_project_url. At17:26:40.419Z, the saved secret was unchanged, the refresh token had rotated, and the new expiry was2026-09-11T17:26:29.956Z. A second fresh process passed the same read at17:27:14.085Z. The real token's expiry was never edited. The test used a separate registration, server identity, File store, project-scoped read-only resource, and one allowlisted tool; no model turn was used.The synthetic regression performs DCR login in one process, expires only its synthetic stored access token, and starts two successive processes. The mock requires the client secret and returns the provider-shaped nonstandard error when it is absent. Both
client_secret_basicandclient_secret_postfail on unchanged 0.153.4 and pass with persistence/restoration repaired. A public client passes both versions. The patched MCP-client suite passed 289 tests; three timing-sensitive remote cases also passed serially with retries disabled. Storage coverage includes File and both injected keyring backends, legacy records, secret redaction, rotation, issuer checks, refresh locks, and persistence failure.The necessary change spans registration export, Codex
StoredOAuthTokens, fallback file serialization, login persistence, refresh persistence, and RMCP restoration. An optional typed secret preserves existing public/legacy records. Old confidential-client records require fresh authorization because their missing secret cannot be recovered.Current source still needs this repair: 0.154.0 credential storage and main at 818f1cca omit the client secret. Both select RMCP 3.2.0. RMCP 3.3.0 also omits it from stored credentials; existing SDK discussion is rust-sdk #784. The newer refresh-coordination changes do not preserve DCR client authentication material.
The SDK implementation is submitted in rust-sdk #1255, based on RMCP 3.3.0. Its 216 auth tests, formatting, and strict package Clippy checks pass. Codex must also retain the secret through its own storage paths and adopt a released SDK containing the repair.
This is candidate evidence, not official-release or daily-client acceptance. Following the current contribution policy, I am adding evidence here rather than opening a Codex PR. No credentials, project identifiers, or raw provider payloads are included.
deniel666 commentedon Sep 16, 2026
Independent production reproduction with ErzyCall MCP (Codex desktop bundled 0.153.4): a fresh runtime reached automatic refresh after token expiry and failed with
invalid_client: Invalid OAuth client credentials. Correlated server-side token-endpoint logs recorded that OAuth client configuration was present but the incoming refresh had no client secret. The provider rejects client authentication before evaluating the refresh token, so this is not an expired/reused refresh token or a user logout. ErzyCall uses DCR, issues a client secret, and requiresclient_secret_basicat the token endpoint. This matches the persistence/restoration gap described here.Scope clarification after production code review: the live observed failure was
/api/dev-mcp. However, ordinary customer/api/mcpuses the same dynamic-client registration function and shared/oauth/tokenhandler; its OAuth metadata advertises the same secret-based client authentication. Therefore the same Codex persistence defect is applicable to both endpoint families. A natural-expiry, fresh-process test against ordinary/api/mcphas not been completed. Sanitized evidence only; no tokens, client IDs, or secrets included.