[BUG] Short-lived subcommand (claude plugin validate) exits mid OAuth refresh: the rotated refresh token is never saved, the next process gets invalid_grant and blanks the login
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet. #88583 has the same blanked-credential signature, caused there by concurrent sessions racing the single-use refresh token. This one needs no concurrency: one short-lived CLI subcommand loses the rotated token on its own.
- [x] This is a single bug report
- [ ] I am using the latest version of Claude Code (2.1.299; 2.1.300 came out hours earlier, and the code paths named below are still present in it)
What's Wrong?
When the stored claude.ai access token has expired, a short-lived subcommand such as claude plugin validate <dir> starts an OAuth refresh and then finishes about a second later, before the new token pair is written to ~/.claude/.credentials.json. The server has already rotated the refresh token, so the file is left holding one the server has retired. The next Claude Code process that needs a token refreshes with it, gets invalid_grant, and blanks the stored credential (accessToken: "", refreshToken: "", expiresAt: 0). From then on every process is logged out until an interactive login.
No other process touched the credential in between: the only other Claude Code processes running were Claude Desktop sessions, which authenticate with their own CLAUDE_CODE_OAUTH_TOKEN. That variable is not passed to their Bash tool, so CLI commands run from a Desktop session's shell use the shared file.
Timeline (Linux, plaintext credential store), from a local proxy's per-process connection log and the commands' own timestamps:
| Time (JST, 2026-10-15) | What happened |
|---|---|
| 00:23:42 | claude auth login completes: fresh login, the CLI then the browser connect to platform.claude.com |
| 00:24 to 11:48 | no process connects to platform.claude.com |
| 11:48:21 | claude plugin validate <plugin dir> starts (CLI 2.1.299) |
| 11:48:23 | that process connects to platform.claude.com:443 (the token endpoint) |
| 11:48:24 | the command has finished |
| 11:52:02 | a new headless session (Desktop's bundled engine 2.1.295, -p --input-format stream-json, no CLAUDE_CODE_OAUTH_TOKEN) connects to platform.claude.com |
| 11:52:03 | ~/.claude/.credentials.json is rewritten with blank tokens; the session answers Failed to authenticate: OAuth session expired and could not be refreshed |
The pair issued at 11:48:23 never reached the file: had it, the 11:52 refresh would have used it instead of the retired token.
The same sequence happened on 2026-10-05 with 2.1.289: claude plugin validate at 18:53:09 and again at 18:54:10, each with a connection to platform.claude.com in the same second, and the credential file blanked at 18:54:11. The first run rotated the token and exited without saving it; the second refreshed with the retired token and blanked the store.
Two code paths in the binary (2.1.295 to 2.1.300) fit this. At exit, an in-flight refresh is waited for only briefly (OAuth refresh still in flight after <n>ms; exiting without waiting further). On invalid_grant, the stored tokens are blanked (tengu_oauth_refresh_token_marked_dead_invalid_grant, tengu_oauth_refresh_token_cleared_on_disk). A process that leaves after the token endpoint has answered but before saving therefore destroys the login for every process on the machine.
What Should Happen?
- A subcommand that never calls the model (
plugin validateand the like) should not start a token refresh. - Once a refresh request has been sent, the process should persist the answer before it exits instead of giving up after a short wait: the old refresh token is dead the moment the server answers.
- Ideally, a less destructive answer to
invalid_grantthan blanking the shared store, or a short reuse grace on the server for the refresh token that was just rotated.
Error Messages/Logs
Failed to authenticate: OAuth session expired and could not be refreshed
The credential file afterwards (values elided):
"claudeAiOauth": {
"accessToken": "",
"refreshToken": "",
"expiresAt": 0,
"refreshTokenExpiresAt": 1794380874109, // intact, ~4 weeks ahead
"scopes": ["user:file_upload", "user:inference", "user:mcp_servers", "user:plugins", "user:profile", "user:sessions:claude_code"],
"subscriptionType": "max",
...
}
Proxy log, trimmed to the token endpoint:
2026-10-15T00:23:42 (2.1.299) --> platform.claude.com:443
2026-10-15T11:48:23 (2.1.299) --> platform.claude.com:443
2026-10-15T11:52:02 (claude) --> platform.claude.com:443
Steps to Reproduce
- Log in with
claude auth login(claude.ai subscription; Linux, plaintext store). - Let the access token expire (about 8 hours) without running Claude Code meanwhile.
- Run
claude plugin validate <any plugin folder>and let it finish. - Run
claude -p hi: it fails withOAuth session expired and could not be refreshed, and the stored tokens are blank.
This is the sequence that happened twice. I have not reproduced it on purpose, since each attempt costs an interactive login.
Claude Model
None (the failure happens before any model call)
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.299 (Claude Code) for the subcommand that lost the token; 2.1.289 on 2026-10-05
Platform
Anthropic API (claude.ai Max subscription login)
Operating System
Linux (Arch Linux)
Terminal/Shell
Other (commands run from a Claude Desktop session's Bash tool, zsh)
Additional Information
Workaround being tried here (installed, not yet seen through a refresh): a systemd user timer checks the stored expiry every 30 seconds and, once the access token enters Claude Code's 5-minute refresh window, runs one minimal claude -p. That run waits for the refresh and saves it, so short-lived commands never find the token due.
Related: #88583 (same blanked signature from concurrent sessions), #92630 (mentions the "exited mid-refresh" message), #98693.