$ ls -la ~/.codex
...
-rw------- auth.json
drwxr-xr-x sessions
...
This immediately suggested that auth.json was the primary source of truth and sessions/ was only useful for the optional local activity counts.
A detailed walkthrough of how we figured out where Codex stores ChatGPT auth state, how stale tokens can be recovered, and how the dashboard now reloads or refreshes tokens before giving up.
The original dashboard worked by copying the current access_token out of ~/.codex/auth.json and storing it in Codex/.local/accounts.ini. That is enough for a snapshot, but it creates a weakness:
auth.json, or can we actually reproduce Codex's own refresh flow?
At a high level, the flow we ended up with looks like this:
┌──────────────┐ ┌──────────────────┐ ┌────────────────────┐ ┌─────────────────────────┐ │ Codex CLI │ │ ~/.codex/auth.json │ │ auth0.openai.com │ │ chatgpt.com usage API │ └──────┬───────┘ └─────────┬────────┘ └──────────┬─────────┘ └────────────┬────────────┘ │ │ │ │ │ ── read tokens ──────▶ │ │ │ │ │ │ │ │ access_token │ │ │ │ account_id │ │ │ │ refresh_token │ │ │ │ │ │ │ │ ────────────────────────────────────────────────────────────────────────────▶ │ │ GET /backend-api/wham/usage │ │ Authorization: Bearer {access_token} │ │ ChatGPT-Account-Id: {account_id} │ │ │ │ │ │ ◀──────────────────────────────────────────────────────────────────────────── │ │ │ │ │ │ [if token expired] │ │ │ │ │ │ │ │ ────────────────────────▶ │ │ │ │ load refresh_token │ │ │ │ │ │ │ │ ────────────────────────────────────────────▶ │ │ │ POST /oauth/token │ │ │ grant_type=refresh_token │ │ │ client_id=app_EMoamEEZ73f0CkXaXp7hrann │ │ │ ◀──────────────────────────────────────────── │ │ │ {access_token, refresh_token?, id_token?} │ │ │ │ │ │ │ ── write updated auth ─▶ │ │ │ │ │ │ │ │ ────────────────────────────────────────────────────────────────────────────▶ │ │ retry GET /backend-api/wham/usage │
The first clue was simple: Codex already stores its own state under ~/.codex/.
$ ls -la ~/.codex
...
-rw------- auth.json
drwxr-xr-x sessions
...
This immediately suggested that auth.json was the primary source of truth and sessions/ was only useful for the optional local activity counts.
Instead of printing raw secrets, we inspected the shape of the file:
$ python3 - <<'PY'
import json, pathlib
p = pathlib.Path.home()/'.codex'/'auth.json'
obj = json.loads(p.read_text())
print(sorted(obj.keys()))
print(sorted(obj.get('tokens', {}).keys()))
PY
['OPENAI_API_KEY', 'auth_mode', 'last_refresh', 'tokens']
['access_token', 'account_id', 'id_token', 'refresh_token']
This was the first big breakthrough: Codex is not just caching an access token. It also stores a refresh_token.
~/.codex/auth.json already contains everything needed for recovery: access_token, refresh_token, id_token, and account_id.
If the access token includes offline_access, that is a very strong signal that a refresh flow exists.
$ python3 - <<'PY'
import json, pathlib, base64
p = pathlib.Path.home()/'.codex'/'auth.json'
tok = json.loads(p.read_text())['tokens']['access_token']
payload = tok.split('.')[1] + '=' * (-len(tok.split('.')[1]) % 4)
data = json.loads(base64.urlsafe_b64decode(payload))
print(data['iss'])
print(data['scp'])
PY
https://auth.openai.com
['openid', 'profile', 'email', 'offline_access', 'api.connectors.read', 'api.connectors.invoke']
That tells us two things:
https://auth.openai.comoffline_access, which is the classic OAuth signal for refresh supportThe dashboard originally copied the access token into Codex/.local/accounts.ini. That works, but only until the copied token becomes stale.
# Original bootstrap behavior
$ jq -r '.tokens.access_token' ~/.codex/auth.json
$ jq -r '.tokens.account_id' ~/.codex/auth.json
# ...then write those values into Codex/.local/accounts.ini
This is fine for a snapshot, but it means the dashboard can lag behind whatever Codex itself is doing.
The first thing we opened was the Homebrew launcher:
$ which codex
/opt/homebrew/bin/codex
$ readlink -f $(which codex)
/opt/homebrew/lib/node_modules/@openai/codex/bin/codex.js
$ sed -n '1,40p' /opt/homebrew/lib/node_modules/@openai/codex/bin/codex.js
#!/usr/bin/env node
// Unified entry point for the Codex CLI.
...
const binaryPath = ...
spawn(binaryPath, process.argv.slice(2), ...)
This told us the JS file is just a launcher. The real auth logic lives in the native platform binary it spawns.
An early assumption was that the platform package would be installed as a top-level sibling npm package.
$ find /opt/homebrew/lib/node_modules/@openai/codex-darwin-arm64 -maxdepth 4 -type f
find: ... No such file or directory
That was wrong on this machine. The platform bundle was nested under the main package's own node_modules.
$ find /opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai -maxdepth 4 -type d | sort
...
/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64
/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor
/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex
...
Once we had the native binary path, we searched for auth-related clues:
$ strings /opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex/codex \
| rg "refresh_token|grant_type|code_verifier|oauth token exchange|app_EMoamEEZ73f0CkXaXp7hrann|chatgpt_account_id"
Access token expired, refreshing.
Refreshed access token.
starting oauth token exchange
oauth token exchange succeeded
access_token
refresh_token
grant_type
code_verifier
client_id
app_EMoamEEZ73f0CkXaXp7hrann
chatgpt_account_id
This was enough to move from “maybe there is refresh” to “Codex definitely has a built-in OAuth token exchange path.”
From the token claims we had iss=https://auth.openai.com. That makes the next step standard OAuth/OpenID Connect discovery:
$ curl -s https://auth.openai.com/.well-known/openid-configuration | jq -r '.token_endpoint'
https://auth0.openai.com/oauth/token
That gives us the missing host for the refresh request.
Between the binary strings and the discovery document, the request shape becomes fairly clear:
POST https://auth0.openai.com/oauth/token
Content-Type: application/x-www-form-urlencoded
Accept: application/json
grant_type=refresh_token
refresh_token=<refresh_token from ~/.codex/auth.json>
client_id=app_EMoamEEZ73f0CkXaXp7hrann
That matches everything we observed locally:
| Clue | What it told us |
|---|---|
offline_access in token scope | Refresh support should exist |
iss=https://auth.openai.com | OIDC discovery starts from auth.openai.com |
Binary strings: refresh_token, grant_type, client_id | Standard form-encoded OAuth token exchange |
Binary string: app_EMoamEEZ73f0CkXaXp7hrann | The ChatGPT client id Codex is using |
| Discovery document | Confirmed https://auth0.openai.com/oauth/token |
If the copied token is stale because Codex already refreshed it in the background, we do not need to hit the network at all. We can just reload the latest token from auth.json.
# Recovery order inside codex_nerve.py
1. usage request fails with 401/403
2. reload ~/.codex/auth.json
3. if access_token changed, retry usage request
4. only if that still fails, do a refresh_token exchange
When reload is not enough, the dashboard now performs the refresh exchange, writes the new token state back to auth.json, and retries the usage API.
Codex/codex_nerve.py
load_local_auth_tokens(...)
reload_account_from_auth_file(...)
refresh_access_token_from_auth_file(...)
refresh_account(...)
codex_nerve.py is now responsible for three auth behaviors:
auth_file if the snapshot goes stalerefresh_token if reload is not enoughcodex_refresh_auth.py is the smallest runnable example of the inferred Codex refresh flow. It does two things:
# Show the inferred request shape without calling the network
$ python3 Codex/codex_refresh_auth.py --print-only
# Perform the refresh and write updated tokens back to ~/.codex/auth.json
$ python3 Codex/codex_refresh_auth.py
The helper intentionally updates ~/.codex/auth.json because that is the same local state Codex itself relies on.
~/.codex/auth.json, including a refresh_token. The access token carries offline_access, the binary contains explicit OAuth refresh clues plus the ChatGPT client id, and OpenID discovery leads to https://auth0.openai.com/oauth/token. That was enough to add automatic recovery to the dashboard and produce a standalone refresh helper.
~/.codex/auth.jsonhttps://chatgpt.com/backend-api/wham/usagehttps://auth0.openai.com/oauth/tokenapp_EMoamEEZ73f0CkXaXp7hrann