[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

Status Open
Reported on v2.1.299
Maintainer reply None cached
Activity 0 comments · opened Oct 15, 2026

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 validate and 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_grant than 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

  1. Log in with claude auth login (claude.ai subscription; Linux, plaintext store).
  2. Let the access token expire (about 8 hours) without running Claude Code meanwhile.
  3. Run claude plugin validate <any plugin folder> and let it finish.
  4. Run claude -p hi: it fails with OAuth 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.

View original on GitHub ↗