Codex CLI 401 未授权错误:9 种实测成因与相似陷阱
Codex CLI 0.146.0 认证会以 9 种方式失败,且并非都是真正的 401。看报文正文,别只看状态码:没发密钥、密钥被拒、多了换行。
太长不看: Codex CLI 认证会以 9 种不同方式出问题,而其中只有一部分表现为真正的 401,这正是状态码成为整段输出里最没用的部分的原因。真正能定位成因的是报文正文。Missing bearer or basic authentication in header 表示什么都没发出去,而意外之处在于:在默认 provider 上导出 OPENAI_API_KEY 根本没用。Incorrect API key provided 表示你的密钥送达了但被拒绝。You didn't provide an API key 表示请求头在传输途中被丢弃,几乎总是因为密钥值末尾带了换行符。本地报的 Missing environment variable 错误压根不是 401,一个丢了 /v1 的 base_url 返回的 404 同样不是。下面每种情况都在 Codex CLI 0.146.0 上于 2026-07-30 复现过。
30 秒快速诊断
三项检查,按此顺序。多数人跳过第一项,然后花 20 分钟去重签一个本来没问题的密钥。
| 检查 | 命令 | 它告诉你什么 |
|---|---|---|
| 1. 到底发出请求了吗? | 在输出里找 Missing environment variable | 如果有,说明 Codex 根本没发起网络调用。你的配置指向了一个未设置或为空的变量。 |
| 2. 401 正文说了什么? | 阅读 401 Unauthorized: 后面的文字 | 三种不同的报文,三种不同的成因。见下表。 |
| 3. 密钥到底能不能用? | 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 表示密钥没问题,问题出在你的 Codex 配置上。401 表示密钥本身就是问题所在。 |
第 3 步里有个坑值得早点点明,因为网上一半的排错建议都搞错了:在聚合网关上,别拿 /v1/models 当密钥检查。 2026-07-30 实测,https://api.ofox.io/v1/models 就算带一个伪造密钥也会返回 200 并列出完整目录,甚至完全不带 Authorization 头也是如此。模型目录本来就是公开的。OpenAI 自家的 api.openai.com/v1/models 在不带密钥时确实会返回 401,这个习惯就是从那儿来的,但这个习惯迁移不过来。请用一个真正会跑推理的端点。
什么时候该修,什么时候该换,什么时候该停手
只要你知道自己遇到的是哪一种,认证失败修起来很便宜;靠猜就很贵。粗略规则:
- 修它,当报文点名了具体成因时:
Missing environment variable、Incorrect API key provided,或任何提到刷新 token 的信息。这些都有确定的一步到位修复方案,每个耗时不到两分钟。 - 换认证路径,当你在 ChatGPT 登录流程上已经折腾了三次。API 密钥路径可动的部件更少(没有刷新 token,没有浏览器往返,没有 8 天刷新窗口),而且如果你要做任何自动化,它是唯一能挺过容器重启的路径。
- 停下来检查别的东西,当你收到的是 404 而不是 401,或者 CLI 因为配置错误干脆拒绝启动。这些都不是认证问题,再怎么轮换密钥也动不了它们。如果错误提到的是用量额度而非授权,同理;那条路径在 Codex 周额度耗尽 一文里有讲。
唯一一种重签密钥是正确首选动作的情况是:Incorrect API key provided 且错误里显示的密钥掩码后缀与你以为在用的密钥一致。如果后缀对不上,那你遇到的是配置问题而非密钥问题,换个新密钥照样会以同样方式失败。
读懂 401:三种报文,三种成因
这是核心表格。每一行都对着一个真实端点复现过。
| 报文正文 | 实际发生了什么 | 来自哪里 | 修复 |
|---|---|---|---|
Missing bearer or basic authentication in header | 请求上没附带任何凭据 | 未登录的默认 OpenAI provider,或仅导出了 OPENAI_API_KEY | printenv OPENAI_API_KEY | codex login --with-api-key |
Incorrect API key provided: sk-proj-****7890 | 请求头送达,服务器拒绝了里面的值 | auth.json 里的密钥错误、被吊销,或跨账户 | 重签密钥,或登出后用正确的密钥登录 |
Invalid or expired API key | 同上,网关侧的措辞 | 自定义 provider 的 env_key 值错误或被引号包裹 | 检查该变量精确的字节内容,而不只是它被设置了 |
You didn't provide an API key. You need to provide your API key in an Authorization header using Bearer auth | 请求头格式错误被丢弃 | 密钥值里嵌了换行符 | 去掉变量末尾的换行符 |
Your access token could not be refreshed... | ChatGPT OAuth 刷新失败 | 刷新 token 过期、被复用或被吊销 | codex logout 后重新登录 |
Missing environment variable: 'X' | 不是 401。 根本没发请求 | env_key 指向的变量未设置或为空 | 在启动 Codex 的那个 shell 里导出该变量 |
测试运行中有两个细节能让输出更好读。在默认 OpenAI provider 上,Codex 会先尝试对 wss://api.openai.com/v1/responses 走 WebSocket 传输,重试五次,然后回退到 HTTPS 再重试五次,所以一次认证失败会在真正的报文出现前产生大约十行错误。在自定义 provider 上,WebSocket 传输是关闭的(codex doctor 报告 supports websockets: false),所以你只会看到一轮重试和一次更干净的失败。如果你正盯着一整墙的 Reconnecting... 4/5,滚到最底部;最后一行才是关键。
成因 1:你导出了 OPENAI_API_KEY,以为这就够了
这是最常见的一种,而且反直觉到值得放在头条。
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
注意服务器说的是什么:Missing bearer。不是「你的密钥错了」。什么都没发出去。Codex 0.146.0 里的默认 provider 从 $CODEX_HOME/auth.json 读取凭据,而不是从环境读取。设置那个变量什么也改变不了。
单变量验证法:拿完全相同的一个无效密钥,把它写进 auth.json 而不是环境里,报文就变了。
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
同一个密钥,不同的报文。第一次运行根本没把它发出去;第二次发了,然后被拒。这个差异就是整个诊断的核心。
把密钥写到 Codex 真正会去找的地方:
printenv OPENAI_API_KEY | codex login --with-api-key
环境变量确实能用,但只能通过自定义 provider 的 env_key 字段,那是另一种机制,后面会讲。
成因 2:你用了旧的 —api-key flag
如果你照着一份 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`.
报文很清楚,但它退出时什么都没写入,而在安装脚本里这行输出会被滚过去。接下来的命令便以 Missing bearer 失败,密钥就此背锅。检查 auth.json 是否存在、内容是否符合预期:
cat ~/.codex/auth.json
# {
# "auth_mode": "apikey",
# "OPENAI_API_KEY": "sk-proj-..."
# }
成因 3:env_key 指向的变量没设置(不是 401)
在自定义 provider 配置块下,Codex 从你指定的环境变量读取密钥:
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"
如果 OFOX_API_KEY 没设置,你收到的不是 401。你会收到一个本地错误,且完全没有网络调用:
ERROR: Missing environment variable: `OFOX_API_KEY`.
空字符串也会产生一模一样的错误,这与源码吻合:model-provider-info/src/lib.rs 在使用前用 !v.trim().is_empty() 过滤了该变量。所以对 Codex 而言,export OFOX_API_KEY="" 和从不导出它是一回事。
这个坑在 launchd、systemd 和 Docker 里咬得最狠,因为启动 Codex 的那个 shell 并不是你导出变量的那个 shell。
成因 4:密钥值里有换行符
这是这一组里最阴险的,因为报错指控你没提供密钥,而你自己在 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.io]
请求头是带着一个内嵌换行符拼出来的,被直接丢掉了。服务器确实从没看到任何凭据,所以它的报文是准确的,只是听起来像你忘了设置。
值得知道这里不是成因的东西,因为它是最明显的嫌疑犯,却是清白的:末尾多个空格没问题。 用 export OFOX_API_KEY="$REAL " 实测请求成功。注意 Codex 并没有帮你清理它:api_key() 里的 trim() 只是个空值检查,它返回的值是原样的,连末尾空格一起。下游某个环节容忍了它。而换行符则截然不同,它会直接破坏请求头。无论如何,别浪费时间去揪多余的空格。
而带引号的值确实会失败,措辞不同:
export OFOX_API_KEY='"sk-..."' # literal quote characters in the value
# ERROR: unexpected status 401 Unauthorized: Invalid or expired API key
当一个 .env 文件被用天真的 export $(cat .env | xargs) 加载、把引号也保留下来时,这种情况就很常见。
在导出时就把碍事的字节剥掉,并确认长度:
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
成因 5:provider 配置块整个漏了 env_key
九种里最安静的一种失败。把上面配置里的一行删掉:
[model_providers.ofox]
name = "Ofox"
base_url = "https://api.ofox.io/v1"
wire_api = "responses"
# env_key line deleted
Codex 不会抱怨。它会回退到 auth.json 里的凭据(对多数人来说是一个 OpenAI 密钥),然后把那个发给网关。网关拒绝它:
ERROR: unexpected status 401 Unauthorized: Invalid or expired API key,
url: https://api.ofox.io/v1/responses
你的环境变量设置得没错。你的密钥有效。报错却说密钥无效,因为发出去的是另一个密钥。在同一个 shell 里两种情况都测过:有 env_key 时请求返回正常补全,删掉那行就 401。
反过来也值得知道,而且是好消息:当 env_key 存在时,它优先于 auth.json。用一个故意伪造、存在 auth.json 里的密钥,配上一个存在环境变量里的有效密钥实测,请求成功。你在配置自定义 provider 之前不需要先登出。
如果你要从零搭建一个网关 provider,完整且验证过的配置块在 Codex CLI 自定义模型 provider 里,文件中每个键都在 config.toml 参考 里有说明。
成因 6:ChatGPT 登录路径过期了
如果你是用 ChatGPT 订阅而非 API 密钥登录的,失败的样子完全不同:
ERROR: Your access token could not be refreshed. Please log out and sign in again.
注意周围日志行里的端点:wss://chatgpt.com/backend-api/codex/responses,而不是 api.openai.com。两种登录模式对接的是不同的后端,这是判断你实际处于哪种模式的一个快速方法。
Codex 0.146.0 在这里会打印五种变体之一,它们并不通用。来自 tag rust-v0.146.0 的 login/src/auth/manager.rs:
| 变体 | 实际含义 |
|---|---|
...because your refresh token has expired | 真正过期了。重新登录。 |
...because your refresh token was already used | 两个客户端在同一份 auth.json 上抢跑。配置被复制过,或用了共享容器镜像。 |
...because your refresh token was revoked | 会话在服务器侧被终止,常见于改密码或在别处登出。 |
...could not be refreshed.(无原因) | 刷新端点返回了某种未归类的东西。重新登录。 |
...because you have since logged out or signed in to another account | 存储的账户与当前活跃账户不再匹配。 |
「already used」 这个变体是最让人意外的。刷新 token 是一次性的,所以把 auth.json 打进 Docker 镜像,或在两台笔记本之间同步 ~/.codex,得到的认证配置会在先完成刷新的那台机器上能用、在另一台上崩掉。同一个源文件把 TOKEN_REFRESH_INTERVAL 设为 8 天,所以一台闲置超过一周的机器会在下次运行时尝试主动刷新,冲突通常就在这时暴露。
只有一个修复方案,而且很粗暴:
codex logout
codex login # browser flow
# or, for anything automated:
printenv OPENAI_API_KEY | codex login --with-api-key
对于无人值守的环境,请首选 API 密钥路径。它没有刷新语义可失步。
成因 7:codex login status 告诉你一切正常
它会撒谎,而且有两种不同的撒法,两种都复现过。
用一份手工构造、含过期 ChatGPT token 的 auth.json,每个请求都以上面的刷新错误失败,而与此同时:
codex login status
# Logged in using ChatGPT
在自定义 provider 下,status 汇报的是躺在 auth.json 里的那个密钥,而那根本不是请求实际使用的密钥:
codex login status
# Logged in using an API key - sk-proj-***n-999
两种输出描述的都是某个文件的内容。都没有做网络检查。用它们来回答「是否存有一个凭据」,绝不要用来回答「我的认证能不能用」。后者请运行 30 秒诊断里的那条 curl,或者干脆运行 codex exec "hi" 然后看最后一行。
codex doctor 在这里更有用。它的 Configuration 部分会显示加载了哪个 config.toml、是否解析成功、auth 存储模式,以及它能看到哪些认证环境变量;它的 Connectivity 部分会报告活跃的 provider、wire API、是否适用 WebSocket 传输,以及端点是否可达。它仍然不校验凭据,但它会在一秒内告诉你 Codex 是不是在读一个与你一直在编辑的那份不同的配置文件,当设置了 CODEX_HOME 时,这是出人意料地频繁的根本成因。
成因 8:auth.json 损坏了(不是 401)
更少见,但它产生的错误看起来完全不像认证问题,而这恰恰是它费时间的原因。一份被截断或手工编辑过的 auth.json:
codex exec "hi"
# EOF while parsing a value at line 2 column 0
没提认证,没有 HTTP 状态码,没有文件路径。这发生在一次被中断的 codex login、一个部分同步的文件,或一次手工编辑漏了个右花括号之后。文件够小,可以直接查看:
python3 -m json.tool ~/.codex/auth.json > /dev/null && echo "valid JSON"
如果它解析不了,删掉重新登录。里面没有任何值得抢救的东西;要么是一个你可以重新粘贴的 API 密钥,要么是会被重新签发的 OAuth token。
成因 9:Codex 读的配置和你编辑的那份不是一回事
CODEX_HOME 会同时重定位 config.toml 和 auth.json。如果它在你的 shell profile、某个包装脚本里,或被某个替你启动 Codex 的工具设置了,那你对 ~/.codex/config.toml 做的每一处编辑,进的都是一个 Codex 从不打开的文件。症状是一个撑过了本该修好它的各种改动的 401。
codex doctor 在它的 state 部分一行内回答这个问题:
CODEX_HOME /private/tmp/codex401/home7 (dir)
以及在 Configuration 下:
config.toml /private/tmp/codex401/home7/config.toml
config.toml parse ok
如果那个路径不是你一直在编辑的文件,别再调密钥了。
同一个命令还会标记出这个问题的第二个版本。在一台同时全局和本地安装了 Codex 的机器上,doctor 会打印:
✗ install npm install -g @openai/codex would update a different install
✗ updates update would target a different npm install
按字面理解,这意味着更新命令针对的包根目录和你正在运行的二进制不同,所以一次意在拉取认证修复的升级可能根本没动到正在运行的那份。在你断定「升个版本没用」之前,值得先解决它。
退出码不会告诉你的一件事
本文里每一种失败模式退出的都是 1。没凭据、缺环境变量、配置解析错误、auth.json 损坏、密钥被拒:全都是 1。如果你把 Codex 包在脚本里并按退出状态分支处理,你没法区分「你的密钥错了」和「你的配置文件有个拼写错误」。请捕获 stderr 并匹配报文文本:
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
}
两种看起来像 401 却不是的情况
base_url 丢了它的 /v1 给你的是 404,不是 401:
ERROR: unexpected status 404 Not Found: 404 page not found,
url: https://api.ofox.io/responses
如果你看到 404 page not found,且 URL 少了一个你预期存在的路径段,修 URL,别再盯着凭据看了。
wire_api = "chat" 在任何请求之前就失败了,发生在配置加载阶段:
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
chat 这个值被移除了;这个枚举只剩下一个 Responses 变体。任何你让 Codex 对接的网关都必须暴露一个兼容 Responses 的端点。这会绊倒从旧配置迁移过来的人,而且因为它在启动阶段就杀掉进程,有时会被归为「升级后认证坏了」。它没坏。关于哪些模型能扛住这个约束,更多内容见 OpenCode vs Codex CLI。
还有第三种擦边情况:如果你在企业代理后面,失败模式通常是 TLS 或连接错误而非 401,修复方案也完全不同。那条路径在 企业代理后的 Codex CLI 里有讲。
按认证模式分类的修复方案
同一个症状,根据你的认证方式不同需要不同的修复。这是那张值得收藏的表。
| 认证模式 | 凭据存在哪 | 最可能的 401 成因 | 修复 |
|---|---|---|---|
| ChatGPT 订阅 | auth.json OAuth token | 刷新 token 过期,或跨机器被复用 | codex logout 后 codex login |
| OpenAI API 密钥 | auth.json 的 OPENAI_API_KEY 字段 | 密钥从没写进去(旧 --api-key flag,或只导出到了环境里) | printenv OPENAI_API_KEY | codex login --with-api-key |
| 自定义网关 provider | 由 env_key 指定的环境变量 | 变量在启动 shell 里未设置、值里带换行符,或配置里漏了 env_key 行 | 在正确的 shell 里导出、剥掉换行符、确认那行存在 |
| 自动化或容器 | 环境变量,运行时注入 | 打进镜像的 auth.json 带着一次性刷新 token | 用 API 密钥路径,把密钥作为环境变量注入,绝不把 auth.json 打进镜像 |
具体到容器和 CI 的场景:不要从 ~/.codex 挂载任何东西,在容器环境里设置变量,并用一个带 env_key 的自定义 provider 配置块。这个组合没有刷新状态,也没有会失效的文件。
常见失败模式,以及各自实际打印出什么
上面所有内容,浓缩成一张表。在 Codex CLI 0.146.0 上于 2026-07-30 复现,macOS,默认 provider 的行对着 api.openai.com,自定义 provider 的行对着 api.ofox.io。
| # | 配置 | 结果 | 输出 |
|---|---|---|---|
| 1 | 到处都没凭据 | 401 | Missing bearer or basic authentication in header |
| 2 | 导出了 OPENAI_API_KEY,默认 provider | 401 | Missing bearer or basic authentication in header(与 #1 完全相同) |
| 3 | 通过 --with-api-key 写入了无效密钥 | 401 | Incorrect API key provided: sk-proj-****7890、auth error code: invalid_api_key |
| 4 | codex login --api-key(旧 flag) | 退出 | The --api-key flag is no longer supported |
| 5 | 自定义 provider,env_key 变量未设置 | 本地错误 | Missing environment variable: 'OFOX_API_KEY' |
| 6 | 自定义 provider,env_key 变量为空字符串 | 本地错误 | 与 #5 相同 |
| 7 | 自定义 provider,无效密钥 | 401 | Invalid or expired API key |
| 8 | 自定义 provider,密钥被字面引号包裹 | 401 | Invalid or expired API key |
| 9 | 自定义 provider,密钥末尾带空格 | 200 | 请求成功,末尾空格被下游容忍 |
| 10 | 自定义 provider,密钥末尾带换行符 | 401 | You didn't provide an API key... |
| 11 | 自定义 provider,配置里删掉了 env_key 行 | 401 | Invalid or expired API key(发出去的是 auth.json 里的密钥) |
| 12 | auth.json 里是伪造密钥,env_key 里是有效密钥 | 200 | env_key 优先 |
| 13 | ChatGPT auth.json,过期的刷新 token | 刷新错误 | Your access token could not be refreshed... |
| 14 | base_url 丢了 /v1 | 404 | 404 page not found |
| 15 | wire_api = "chat" | 配置错误 | wire_api = "chat" is no longer supported |
第 9 行和第 12 行是那两个靠排除法省时间的。如果你一直在揪空白字符,或以为配置网关之前必须先登出,这两条都是死路。
当你修不了凭据时:现在就能用的替代方案
如果障碍是认证路径本身而非一个拼写错误,你有几个选择。
| 选项 | 它解决什么 | 代价 |
|---|---|---|
| 用 API 密钥代替 ChatGPT 登录 | 去掉刷新 token、浏览器流程和 8 天刷新窗口 | 按 token 计费,而非订阅覆盖 |
带 env_key 的网关 provider | 一个密钥、一个变量,在容器和 CI 里无需登录步骤即可工作 | 需要一个兼容 Responses 的端点,所以各模型的支持情况不一 |
| 用第二个 provider 配置块做手动兜底 | 当一个凭据失效时,让你能用 --config model_provider=... 切换 | 手动,非自动 |
| 换一个 agent | OpenCode 直接从环境变量读取 provider 凭据,无需登录步骤 | 不同工具、不同默认值,见正面对比 |
对自动化配置来说,网关路径是去掉可动部件最多的那个,因为没有会过期的登录步骤。Ofox 就能当这样一个网关:它暴露了 /v1/responses,因此 wire_api = "responses" 的要求得到满足,而且一个 OFOX_API_KEY 就能触达来自多个供应商的模型,这意味着某个上游出了凭据问题,你也不至于无路可走。支持情况是按模型而非按网关来的,所以在承诺投入前先查一下你想用的那个模型。配置块就是成因 3 里展示的那个,带模型 ID 的更完整版本在 Codex CLI API 配置指南 里。
如果你是从 Claude Code 过来、想对比两者的认证使用体验,从 Claude Code 迁移到 Codex 讲了哪些能沿用、哪些必须重新配置。
怎样避免旧病复发
几个能防住大多数重复事故的习惯:
用真实请求验证,别用目录调用。 把这个放进你的安装脚本,而不是 /v1/models 探测:
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"
在导出的那一刻就防住换行符,别等事后:
export OFOX_API_KEY="$(tr -d '\n\r' < ~/keys/ofox.txt)"
绝不在镜像里携带 auth.json,也不要在机器之间同步它。 一次性刷新 token 在设计上就让那成了一场竞态。改用环境注入一个密钥。
任何配置改动后都运行 codex doctor。 它能在约一秒内抓出 CODEX_HOME 用错和配置没解析成功这两种情况,而这正是最容易让你去怀疑一个本来完好的密钥的两种失败。
来源
- https://github.com/openai/codex
- https://github.com/openai/codex/blob/rust-v0.146.0/codex-rs/model-provider-info/src/lib.rs
- https://github.com/openai/codex/blob/rust-v0.146.0/codex-rs/login/src/auth/manager.rs
- https://github.com/openai/codex/discussions/7782
- https://learn.chatgpt.com/docs/config-file/config-reference


