Skip to content

[Bug]: restore Codex env_key bearer admission and direct-mode OpenAI credential substitution #1686

Closed

Description

@rrmlima

Summary

/v1/responses rejects requests authenticated with Authorization: Bearer <token> even though that is exactly what the Codex CLI sends when a provider is injected with env_key (the runtime contract since codex-cli 0.146). resolveResponsesApiAuth only reads x-opencodex-api-key ("Dedicated header ONLY"), so any user who injects the proxy with env_key — the modern runtime's preferred form — gets 401 authentication_error: opencodex API key required on every model, including native OpenAI passthrough.

This is inconsistent with the rest of the proxy: resolveApiAuth (the non-responses path) already accepts authorization as a fallback, and the grok injector already emits env_key.

This issue also owns the directly coupled failure previously tracked in #1687: after an env_key admission bearer is accepted, codexAccountMode: "direct" must replace the proxy admission credential with the stored main ChatGPT/OpenAI credential before forwarding upstream. The two failures are one end-to-end auth contract and must be fixed and tested together.

Reproduction

# 1. Inject the proxy with env_key (codex-cli 0.146+ contract)
# [model_providers.opencodex]
# base_url = "http://127.0.0.1:10100/v1"
# env_key = "OPENCODEX_API_AUTH_TOKEN"

# 2. Start the proxy with OPENCODEX_API_AUTH_TOKEN exported, then request:
curl -X POST http://127.0.0.1:10100/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENCODEX_API_AUTH_TOKEN" \
  -d '{"model":"gpt-5.6-luna","input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}],"stream":true,"store":false}'

Logs and screenshots

Admission failure:

{"error":{"message":"opencodex API key required","type":"authentication_error","code":"invalid_api_key"}}

The same request with x-opencodex-api-key: $OPENCODEX_API_AUTH_TOKEN succeeds (200), proving the token is valid — only the header is rejected.

After bearer admission is accepted, direct-mode forwarding can instead fail with the second half of this contract:

{"error":{"message":"OpenCodex admission credentials cannot be forwarded upstream","type":"invalid_request_error","code":"invalid_request_error"}}

Area

Proxy runtime / Codex integration / direct OpenAI forwarding

Version

2.14.2 (also present on the affected main/dev lineage when reported)

OS

Ubuntu 24.04 (VPS, root)

Config shape

{
  "providers": {
    "openai": {
      "adapter": "openai-responses",
      "baseUrl": "https://chatgpt.com/backend-api/codex",
      "codexAccountMode": "direct",
      "authMode": "forward"
    }
  }
}

Root cause

There are two sequential failures in the same env_key flow.

1. Responses admission rejects the bearer form

src/server/auth-cors.ts resolveResponsesApiAuth only accepts the dedicated OpenCodex header:

const actual = req.headers.get("x-opencodex-api-key")?.trim();
if (!actual) return null;

Meanwhile resolveApiAuth already accepts Authorization: Bearer as a fallback, and modern Codex env_key injection populates that standard bearer header.

2. Direct-mode forwarding must substitute the admission bearer

Once the caller is admitted, the same bearer is a proxy admission credential and must never be forwarded to ChatGPT/OpenAI. In codexAccountMode: "direct", when a stored main Codex account exists, the forwarding path must replace it with the real account access token and chatgpt-account-id.

The regression tracked in former #1687 was that validateForwardAdmissionCredential rejected the admission bearer unconditionally while the ctx.kind === "main" substitution path had also been removed. That makes a correct env_key request fail even after admission is fixed.

Required fix

Treat this as one end-to-end contract:

  1. resolveResponsesApiAuth accepts Authorization: Bearer <OPENCODEX_API_AUTH_TOKEN> as an admission fallback while preserving the dedicated header path.
  2. Codex injection uses the supported env_key contract, with compatibility handling for older runtimes where required.
  3. For direct-mode OpenAI forwarding, an admitted proxy bearer is never sent upstream.
  4. If a stored main ChatGPT/OpenAI account is available, replace the admission bearer with the stored access token and the matching chatgpt-account-id.
  5. If substitution is impossible, remain fail-closed and reject the request rather than leaking the admission credential.
  6. Pool mode and non-bearer dedicated-header flows remain unchanged.

Security invariant

Accepting the bearer for proxy admission does not mean it may be forwarded upstream. Admission validation and upstream authentication are separate credential domains.

The proxy must therefore preserve both properties:

  • accept the modern Codex env_key bearer when its value matches the configured OpenCodex admission token;
  • never forward that admission token to ChatGPT/OpenAI, substituting a real stored account credential only when one is available.

Acceptance matrix

  • x-opencodex-api-key with the valid admission secret -> admitted, unchanged.
  • Authorization: Bearer <valid admission secret> -> admitted.
  • no valid admission key where auth is required -> 401.
  • direct mode + admission bearer + main account present -> upstream receives the stored ChatGPT/OpenAI credential, not the admission token.
  • direct mode + admission bearer + no usable main account -> fail closed with no credential leak.
  • pool mode -> existing pool credential override remains unchanged.
  • dedicated-header admission -> existing forwarding behavior remains unchanged.
  • regression coverage proves the OpenCodex admission secret cannot appear in upstream request headers.

Alternatives considered

  • Keep env_http_headers and do nothing: breaks modern Codex env_key users.
  • Fix only admission: still leaves direct-mode requests failing at the forward-credential guard.
  • Fix only forwarding: requests never reach that stage when /v1/responses rejects the bearer.
  • Forward the admission bearer as-is: invalid and unsafe because the OpenCodex admission secret is not an upstream ChatGPT/OpenAI credential.

Related

Activity

  1. coderabbitai commented on Aug 14, 2026

    @coderabbitai
    Contributor
    ⚠️ Possible Duplicate Issue(s)
    🔗 Related PRs

    #496 - fix(auth): support standard OpenAI Authorization and x-api-key headers in remote mode [open]
    #749 - fix(responses): reject expired forward continuations [merged]
    #1020 - fix(anthropic): surface upstream error bodies through sidecar bridges [merged]
    #1471 - fix(auth): pin forwarded Codex credentials to ChatGPT [merged]
    #1755 - fix(claude): authenticate gateway model cache refresh [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. added
    proxyHTTP proxy, routing, reverse-proxy / management auth
    cliCLI, config inject, packaging flags
    on Aug 15, 2026
  3. changed the title [-][Bug]: /v1/responses rejects Authorization: Bearer when provider is injected with env_key (codex-cli >= 0.146)[/-] [+][Bug]: restore Codex env_key bearer admission and direct-mode OpenAI credential substitution[/+] on Aug 16, 2026
  4. added
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    streamingSSE, WebSocket, terminal stream frames
    on Aug 16, 2026
  5. Wibias commented on Aug 16, 2026

    @Wibias
    Contributor

    Merged #1687 into this issue because the two reports are sequential stages of the same Codex env_key authentication contract. Admission must first accept the proxy token from Authorization: Bearer; direct-mode forwarding must then replace that admission credential with the stored ChatGPT/OpenAI account credential rather than rejecting or forwarding the proxy secret. The title, scope, security invariant, and acceptance matrix now cover both stages end-to-end.

  6. lidge-jun commented on Aug 16, 2026

    @lidge-jun
    Owner

    Groundwork landed on dev by #1853 (merged as the commit above). Keeping this open for the remaining half.

    What landed. The blocker was structural: DataPlaneAdmission named which credential matched but not how it was presented, so "a proxy secret arriving as a bearer on the Responses transport" could not be expressed — the transport rejected every bearer, which is why a client configured with env_key could not reach Direct at all.

    • DataPlaneAdmission now carries source: loopback | dedicated | bearer | x-api-key.
    • The Responses and Chat transports accept a bearer that is one of our admission secrets. The dedicated header still wins when both are present, x-api-key is still refused there, and a bearer that is not our secret stays unadmitted — so Codex Direct passthrough is untouched and the two bearer domains still never mix.
    • materializeCodexUpstreamAuth is now the single place the two credential domains meet. Pool and main-pool overwrite as before; main with an admission bearer substitutes the stored main credential and throws CodexMainSubstitutionUnavailableError before any upstream I/O when none is usable; main with a dedicated header keeps the existing intentional passthrough.

    One correction to the original report worth recording. I could not reproduce an admission secret actually reaching an upstream at this SHA: Direct rejects a recognized proxy bearer before upstream auth resolution, pool/main-pool overwrite it, and non-forward adapters install their own credential. Only canonical authMode: "forward" forwards a caller bearer, which is intentional. The real defect is the inverse — the proxy refused the flow you wanted instead of admitting it and substituting. That is why the fix widens admission and adds guaranteed substitution together; widening alone would have created the leak the forward guard prevents.

    What is still open:

    1. Threading the resolved admission through the HTTP, compact and Chat-translated handlers so Direct actually performs the substitution end to end. WebSocket already retains it; HTTP currently drops it before handleResponses.
    2. Emitting modern env_key from injection — today it writes only env_http_headers.

    Both are documented at devlog/_plan/260816_wave34_closeout/100_1686_admission.md. They are deliberately separate commits so the boundary change could be reviewed on its own.

  7. lidge-jun commented on Aug 16, 2026

    @lidge-jun
    Owner
  8. mkarolczyk commented on Aug 17, 2026

    @mkarolczyk

    Still reproducible on released 2.24.0 with the default loopback bind

    I can confirm that the Direct-mode path described here is still broken in the released 2.24.0.

    Environment:

    • OpenCodex 2.24.0
    • macOS
    • healthy proxy on 127.0.0.1:10100
    • DSH client using api: openai-responses
    • DSH sends Authorization: Bearer <local OpenCodex admission value>
    • OpenAI provider used authMode: "forward" and codexAccountMode: "direct" during the test

    The request repeatedly fails before upstream execution. A representative usage.jsonl entry contains:

    {
      "provider": "openai",
      "requestedModel": "gpt-5.6-luna",
      "admissionKind": "loopback",
      "inboundProtocol": "responses",
      "status": 401,
      "errorCode": "invalid_api_key",
      "upstreamError": "OpenCodex admission credentials cannot be forwarded upstream"
    }

    This matches the unresolved P1 review finding in #1861: on the default loopback bind, resolveResponsesApiAuth() returns { kind: "loopback", source: "loopback" } before inspecting the presented bearer. Consequently, substituteMainCredential remains false and Direct reaches validateForwardAdmissionCredential().

    The distributed 2.24.0 source still contains this ordering in src/server/auth-cors.ts, while src/server/responses/core.ts substitutes only when admission?.source === "bearer".

    Could this issue be reopened, or should I create a focused follow-up for the loopback case? A regression test should cover default 127.0.0.1 + Direct + an Authorization admission bearer, not only a bind where API authentication is required.

    Related review finding: #1861 (comment)

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

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't workingcliCLI, config inject, packaging flagsproxyHTTP proxy, routing, reverse-proxy / management authstreamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions