Skip to content

MCP OAuth: DCR client_secret is not persisted across restarts, causing refresh failures #43216

Open

Description

@stargt

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.

  1. RMCP retains the DCR-issued secret in its OAuth client configuration for the initial authorization-code exchange.
  2. Codex persists client_id and token_response, but StoredOAuthTokens has no client_secret field.
  3. 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:

  • #[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>,
    }
  • .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

  1. Configure a project-scoped hosted Supabase remote MCP endpoint using the actual URL locally.
  2. Run codex mcp login <server-name> with the official CLI and complete browser authorization.
  3. Exit the client process.
  4. After access-token expiry, start a fresh client process and use the MCP server.
  5. 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

  1. added
    CLIIssues related to the Codex CLI
    mcpIssues related to the use of model context protocol (MCP) servers
    authIssues related to authentication and accounts
    on Sep 6, 2026
  2. LSGoCards7 commented on Sep 10, 2026

    @LSGoCards7

    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 only get_project_url. At 17:26:40.419Z, the saved secret was unchanged, the refresh token had rotated, and the new expiry was 2026-09-11T17:26:29.956Z. A second fresh process passed the same read at 17: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_basic and client_secret_post fail 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.

  3. deniel666 commented on Sep 16, 2026

    @deniel666

    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 requires client_secret_basic at 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/mcp uses the same dynamic-client registration function and shared /oauth/token handler; 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/mcp has not been completed. Sanitized evidence only; no tokens, client IDs, or secrets included.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIauthIssues related to authentication and accountsbugSomething isn't workingmcpIssues related to the use of model context protocol (MCP) servers

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions