Codex CLI 401 Unauthorized: 9 Tested Causes and Lookalikes

Codex CLI 0.146.0 auth fails nine ways, and not all of them are a literal 401. Read the message body, not the code: no key sent, key rejected, newline.

Codex CLI 401 Unauthorized: 9 Tested Causes and Lookalikes

TL;DR: Codex CLI auth breaks in nine distinct ways, and only some of them arrive as a literal 401, which is exactly why the status code is the least useful part of the output. The message body is what identifies the cause. Missing bearer or basic authentication in header means nothing was sent, and the surprise there is that exporting OPENAI_API_KEY does not help on the default provider. Incorrect API key provided means your key arrived and was rejected. You didn't provide an API key means the header was dropped in transit, almost always because the value ends with a newline. A local Missing environment variable error is not a 401 at all, and neither is a 404 from a base_url that lost its /v1. Every case below was reproduced on Codex CLI 0.146.0 on 2026-07-30.

The 30-Second Diagnosis

Three checks, in this order. Most people skip the first one and spend twenty minutes re-issuing a key that was fine.

CheckCommandWhat it tells you
1. Did a request even happen?Look for Missing environment variable in the outputIf present, Codex never made a network call. Your config points at a variable that is unset or empty.
2. What does the 401 body say?Read the text after 401 Unauthorized:Three distinct messages, three distinct causes. See the table below.
3. Does the key work at all?curl -s -o /dev/null -w "%{http_code}" -X POST https://api.ofox.io/v1/responses -H "Authorization: Bearer $YOUR_KEY" -H "Content-Type: application/json" -d '{"model":"openai/gpt-5.5","input":"hi","max_output_tokens":16}'200 means the key is good and the problem is in your Codex config. 401 means the key itself is the problem.

One trap in step 3 worth calling out early, because half the troubleshooting advice on the internet gets it wrong: do not use /v1/models as a key check on an aggregator. Tested on 2026-07-30, https://api.ofox.io/v1/models returns 200 with the full catalog when sent with a bogus key, and also with no Authorization header at all. The model catalog is public. OpenAI’s own api.openai.com/v1/models does return 401 without a key, which is where the habit comes from, but the habit does not transfer. Use an endpoint that actually runs inference.

When to Fix This, When to Switch, and When to Stop

Auth failures are cheap to fix when you know which one you have, and expensive when you guess. Rough rule:

  • Fix it when the message names a specific cause: Missing environment variable, Incorrect API key provided, or anything mentioning a refresh token. These have deterministic one-step fixes and take under two minutes each.
  • Switch the auth path when you are three attempts deep on the ChatGPT login flow. The API key path has fewer moving parts (no refresh tokens, no browser round trip, no 8-day refresh window), and if you are automating anything, it is the only path that survives a container restart.
  • Stop and check something else if you get 404 instead of 401, or if the CLI refuses to start with a config error. Those are not auth problems and no amount of key rotation will move them. Same if the error mentions a usage limit rather than authorization; that path is covered in Codex weekly limit drained.

The one case where re-issuing a key is the right first move is Incorrect API key provided with the masked suffix shown in the error matching the key you think you are using. If the suffix does not match, you have a config problem, not a key problem, and a new key will fail identically.

Reading the 401: Three Messages, Three Causes

This is the core table. Every row was reproduced against a live endpoint.

Message bodyWhat actually happenedWhere it comes fromFix
Missing bearer or basic authentication in headerNo credential was attached to the requestDefault OpenAI provider with no login, or with only OPENAI_API_KEY exportedprintenv OPENAI_API_KEY | codex login --with-api-key
Incorrect API key provided: sk-proj-****7890Header arrived, server rejected the valueWrong, revoked, or cross-account key in auth.jsonRe-issue the key, or log out and log in with the right one
Invalid or expired API keySame as above, gateway-side wordingCustom provider whose env_key value is wrong or quote-wrappedCheck the variable’s exact bytes, not just that it is set
You didn't provide an API key. You need to provide your API key in an Authorization header using Bearer authHeader was malformed and droppedNewline embedded in the key valueStrip the trailing newline from the variable
Your access token could not be refreshed...ChatGPT OAuth refresh failedExpired, reused, or revoked refresh tokencodex logout then sign in again
Missing environment variable: 'X'Not a 401. No request was madeenv_key names a variable that is unset or emptyExport the variable in the shell that launches Codex

Codex CLI 401 diagnosis flow: check for a local Missing environment variable error first, then read the message body, which splits into no key sent, key rejected, and header dropped by a newline

Two details from the test runs that make the output easier to read. On the default OpenAI provider, Codex first tries a WebSocket transport against wss://api.openai.com/v1/responses, retries five times, then falls back to HTTPS and retries five more, so a single auth failure produces roughly ten error lines before the real message. On a custom provider, WebSocket transport is off (codex doctor reports supports websockets: false), so you get one retry loop and a cleaner failure. If you are staring at a wall of Reconnecting... 4/5, scroll to the bottom; the last line is the one that matters.

Cause 1: You Exported OPENAI_API_KEY and Assumed That Was Enough

This is the single most common one, and it is counterintuitive enough that it deserves the top slot.

export OPENAI_API_KEY="sk-proj-..."
codex exec "say hi"
# ERROR: unexpected status 401 Unauthorized: Missing bearer or basic
# authentication in header, url: https://api.openai.com/v1/responses

Note what the server said: Missing bearer. Not “your key is wrong”. Nothing was sent. The default provider in Codex 0.146.0 reads credentials from $CODEX_HOME/auth.json, not from the environment. Setting the variable changes nothing.

The single-variable proof: take the exact same invalid key, write it into auth.json instead of the environment, and the message changes.

echo "sk-proj-invalidkeyfortesting1234567890" | codex login --with-api-key
codex exec "say hi"
# ERROR: unexpected status 401 Unauthorized: Incorrect API key provided:
# sk-proj-**************************7890 ... auth error code: invalid_api_key

Same key, different message. The first run never sent it; the second did and got it rejected. That difference is the whole diagnostic.

Write the key where Codex will actually look for it:

printenv OPENAI_API_KEY | codex login --with-api-key

Environment variables do work, but only through a custom provider’s env_key field, which is a different mechanism covered further down.

Cause 2: You Used the Old —api-key Flag

If you followed a tutorial written before mid-2026:

codex login --api-key "sk-proj-..."
# The --api-key flag is no longer supported. Pipe the key instead,
# e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`.

The message is clear, but it exits without writing anything, and in a setup script the output scrolls past. The next command then fails with Missing bearer and the key gets blamed. Check that auth.json exists and contains what you expect:

cat ~/.codex/auth.json
# {
#   "auth_mode": "apikey",
#   "OPENAI_API_KEY": "sk-proj-..."
# }

Cause 3: env_key Points at a Variable That Is Not Set (Not a 401)

With a custom provider block, Codex reads the key from the environment variable you name:

model = "openai/gpt-5.5"
model_provider = "ofox"

[model_providers.ofox]
name = "Ofox"
base_url = "https://api.ofox.io/v1"
env_key = "OFOX_API_KEY"
wire_api = "responses"

If OFOX_API_KEY is unset, you do not get a 401. You get a local error and no network call at all:

ERROR: Missing environment variable: `OFOX_API_KEY`.

An empty string produces the identical error, which matches the source: model-provider-info/src/lib.rs filters the variable with !v.trim().is_empty() before using it. So export OFOX_API_KEY="" and never exporting it are the same thing as far as Codex is concerned.

This one bites hardest in launchd, systemd, and Docker, where the shell that starts Codex is not the shell where you exported the variable.

Cause 4: A Newline in the Key Value

This is the nastiest of the set, because the error accuses you of not providing a key that you can see with your own eyes in printenv.

export OFOX_API_KEY="$(cat ~/keys/ofox.txt)"   # file ends with a newline
codex exec "hi"
# ERROR: unexpected status 401 Unauthorized: You didn't provide an API key.
# You need to provide your API key in an Authorization header using Bearer
# auth (i.e. Authorization: Bearer YOUR_KEY). [ofox.ai]

The header was built with a newline inside it and dropped on the floor. The server genuinely never saw a credential, so its message is accurate; it just sounds like you forgot to set anything.

Worth knowing what is not a cause here, because it is the obvious suspect and it is innocent: a trailing space is fine. Tested with export OFOX_API_KEY="$REAL " and the request succeeded. Note that Codex is not cleaning it up for you: the trim() in api_key() is an emptiness check only, and the value it returns is the raw one, trailing space included. Something downstream tolerates it. A newline, by contrast, breaks the header outright. Either way, do not spend time hunting for stray spaces.

A quoted value, on the other hand, does fail, with different wording:

export OFOX_API_KEY='"sk-..."'   # literal quote characters in the value
# ERROR: unexpected status 401 Unauthorized: Invalid or expired API key

That one is common when a .env file is loaded with a naive export $(cat .env | xargs) that keeps the quotes.

Strip the offending bytes at export time and confirm the length:

export OFOX_API_KEY="$(tr -d '\n\r"' < ~/keys/ofox.txt)"
printf '%s' "$OFOX_API_KEY" | wc -c   # confirm the byte count matches the key length

Cause 5: The Provider Block Is Missing env_key Entirely

The quietest failure of the nine. Delete one line from the config above:

[model_providers.ofox]
name = "Ofox"
base_url = "https://api.ofox.io/v1"
wire_api = "responses"
# env_key line deleted

Codex does not complain. It falls back to the credential in auth.json, which for most people is an OpenAI key, and sends that to the gateway. The gateway rejects it:

ERROR: unexpected status 401 Unauthorized: Invalid or expired API key,
url: https://api.ofox.io/v1/responses

Your environment variable is set correctly. Your key is valid. The error says the key is invalid, because a different key was sent. Tested both ways on the same shell: with env_key present the request returns a normal completion, with the line deleted it 401s.

The inverse is also worth knowing, and it is good news: when env_key is present, it wins over auth.json. Tested with a deliberately bogus key stored in auth.json and a valid one in the environment variable, the request succeeded. You do not need to log out before configuring a custom provider.

If you are setting up a gateway provider from scratch, the full vetted block lives in Codex CLI custom model providers, and every key in the file is documented in the config.toml reference.

Cause 6: The ChatGPT Login Path Expired

If you signed in with a ChatGPT subscription rather than an API key, the failure looks completely different:

ERROR: Your access token could not be refreshed. Please log out and sign in again.

Note the endpoint in the surrounding log lines: wss://chatgpt.com/backend-api/codex/responses, not api.openai.com. The two login modes talk to different backends, which is a fast way to tell which one you are actually on.

Codex 0.146.0 prints one of five variants here, and they are not interchangeable. From login/src/auth/manager.rs at tag rust-v0.146.0:

VariantWhat it means in practice
...because your refresh token has expiredGenuine expiry. Sign in again.
...because your refresh token was already usedTwo clients raced on the same auth.json. Copied config or a shared container image.
...because your refresh token was revokedSession killed server-side, often by a password change or a sign-out elsewhere.
...could not be refreshed. (no reason)Refresh endpoint returned something unclassified. Sign in again.
...because you have since logged out or signed in to another accountThe stored account no longer matches the active one.

The “already used” variant is the one that surprises people. Refresh tokens are single-use, so baking an auth.json into a Docker image or syncing ~/.codex across two laptops gives you an auth setup that works on whichever machine refreshes first and breaks on the other. The same source file sets TOKEN_REFRESH_INTERVAL to 8 days, so a machine idle for more than a week will attempt a proactive refresh on its next run, which is usually when the conflict surfaces.

There is one fix and it is the blunt one:

codex logout
codex login          # browser flow
# or, for anything automated:
printenv OPENAI_API_KEY | codex login --with-api-key

For unattended environments, prefer the API key path. It has no refresh semantics to get out of sync.

Cause 7: codex login status Told You Everything Was Fine

It lies, in two different ways, and both were reproduced.

With a hand-built auth.json containing an expired ChatGPT token, every request failed with the refresh error above, while:

codex login status
# Logged in using ChatGPT

And under a custom provider, status reports the key sitting in auth.json, which is not the key being used for requests at all:

codex login status
# Logged in using an API key - sk-proj-***n-999

Both outputs describe the contents of a file. Neither performs a network check. Use them to answer “is a credential stored”, never “does my auth work”. For the latter, run the curl from the 30-second diagnosis, or just run codex exec "hi" and read the last line.

codex doctor is more useful here. Its Configuration section shows which config.toml was loaded and whether it parsed, the auth storage mode, and which auth environment variables it can see; its Connectivity section reports the active provider, wire API, whether WebSocket transport applies, and whether the endpoint is reachable. It still does not validate the credential, but it will tell you within a second if Codex is reading a different config file than the one you have been editing, which is a surprisingly frequent root cause when CODEX_HOME is set.

Cause 8: auth.json Is Corrupt (Not a 401)

Rarer, but it produces an error that looks nothing like an auth problem, which is exactly why it costs time. A truncated or hand-edited auth.json:

codex exec "hi"
# EOF while parsing a value at line 2 column 0

No mention of authentication, no HTTP status, no file path. This happens after an interrupted codex login, a partially synced file, or a hand edit that dropped a closing brace. The file is small enough to inspect directly:

python3 -m json.tool ~/.codex/auth.json > /dev/null && echo "valid JSON"

If it does not parse, delete it and log in again. There is nothing in it worth recovering; it is either an API key you can re-paste or OAuth tokens that will be reissued.

Cause 9: Codex Is Reading a Different Config Than the One You Edited

CODEX_HOME relocates both config.toml and auth.json. If it is set in your shell profile, in a wrapper script, or by a tool that launched Codex for you, every edit you make to ~/.codex/config.toml is going into a file Codex never opens. The symptom is a 401 that survives changes that should have fixed it.

codex doctor answers this in one line, under its state section:

CODEX_HOME  /private/tmp/codex401/home7 (dir)

and under Configuration:

config.toml  /private/tmp/codex401/home7/config.toml
config.toml parse  ok

If that path is not the file you have been editing, stop debugging the key.

The same command flags a second version of this problem. On a machine where Codex is installed both globally and locally, doctor prints:

✗ install   npm install -g @openai/codex would update a different install
✗ updates   update would target a different npm install

Read literally, that means the update command targets a different package root than the binary you are running, so an upgrade meant to pick up an auth fix can leave the running copy untouched. Worth resolving before you conclude that a version bump did not help.

One thing exit codes will not tell you

Every failure mode in this article exits 1. No credential, missing environment variable, config parse error, corrupt auth.json, rejected key: all 1. If you are wrapping Codex in a script and branching on the exit status, you cannot distinguish “your key is wrong” from “your config file has a typo”. Capture stderr and match on the message text instead:

out=$(codex exec "ping" 2>&1) || {
  case "$out" in
    *"Missing environment variable"*) echo "config points at an unset variable" ;;
    *"Missing bearer"*)               echo "no credential was sent" ;;
    *"Incorrect API key"*|*"Invalid or expired API key"*) echo "credential rejected" ;;
    *"could not be refreshed"*)       echo "ChatGPT session expired, log in again" ;;
    *) echo "other failure: $out" ;;
  esac
}

Two Things That Look Like 401 and Are Not

A base_url missing its /v1 gives you a 404, not a 401:

ERROR: unexpected status 404 Not Found: 404 page not found,
url: https://api.ofox.io/responses

If you see 404 page not found with a URL that is missing a path segment you expected, fix the URL and stop looking at credentials.

wire_api = "chat" fails before any request, at config load:

Error loading config.toml: `wire_api = "chat"` is no longer supported.
How to fix: set `wire_api = "responses"` in your provider config.
More info: https://github.com/openai/codex/discussions/7782

The chat value was removed; the enum has a single Responses variant left. Any gateway you point Codex at has to expose a Responses-compatible endpoint. This trips people migrating from older configs, and because it kills the process at startup it sometimes gets filed as “auth broke after upgrade”. It did not. More on which models survive that constraint in OpenCode vs Codex CLI.

A third near-miss: if you are behind a corporate proxy, the failure mode is usually a TLS or connect error rather than 401, and the fix is a different one entirely. That path is covered in Codex CLI behind a corporate proxy.

Fixes by Auth Mode

The same symptom needs a different fix depending on how you authenticate. This is the table to bookmark.

Auth modeWhere the credential livesMost likely 401 causeFix
ChatGPT subscriptionauth.json OAuth tokensRefresh token expired or reused across machinescodex logout then codex login
OpenAI API keyauth.json OPENAI_API_KEY fieldKey never written (old --api-key flag, or only exported to the env)printenv OPENAI_API_KEY | codex login --with-api-key
Custom gateway providerEnvironment variable named by env_keyVariable unset in the launching shell, newline in the value, or env_key line missing from the configExport in the right shell, strip newlines, confirm the line exists
Automated or containerEnvironment variable, injected at runtimeBaked-in auth.json with single-use refresh tokensUse the API key path, inject the key as an env var, never ship auth.json in an image

For container and CI setups specifically: mount nothing from ~/.codex, set the variable in the container environment, and use a custom provider block with env_key. That combination has no refresh state and no file to go stale.

Common Failure Patterns, With What Each One Actually Prints

Everything above, in one grid. Reproduced on Codex CLI 0.146.0 on 2026-07-30, macOS, against api.openai.com for default-provider rows and api.ofox.ai for custom-provider rows.

#SetupResultOutput
1No credential anywhere401Missing bearer or basic authentication in header
2OPENAI_API_KEY exported, default provider401Missing bearer or basic authentication in header (identical to #1)
3Invalid key written via --with-api-key401Incorrect API key provided: sk-proj-****7890, auth error code: invalid_api_key
4codex login --api-key (old flag)exitsThe --api-key flag is no longer supported
5Custom provider, env_key variable unsetlocal errorMissing environment variable: 'OFOX_API_KEY'
6Custom provider, env_key variable empty stringlocal erroridentical to #5
7Custom provider, invalid key401Invalid or expired API key
8Custom provider, key wrapped in literal quotes401Invalid or expired API key
9Custom provider, key with trailing space200request succeeds, the trailing space is tolerated downstream
10Custom provider, key with trailing newline401You didn't provide an API key...
11Custom provider, env_key line deleted from config401Invalid or expired API key (auth.json key sent instead)
12Bogus key in auth.json, valid key in env_key200env_key takes precedence
13ChatGPT auth.json, stale refresh tokenrefresh errorYour access token could not be refreshed...
14base_url missing /v1404404 page not found
15wire_api = "chat"config errorwire_api = "chat" is no longer supported

Rows 9 and 12 are the two that save time by ruling things out. If you have been hunting for whitespace or assuming you need to log out before configuring a gateway, both of those are dead ends.

When You Cannot Fix the Credential: Alternatives That Work Now

If the auth path itself is the obstacle rather than a typo, you have a few options.

OptionWhat it solvesTrade-off
API key instead of ChatGPT loginRemoves refresh tokens, browser flows, and the 8-day refresh windowMetered per token instead of covered by a subscription
A gateway provider with env_keyOne key, one variable, works in containers and CI without a login stepRequires a Responses-compatible endpoint, so per-model support varies
A second provider block as a manual fallbackLets you switch with --config model_provider=... when one credential diesManual, not automatic
A different agentOpenCode reads provider credentials straight from environment variables with no login stepDifferent tool, different defaults, see the head-to-head comparison

The gateway route is the one that removes the most moving parts for automated setups, because there is no login step to expire. Ofox works as one: it exposes /v1/responses, so the wire_api = "responses" requirement is satisfied, and a single OFOX_API_KEY reaches models from several vendors, which means a credential problem with one upstream does not leave you with nothing to run. Support is per model rather than per gateway, so check the model you want before committing. The setup block is the one shown in Cause 3, and the longer version with model IDs is in the Codex CLI API configuration guide.

If you are coming from Claude Code and comparing auth ergonomics between the two, migrating Claude Code to Codex covers what carries over and what has to be reconfigured.

How to Keep This From Recurring

A few habits that prevent most repeat incidents:

Verify with a real request, not a catalog call. Put this in your setup script rather than a /v1/models ping:

code=$(curl -s -o /dev/null -w "%{http_code}" -X POST https://api.ofox.io/v1/responses \
  -H "Authorization: Bearer $OFOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"openai/gpt-5.5","input":"ping","max_output_tokens":16}')
[ "$code" = "200" ] || echo "auth check failed: HTTP $code"

Guard against the newline at the point of export, not after the fact:

export OFOX_API_KEY="$(tr -d '\n\r' < ~/keys/ofox.txt)"

Never ship auth.json in an image or sync it between machines. Single-use refresh tokens make that a race by design. Inject a key through the environment instead.

Run codex doctor after any config change. It catches the wrong-CODEX_HOME and config-did-not-parse cases in about a second, which are the two failures most likely to make you doubt a perfectly good key.

Sources