[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
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)

  1. Authenticate an OAuth HTTP MCP server backed by an IdP with rotating refresh tokens and a short access-token lifetime.
  2. Wait for the access token to expire.
  3. 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 mcpOAuth entry 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

  1. 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.
  2. A process whose refresh fails with invalid_grant should 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)

View original on GitHub ↗