MCP OAuth: per-process refresh lock lets concurrent sessions race token refresh; rotation-strict servers revoke the token family, forcing manual re-auth

Status Open
Reported on v2.1.222
Maintainer reply None cached
Activity 1 comment · opened Aug 9, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet (closest are #43000 and #66210, both closed without a fix; this report adds root-cause evidence from MCP logs)
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code (reproduced on 2.1.222 and 2.1.226)

What's Wrong?

The MCP OAuth token refresh lock is per-process only. When multiple Claude Code sessions run concurrently on the same machine (multiple terminals plus scheduled/headless sessions) and the access token has expired, each session independently acquires its own "refresh lock" and attempts the refresh. Against servers that use single-use rotating refresh tokens with reuse detection (e.g. Exa's hosted MCP, mcp.exa.ai), the second refresh presents an already-rotated token, the server treats it as token theft and revokes the entire token family, and Claude Code then clears stored tokens. Every subsequent session fails with Unauthorized until the user manually re-authenticates via /mcp.

Log evidence from ~/Library/Caches/claude-cli-nodejs/<project>/mcp-logs-plugin-exa-exa/, three sessions starting within 8 seconds:

Session A (03:55:20) — wins the race:

Token expires in -889s, attempting proactive refresh
Acquiring refresh lock (attempt 1)
Acquired refresh lock          <- acquired instantly, no cross-process contention
Starting token refresh

Session B (03:55:26) — 6 seconds later, also acquires "the" lock instantly:

Token expires in -896s, attempting proactive refresh
Acquiring refresh lock (attempt 1)
Acquired refresh lock          <- per-process lock, so no coordination with Session A
Starting token refresh
Token refresh failed with invalid_grant: Refresh token has been revoked
No valid tokens in storage, clearing stored tokens

Session C (03:55:28) — and every session after:

No access token in storage
HTTP Connection failed after 837ms: Unauthorized

Across ~5 weeks of logs on this machine: 93 session starts found no token in storage (each one either failed silently in headless runs or demanded a browser re-auth), versus 191 that connected fine. The failures always begin at a refresh race and persist until manual /mcp re-auth.

A related failure from the same logs (possibly worth its own issue): after a CLI auto-update, a refresh failed with invalid_grant: Client ID mismatch, suggesting the dynamic client registration was redone while the stored refresh token still belonged to the old client ID. Same end state: tokens cleared, manual re-auth required.

Steps to Reproduce

  1. Add an OAuth HTTP MCP server whose auth server rotates refresh tokens and revokes on reuse (Exa's https://mcp.exa.ai/mcp?client=claude-code-plugin, i.e. the official exa plugin, is one).
  2. Authenticate via /mcp. Access token TTL is 3600s.
  3. Wait for the access token to expire (>1h).
  4. Start two or more Claude Code sessions within a few seconds of each other (or have a scheduled headless session overlap an interactive one).
  5. Second session's refresh gets invalid_grant: Refresh token has been revoked; stored tokens are cleared; all sessions are now locked out until manual re-auth.

Expected Behavior

Token refresh should be coordinated across processes (e.g. an advisory file lock in ~/.claude around refresh, with a re-read of storage after acquiring the lock so the loser picks up the winner's fresh tokens instead of refreshing again). A refresh race should never destroy stored credentials.

Environment

  • Claude Code 2.1.222 and 2.1.226 (claude-code/2.1.226 (cli))
  • macOS (darwin 25.5.0), Node v26.3.0 (bundled runtime)
  • Server: exa official plugin (plugin:exa:exa, HTTP transport, OAuth via auth.exa.ai, scope mcp:tools, 3600s access tokens, rotating refresh tokens)

Workaround

Switching the server to API-key auth (for Exa: an x-api-key header) sidesteps OAuth entirely, which is how we confirmed the refresh race is the only failure mechanism.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗