Short-lived commands (`claude auth status`, `claude --bg …`) start an OAuth refresh at init and exit without saving it, leaving the profile with a spent refresh token

Status Open
Reported on v2.1.277
Maintainer reply None cached
Activity 7 comments · opened Sep 21, 2026

Related: #16957 (exit-time refresh not persisted, closed). This is a different path: the refresh init starts on every command, abandoned by short-lived commands that process.exit() before it lands — still present in 2.1.277.

Version: 2.1.277 (static reading); the observed failure ran 2.1.259.
macOS, claude.ai Max subscription, several profiles via CLAUDE_CONFIG_DIR.

What happens

  1. Every command awaits init in the root preAction hook. init starts the

OAuth populate step without awaiting it:
…Dr("init_after_1p_event_logging"),CJr(…),rlr(c,o).catch(d),Dr("init_after_oauth_populate")…

  1. rlr begins with await Gs({credentials:e,storageV5:n}), and Gs is

checkAndRefreshOAuthTokenIfNeeded. When the stored access token is within
five minutes of expiresAt (iO: n+300000>=e), or already expired, it
takes <config>/.oauth_refresh.lock and POSTs grant_type=refresh_token.

  1. auth status's action renders and calls process.exit(C?0:1) directly.

The only path that waits for an in-flight refresh is the graceful-shutdown
waitForHeldOAuthRefresh (2000ms), which this does not go through. A
claude --bg --resume … launcher is similarly short-lived.

  1. If the POST reached the server before the process exited, the server has

rotated the refresh token, but the new pair was never written. The Keychain
keeps the old, spent refresh token. The next process that refreshes gets
invalid_grant and the profile shows "Login expired · Please run /login".

The exposure is structural, not a narrow race. A profile nobody is using has
an expired access token, so every short-lived command run against it starts
a refresh. A monitoring tool that ran claude auth status per profile every
few minutes did exactly that.

Reproduction (local, no real credential)

Setup:

  • CLAUDE_CONFIG_DIR points at a throwaway directory.
  • Its Keychain item holds a fabricated credential whose expiresAt is in the

past.

  • HTTPS_PROXY and NODE_EXTRA_CA_CERTS send platform.claude.com to a local

mock /v1/oauth/token. Every other host is refused.

  • The mock answers grant_type=refresh_token with a new token pair after a

set delay.

Then run claude auth status --json, and afterwards read which refresh token
the Keychain item holds. 2.1.277, macOS:

| mock latency | runs | refresh POSTed | process exited before the reply | new token saved |
|---|---|---|---|---|
| 0s | 5 | 5 | 0 | 5 |
| 0.5s | 5 | 5 | 5 | 0 |
| 3s | 1 | 1 | 1 | 0 |

Timings:

  • The POST left 2.1–5.8s after spawn.
  • The process exited 0.05–1.0s after the POST, with exit code 0 and

loggedIn: true.

  • The refresh lock was taken and released around it (0.3–1.1s).

With a real server, the refresh token that was posted has been spent, and
the profile's next refresh fails with "Login expired".

Script and raw results are available on request.

Observed

On 2026-09-03 a coordinator ran claude --bg --resume … under one profile
from 01:59:55.5Z to 01:59:56.6Z. That was about 15s after the token entered
the five-minute window, where it sat about 4m45s before expiry. What followed:

  • 02:00:14Z: the Remote Control bridge reported "Claude.ai login was rejected".
  • Two independent sessions got "401 OAuth access token has been revoked":
  • one at 02:00:30.8Z;
  • one at 02:00:58.2Z.

Both came 4+ minutes before that token's expiry. Three more transcripts carry
the same 02:00:30 row, but they are forks that copied it, not separate events.

  • 02:03:30Z: an interactive session got "Login expired".
  • The daemon's proactive refresh failed twice:
  • 02:00:40Z: failed after 7.7s of lock retries;
  • 02:01:48Z: failed in 49ms and signalled re-auth.

Across ~434 launches of that command over several weeks, two landed inside a
profile's refresh window. That was one of them.

Two limits on this evidence. The Keychain keeps only an item's last write
time, so whether the new token was saved can only be inferred from the
daemon's branch logic. And whether the server revokes the superseded access
token when it rotates is not visible from the client.

What would settle it

Server-side refresh events for this grant between 01:59:40Z and 02:00:15Z on
2026-09-03. Specifically:

  • a refresh_token grant from the coordinator's CLI;
  • any second presentation of the same refresh token;
  • whether rotation revokes the superseded access token.

We can supply the account on request.

Why it matters

People who run several CLAUDE_CONFIG_DIR profiles, or tooling that checks
auth status, lose sessions every few days. The fix is always a manual browser
sign-in.

Suggested fix (any one)

  • Do not start the init-time refresh for commands that do not need a token

(auth status only reads presence: Xc()).

  • Or: before process.exit() in short-lived commands, await any in-flight

refresh (the machinery already exists: waitForHeldOAuthRefresh).

  • Or: make the refresh-and-save atomic with respect to process exit, by

registering the in-flight rotation with the shutdown path the way the
graceful path does.

Related, smaller

  • eE returns "refreshed" whatever zkn (the CAS save) returns, so a failed

save after a successful rotation is also silent.

  • proper-lockfile's stale-steal rmdir fails ENOTEMPTY on a legacy

<config>.lock directory that something has written files into. It then
stays "held" for 60s after every write inside it.

View original on GitHub ↗

3 Comments

legion-the-geth · 22 days ago

Same failure on Linux with claude doctor, measured from the outside with inotify. It matches your analysis exactly, and doctor should be in scope for the fix.

Setup. Debian 13 x86_64 in an unprivileged container, native install, file credential store (~/.claude/.credentials.json), claude.ai subscription. A headless service keeps a login alive by running claude doctor whenever the access token is within 5 minutes of expiresAt (checked every 10 min). Over 8 production cycles of 8 h, 4 ended with the store blanked (refreshToken: "", expiresAt: 0) after a 400 on the next refresh.

Server side. In claude.ai → Settings → Claude Code, the authorization's "connected" time matched the run that wrote nothing, not the later run that got the 400. The server had rotated; the new pair never reached disk.

Bench. A dedicated HOME with its own login, used for nothing else. Before each run, expiresAt is set to now + 4 min. Then claude doctor runs with stdout not a TTY. inotify timestamps every create/rename/delete in ~ and ~/.claude to the microsecond, and the process start and end are recorded. 27 runs across 2.1.252, 2.1.276 and 2.1.278:

| Runs | .credentials.json renamed into place | .oauth_refresh.lock removed |
|---|---|---|
| 24 (21 renewed, 3 blanked after a 400) | 18–972 ms before process end | right after the write |
| 3 (rotation lost) | never; no .storage-write.lock either | 7–12 ms before process end, i.e. by exit cleanup |

In the 3 lost runs: exit code 0, stdout identical to a successful run, nothing in --debug-file (which logs neither successful nor abandoned refreshes), no signal received. The next run got a 400 and blanked the store.

It is a tight race. Lock taken → credentials written took 333–464 ms on successful runs. doctor exits 383–672 ms after taking the lock. Some successful runs won by 18 ms. Consistent with that:

  • the loss rate follows doctor's own run time rather than the version. 2.1.276 (≈0.4 s total run time) lost about one rotation in two; 2.1.278 (≈0.6–0.75 s) had 18 successes in a row, then a loss;
  • throttling the process to 10 % CPU (CPUQuota=10%, 3.3–3.9 s run time) does not help: it slows the reply handling as much as the rest, the lock was held 957–1,776 ms, and it still lost 1 of 3. We first misread this as ruling the race out;
  • on the same account, a Windows desktop running the same near-expiry claude doctor loop (≈2.6 s run time) has not lost a rotation in three weeks, and long-running interactive sessions on Linux never do;
  • claude auth status exits after ≈225 ms here: in 20 runs it never renewed and never broke the login, presumably because it exits before the POST leaves;
  • once, a doctor run also left ~/.claude.json.tmp.<pid>.<hex> behind, never renamed: the exit does not wait for pending writes either.

Ask. Please cover doctor in the fix (await an in-flight refresh before process.exit(), as waitForHeldOAuthRefresh already does on the graceful path). A supported non-interactive way to refresh the stored login would help too, something like claude auth refresh. Headless setups that need a live user:profile token currently have no reliable way to keep one.

Happy to share the bench script (it never logs tokens, only short hashes) or run a patched build.

legion-the-geth · 22 days ago

Follow-up with a workaround that holds on 2.1.278, for anyone keeping a headless login alive.

Trigger the renewal with claude -p /usage instead of claude doctor or auth status. In -p mode, /usage is a local command: the source declares it type:"local" with supportsNonInteractive, and our debug logs show no request to the model endpoint. It needs a valid token to fetch /api/oauth/usage. So when the stored token is within the refresh window, the fetch waits for the in-flight refresh, and the new pair is saved before the fetch goes out and before the process exits.

We tested it against the race, using a small CONNECT proxy (HTTPS_PROXY, no TLS interception) that holds the response from platform.claude.com for 3 s after the request has left. Same bench as above, 2.1.278:

| Condition | Runs | Result |
|---|---|---|
| usage cache stale, token expired or 4 min before expiry | 7 | 7 renewed. The usage request only leaves after .credentials.json is written, and the process exits 1.0–1.8 s after that write |
| usage cache fresh | 1 | rotation lost. The endpoint isn't queried, nothing awaits the refresh, and the process exits before the delayed response arrives |

Caveat: the usage cache. /usage answers from cachedUsageUtilization in ~/.claude.json while its fetchedAtMs is recent. A 25–32 s old snapshot was served from cache; after 65 s it refetched. On the cache, nothing needs the token, and it is the same race as doctor. So only run it when fetchedAtMs is older than a couple of minutes, which is always true if it only runs at renewal time.

Each run also reads the rate-limited usage endpoint once, which is fine at one run per renewal. This relies on internal behaviour, so re-check it on every upgrade. A fix for this issue, or a supported claude auth refresh, would still be the real answer.

curran-gehring · 11 days ago

Same mechanism on Windows 11 (2.1.284–2.1.288, plaintext ~/.claude/.credentials.json, Max plan). The trigger was a 5-minute scheduled watchdog running claude daemon status and claude agents --json. It shows how this lands on the daemon when nobody runs auth status by hand.

The daemon.log signature. Six forced logouts between 09-22 and 10-03 all have this shape. The first attempt fails after 7.6–8.0 s; a healthy refresh here takes ~0.5 s:

[2026-10-03T10:19:01.523Z] [supervisor] auth: proactive refresh starting
[2026-10-03T10:19:09.121Z] [supervisor] auth: proactive refresh failed, retrying in ~60s (token still valid)
[2026-10-03T10:20:09.125Z] [supervisor] auth: proactive refresh starting
[2026-10-03T10:20:09.480Z] [supervisor] auth: proactive refresh failed, signalling re-auth required
[2026-10-03T10:20:09.511Z] [supervisor] auth: headless daemon cannot complete OAuth — run `claude auth login` to refresh

Reading it against the code:

  • The ~7.6 s is the ELOCKED retry loop on .oauth_refresh.lock: 5 retries × 1000 + rand·1000 ms. A short-lived command started its init refresh inside the 5-minute window, then exited without saving or releasing the lock. The lock directory stays "fresh" until stale: 60000 runs out.
  • The retry 60 s later gets the lock. It reads the unchanged file, which still holds the refresh token the short-lived process already spent. It gets invalid_grant, and Dtr blanks accessToken/refreshToken/expiresAt on disk. That logs out every session on the machine.
  • At every logout, a watchdog run came 10–50 s before the daemon's first attempt, always inside that 60 s lock lifetime. A file watcher on .credentials.json saw no write between the last good rotation and the wipe. So the spent token's replacement never reached disk.

Workaround that removed the trigger here: don't run short-lived claude subcommands on a timer near token expiry. Check claudeAiOauth.expiresAt first and skip the call when it is less than 15 minutes away. For daemon liveness, read the pid from ~/.claude/daemon.lock instead of claude daemon status. This matches the earlier comment here about claude doctor.

Two suggestions beyond waiting for in-flight refreshes on exit:

  1. When a refresh returns invalid_grant right after the lock was found held by a process that is now dead, the posted token was most likely spent by that process. The token isn't proven dead. Blanking the shared store turns one lost save into a machine-wide logout.
  2. Short-lived informational commands (daemon status, agents, auth status, doctor) could skip the init refresh entirely. They don't need a fresh access token to answer.

Showing cached comments. Read the full discussion on GitHub ↗