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
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
- Every command awaits
initin the rootpreActionhook.initstarts the
OAuth populate step without awaiting it:
…Dr("init_after_1p_event_logging"),CJr(…),rlr(c,o).catch(d),Dr("init_after_oauth_populate")…
rlrbegins withawait Gs({credentials:e,storageV5:n}), andGsis
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.
auth status's action renders and callsprocess.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.
- 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_DIRpoints at a throwaway directory.- Its Keychain item holds a fabricated credential whose
expiresAtis in the
past.
HTTPS_PROXYandNODE_EXTRA_CA_CERTSsendplatform.claude.comto a local
mock /v1/oauth/token. Every other host is refused.
- The mock answers
grant_type=refresh_tokenwith 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_tokengrant 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 checksauth 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
eEreturns"refreshed"whateverzkn(the CAS save) returns, so a failed
save after a successful rotation is also silent.
- proper-lockfile's stale-steal
rmdirfailsENOTEMPTYon a legacy
<config>.lock directory that something has written files into. It then
stays "held" for 60s after every write inside it.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Same failure on Linux with
claude doctor, measured from the outside with inotify. It matches your analysis exactly, anddoctorshould 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 runningclaude doctorwhenever the access token is within 5 minutes ofexpiresAt(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
HOMEwith its own login, used for nothing else. Before each run,expiresAtis set to now + 4 min. Thenclaude doctorruns with stdout not a TTY. inotify timestamps every create/rename/delete in~and~/.claudeto 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.jsonrenamed into place |.oauth_refresh.lockremoved ||---|---|---|
| 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.lockeither | 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.
doctorexits 383–672 ms after taking the lock. Some successful runs won by 18 ms. Consistent with that: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;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;claude doctorloop (≈2.6 s run time) has not lost a rotation in three weeks, and long-running interactive sessions on Linux never do;claude auth statusexits after ≈225 ms here: in 20 runs it never renewed and never broke the login, presumably because it exits before the POST leaves;doctorrun also left~/.claude.json.tmp.<pid>.<hex>behind, never renamed: the exit does not wait for pending writes either.Ask. Please cover
doctorin the fix (await an in-flight refresh beforeprocess.exit(), aswaitForHeldOAuthRefreshalready does on the graceful path). A supported non-interactive way to refresh the stored login would help too, something likeclaude auth refresh. Headless setups that need a liveuser:profiletoken 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.
Follow-up with a workaround that holds on 2.1.278, for anyone keeping a headless login alive.
Trigger the renewal with
claude -p /usageinstead ofclaude doctororauth status. In-pmode,/usageis a local command: the source declares ittype:"local"withsupportsNonInteractive, 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 fromplatform.claude.comfor 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.jsonis 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.
/usageanswers fromcachedUsageUtilizationin~/.claude.jsonwhile itsfetchedAtMsis 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 asdoctor. So only run it whenfetchedAtMsis 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.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 runningclaude daemon statusandclaude agents --json. It shows how this lands on the daemon when nobody runsauth statusby 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:
Reading it against the code:
ELOCKEDretry loop on.oauth_refresh.lock: 5 retries ×1000 + rand·1000ms. 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" untilstale: 60000runs out.invalid_grant, andDtrblanksaccessToken/refreshToken/expiresAton disk. That logs out every session on the machine..credentials.jsonsaw 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
claudesubcommands on a timer near token expiry. CheckclaudeAiOauth.expiresAtfirst and skip the call when it is less than 15 minutes away. For daemon liveness, read the pid from~/.claude/daemon.lockinstead ofclaude daemon status. This matches the earlier comment here aboutclaude doctor.Two suggestions beyond waiting for in-flight refreshes on exit:
invalid_grantright 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.daemon status,agents,auth status,doctor) could skip the init refresh entirely. They don't need a fresh access token to answer.