Plugin hooks that refresh OAuth tokens silently break authentication
Bug Description
When a plugin hook reads OAuth credentials from the macOS Keychain and performs a token refresh (via POST /v1/oauth/token), it invalidates the access token that Claude Code is currently holding. This causes persistent 401 authentication_error on every subsequent API call, even immediately after a successful /login.
Steps to Reproduce
- Create a plugin with a
statusLinehook (or any frequently-executing hook) that:
- Reads credentials from macOS Keychain via
security find-generic-password -s "Claude Code-credentials" -w - Detects the token is expired
- Calls the OAuth refresh endpoint to get a new access token
- Install and enable the plugin
- Run
/login— succeeds - Send any message — fails with
401 Invalid authentication credentials - Run
/loginagain — succeeds - Send any message — fails again with
401
This loop repeats indefinitely because the hook fires on every render cycle, immediately invalidating the freshly-obtained token.
Actual Behavior
/login → Login successful
"hello" → API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}
/login → Login successful
"test" → API Error: 401 ...
The user sees no indication that a plugin is the cause. The error message only says "Please run /login", which does not help since login succeeds but the token is immediately invalidated by the plugin hook.
Expected Behavior
Either:
- Sandbox credential access — Plugin hooks should not be able to read or refresh OAuth tokens from the Keychain. Credentials should be inaccessible to plugin subprocesses.
- Detect external token invalidation — If Claude Code's token is invalidated externally, it should automatically re-authenticate rather than showing a generic "Please run /login" message.
- Warn on credential access — If a plugin hook accesses
security find-generic-passwordfor Claude Code credentials, warn the user or block the call.
Environment
- Claude Code: latest (CLI)
- OS: macOS (Sequoia 15.5)
- Plugin: Custom plugin with statusLine hook using
bestwork-hud.mjs
Workaround
Remove all refreshAccessToken() / OAuth token refresh logic from plugin hooks. Only read the current access token without refreshing — rely on Claude Code to manage its own token lifecycle.
Additional Context
The root cause is that OAuth refresh tokens are single-use. When a plugin hook refreshes the token, it receives a new access token and the old one (held by Claude Code) is revoked server-side. Since statusLine hooks execute on every render cycle, this creates a race condition where the plugin always wins and Claude Code always loses.
This is particularly dangerous because:
- There is no permission boundary between plugin hooks and Claude Code's credential store
- The failure mode is completely silent — no log, no warning, just repeated 401s
- Users will blame Claude Code login, not the plugin
This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗