[BUG] Concurrent MCP OAuth refresh across sessions destroys valid credentials with rotating refresh tokens
Status Open
Reported on v2.1.223
Maintainer reply None cached
Workaround ✓ Mentioned in description ↑
Activity 0 comments · opened Aug 6, 2026
Environment
- Claude Code 2.1.223 (CLI), macOS (Darwin 25.5.0)
- Remote MCP server:
type: http, OAuth 2.1 + PKCE, proxying to Auth0 - Auth0 client config: refresh token
rotation_type: rotating,leeway: 0; access token lifetime 300s
Summary
When multiple Claude Code processes share one stored MCP OAuth credential (single keychain entry), they race to refresh the same rotating refresh token at expiry. The first request wins and rotates the token; the losers hit the IdP's reuse detection (invalid_grant), which revokes the whole token family. A losing process then clears the keychain entry, destroying the new token the winning process just stored — so even the sessions that refreshed successfully lose authentication.
Reproduction (100% reproducible)
- Authenticate an OAuth HTTP MCP server backed by an IdP with rotating refresh tokens and a short access-token lifetime.
- Wait for the access token to expire.
- Run 5 concurrent probes:
``bash``
for i in $(seq 5); do claude mcp get <server> & done; wait
Observed (two runs, with and without a fixed oauth.callbackPort — identical result):
- Run A: 2x
✔ Connected, 2x! Needs authentication, 1x✘ Failed to connect - Run B: 4x
✔ Connected, 1x! Needs authentication - Keychain
mcpOAuthentry immediately after the race:expiresAt: 0,accessToken: null,refreshToken: null(verified via SHA-256 hashes of the token values before/after — the winner's rotated token was stored, then wiped) - Any subsequent probe:
! Needs authentication
Expected behavior
- Cross-process coordination on token refresh (e.g. a file lock around the refresh + keychain write), the way auth0-spa-js serializes refresh across browser tabs.
- A process whose refresh fails with
invalid_grantshould re-read the credential store before clearing it — if another process has stored a newer token in the meantime, use that token instead of wiping it.
Related
- #43000 (multi-terminal re-auth; closed as stale — the storage-key part appears fixed in 2.1.223, but the refresh race remains)
- #61912 (credential state corrupted by transient refresh failure)