[BUG] MCP OAuth with multiple terminals open causes re-auth

Status Closed — not planned
Reported on v2.1.91
Maintainer reply None cached
Activity 11 comments · opened Apr 3, 2026 · closed Jul 26, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

What's wrong?

When running multiple Claude Code instances simultaneously (multiple terminals, same machine),
each new instance fails to find the stored MCP OAuth token and triggers a new browser
authentication flow — even when tokens have days or weeks of TTL remaining.

Root cause

After OAuth completes, Claude Code stores MCP tokens in the macOS Keychain (or
~/.claude/.credentials.json on Linux) under the service name Claude Code-credentials,
within an mcpOAuth object. The storage key for each server entry is:serverName|base64(callbackUrl)

For example, after authenticating against an MCP server named my-server:

```json{
"claudeAiOauth": { "...": "..." },
"mcpOAuth": {
"my-server|aHR0cDovL2xvY2FsaG9zdDo1MjQ4NS9jYWxsYmFjaw": {
"serverName": "my-server",
"serverUrl": "https://my-mcp-server.example.com/mcp",
"accessToken": "ey...",
"refreshToken": "...",
"expiresAt": 1775807773583,
"clientId": "aHR0cDovL2xvY2FsaG9zdDo1MjQ4NS9jYWxsYmFjaw"
}
}
}


The `clientId` value decodes from base64 to:http://localhost:52485/callback

**Port `52485` is ephemeral.** Claude Code picks a random available localhost port for the
OAuth callback listener on every startup. The next instance might use port `61203`, producing
a different base64 string, a different storage key, and a complete miss on lookup. The valid
token is never found, a new OAuth flow is triggered, and yet another orphaned entry accumulates
in the Keychain.

This is confirmed by the key structure visible in related issue #28262
(`"atlassian|<hash>"`, `"notion|<hash>"`), which exhibits the same key format on Linux via
`~/.claude/.credentials.json` — confirming the issue is platform-independent.

---

## What happens

- Terminal A authenticates successfully against the MCP server. Tools work.
- Terminal B starts. It generates a different callback port, constructs a different storage key,
  finds no token, and immediately prompts for re-authentication.
- After re-authenticating in terminal B, terminal A may now also lose its session if the
  server invalidates the previous DCR client registration.
- Inspecting the credential store after two auth cycles shows two orphaned entries under
  different keys, both pointing to the same server URL.

### What Should Happen?

Claude should use the token for the specific MCP server instead of asking to authenticate.

### Error Messages/Logs

```shell

Steps to Reproduce

Steps to reproduce

  1. Add an HTTP MCP server with OAuth:

claude mcp add --transport http my-server https://my-mcp-server.example.com/mcp

  1. Open Claude Code in terminal A, run /mcp, complete the browser OAuth flow.
  2. Verify tools are available and working.
  3. Inspect the credential store:
   # macOS
   security find-generic-password -s "Claude Code-credentials" -w | python3 -m json.tool

   # Linux
   cat ~/.claude/.credentials.json | python3 -m json.tool

Observe the mcpOAuth key — note the base64 segment. Decode it:

   echo "aHR0cDovL2xvY2FsaG9zdDo1MjQ4NS9jYWxsYmFjaw" | base64 -d
   # → http://localhost:52485/callback   ← ephemeral port
  1. Open Claude Code in terminal B (new terminal, same machine).
  2. Observe: terminal B immediately asks for re-authentication despite the token having a

TTL of days or weeks.

  1. Re-inspect the credential store after authenticating in terminal B — a second mcpOAuth

entry now exists under a different key with a different port in the base64 value.

---

Why clientId should not be part of the storage key

The clientId (derived from the callback URL) serves a legitimate purpose during the DCR
OAuth flow
: it identifies the dynamically registered client to the authorization server for
that specific session. It is unsuitable as a persistent storage key for three reasons:

  1. It encodes a transient implementation detail. The localhost port is an ephemeral OS

resource, not a stable identity. It changes on every process start by design.

  1. It identifies a session, not a user+server relationship. The meaningful identity for

token storage is the combination of user and MCP server — not which port happened to be
free during registration.

  1. The server-side DCR registration is also ephemeral. Per RFC 7591, each DCR flow

creates a new client record server-side. When a new instance re-authenticates, it registers
a new client anyway — the old clientId is abandoned on both ends. Keeping it in the
storage key provides no continuity benefit whatsoever.

Note on stateful MCP servers: A stable clientId could theoretically be useful for
stateful MCP servers tracking per-client session state — but the current design actively
prevents this, since each instance generates and discards its own. The proposed fix below
would improve stateful scenarios as a side effect, by enabling clientId reuse across
instances.

---

Proposed fix

Change the storage key from serverName|base64(callbackUrl) to serverName|serverUrl
(or simply serverName for user-scoped configurations where one entry per server is expected).

On token lookup, Claude Code would:

  1. Retrieve the stored entry by serverName|serverUrl
  2. Reuse the stored clientId from that entry for subsequent requests (enabling proper

stateful session continuity as a bonus)

  1. Only trigger a new OAuth flow if no entry exists, or if both access token and refresh

token are expired or absent

This is a minimal change to the key construction logic. Existing entries can be migrated by
re-keying on next successful authentication, or simply fall through to a one-time fresh auth
and then persist correctly going forward.

---

Impact

Every developer running more than one Claude Code terminal with any OAuth-protected HTTP MCP
server is affected. With long-lived tokens (days to weeks TTL), this is pure unnecessary
friction — the tokens are valid, stored, and silently ignored.

---

Environment

  • Claude Code: latest
  • Transport: http (native, no mcp-remote proxy)
  • OAuth: Dynamic Client Registration (RFC 7591)
  • Platform: macOS (Keychain) and Linux (~/.claude/.credentials.json) — same key

construction on both, confirmed by issue #28262

---

Related issues

  • #28262 — MCP OAuth tokens not auto-refreshing; reveals the same serverName|<hash> key

structure on Linux

  • #5706 — Missing token refresh mechanism for MCP server integrations
  • #12447 — OAuth token expiration disrupts autonomous workflows
  • #21333 — MCP OAuth refresh tokens stored but never used
  • #9403 — macOS Keychain service name mismatch (different bug, same auth subsystem)

Claude Model

Sonnet (default)

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.91 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

_No response_

View original on GitHub ↗

11 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/24317
  2. https://github.com/anthropics/claude-code/issues/35740
  3. https://github.com/anthropics/claude-code/issues/42922

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

tpjg · 4 months ago

Number 2 (https://github.com/anthropics/claude-code/issues/35740) is similar, actually my guess is that if you solve this bug it'll also resolve that one. This bug has a more generic problem statement and proposed fix that could also benefit 35740.

shigechika · 4 months ago

Great root-cause analysis — the serverName|base64(ephemeral_callback_url) key structure explains a lot of the "why does it randomly re-prompt" reports.

Until this is fixed upstream, mcp-stdio sidesteps the bug by design. It's a stdio ↔ Streamable-HTTP bridge with its own OAuth 2.1 client, and its token store keys tokens by server URL only — not by the ephemeral callback port:

  • Storage: ~/.config/mcp-stdio/tokens.json, mode 0o600, one entry per server URL (including client_id, client_secret, endpoint URLs, refresh token).
  • The callback listener still uses an ephemeral port on every flow, but the port is never part of the storage key, so a different port on the next invocation is a non-event.
  • Refresh tokens are persisted and reused across processes. Terminal A authenticates once; terminals B, C, … pick up the existing token from disk and never hit the browser.
claude mcp add myserver -- uvx mcp-stdio --oauth https://server.example.com/mcp

Claude Code sees this as a plain stdio server, so the mcpOAuth Keychain / .credentials.json path is never touched for servers routed through mcp-stdio — no orphaned entries accumulate either.

DownRangeDevOps · 4 months ago

Hitting this daily with the hosted Atlassian MCP (https://mcp.atlassian.com/v1/mcp, type: http in ~/.claude.json). Re-auth prompt every 2–6 hours despite a valid refresh token. Claude Code 2.1.119 on macOS 15 (Darwin 25.3.0).

I think OP's key-collision path is one of two concurrency bugs, not the whole story. Adding the second one with evidence in case it helps.

Storage layout (2.1.119, macOS). Tokens no longer live in ~/.claude/.credentials.json. They're in a single macOS keychain item Claude Code-credentials, account = $USER, holding one JSON blob for all MCP OAuth servers:

{
  "mcpOAuth": {
    "atlassian|1eb778bd626fb68d": {
      "serverName": "atlassian",
      "serverUrl": "https://mcp.atlassian.com/v1/mcp",
      "accessToken": "<93 chars>",
      "refreshToken": "<93 chars>",
      "expiresAt": 1777552404689,
      "clientId": "VrwdyfzevC4w8IwJ",
      "scope": ""
    },
    "mcp-2|...":   { ... },
    "mcp-3|...":  { ... }
  }
}

Reproduces the #43000 key-encoding issue (keys still include a per-instance suffix) and surfaces a second problem: the blob is rewritten whole by every Claude Code process.

Repro for the multi-process clobber.

  1. Start N ≥ 2 concurrent claude processes that have Atlassian access (teammate-mode tmux makes this trivial; I regularly have 5+: ps aux | rg '^[^ ]+ +\d+.* claude ' | wc -l → 5).
  2. Let all of them idle past the access-token TTL (~1h).
  3. First tool call in any one session triggers a refresh. Atlassian rotates the refresh token and returns a new one. That session writes the full mcpOAuth blob back to keychain.
  4. Any other session that subsequently refreshes still has the old refresh token in memory. Atlassian returns invalid_grant. That session prompts for full interactive re-auth and then writes the blob back — invalidating everyone else.

Net effect: the more parallel sessions, the shorter the effective token lifetime. With 5 active sessions I see re-auth every 2–4 hours; with 1 session it holds the full ~24h.

Two concurrency bugs, not one.

  • #43000 as-filed: ephemeral callback port baked into the storage key → cache miss → unnecessary fresh flow. Fix: key on serverName|serverUrl.
  • This comment: single shared blob + per-process in-memory copies + rotating refresh tokens → lost-update / stale-token race. Fix: single-writer discipline (file lock, broker process, or at minimum read-modify-write under a keychain-level lock with a re-read on invalid_grant).

Closed #28262 is the earlier Atlassian surface of the same family.

Workarounds that don't work.

  • Setting "Authorization": "Bearer <PAT>" in headers — Claude Code doesn't expand ${ENV}/keychain refs in HTTP-MCP headers, and mcp.atlassian.com rejects static bearers anyway (OAuth-only).
  • envFile / env — stdio-only, not available for type: http.

Happy to test a patch against a prerelease if useful.

crisperit · 3 months ago

Is there anyone from Anthropic on this? It's so annoying..

semanticart · 3 months ago

I'm also running into this several times a day.

dude84 · 3 months ago

Adding evidence from a self-hosted MCP server (Python mcp SDK with OAuthAuthorizationServerProvider, SSE transport, full DCR + auth-code + refresh-token support) — confirms the root cause analysis here and adds two adjacent data points.

1. SSE transport may not persist at all

This thread's keychain dump shows MCP tokens under Claude Code-credentialsmcpOAuth for HTTP-transport servers. On my install (macOS, claude mcp add --transport sse ha-ops http://10.0.0.150:8901/sse), after a fully successful authorization-code exchange:

security find-generic-password -s "Claude Code-credentials" -w | python3 -c 'import json,sys; d=json.load(sys.stdin); print(list(d.keys()))'
# ['claudeAiOauth']

No mcpOAuth key, no per-server entry. Same ~/.claude/.credentials.json, ~/.claude/mcp-needs-auth-cache.json, and ~/Library/Application Support/Claude/ checked — no MCP OAuth state for SSE servers anywhere on disk. So for SSE transport the "write on auth completion" step doesn't appear to happen at all; for HTTP transport (per the original report) it writes but keys by the ephemeral callback port. Two different surfaces, same user-visible symptom of re-auth at every launch.

If the fix here covers reading existing entries by URL instead of serverName|base64(callbackUrl), please also make sure the write path is wired for the SSE transport — otherwise SSE servers will still have nothing to read.

2. Server-side accumulation for DCR-capable servers

For self-hosted MCP servers that implement Dynamic Client Registration (DCR), every Claude Code launch results in a fresh DCR call with a new ephemeral callback URL → a new client_id is minted and stored server-side. After three launches my server's auth-status reports:

clients: 3   (all named "Claude Code (ha-ops)")
  - 4fd4f93c... → http://localhost:50836/callback
  - 66d8073f... → http://localhost:62091/callback
  - e5dc7735... → http://localhost:3118/callback
access_tokens: 2 active, both valid for ~29 more days
refresh_tokens: 2 active, both valid for ~29 more days

The tokens never expire in practice (30-day sliding TTL on the server) — they're stranded under stale client_ids and grow unbounded over time. Not a security issue (orphaned, scoped, individually revocable), but it's noise that needs server-side GC if Claude Code keeps re-DCRing.

A pinned callback port (or wildcard 127.0.0.1 loopback redirect URI per OAuth 2.1 §7.5.2) would let the server treat repeat launches as the same client without any persistence on the Claude Code side — worth considering as the simpler fix.

3. Notes

  • I filed this as anthropics/claude-code#58607 before searching carefully; the github-actions bot correctly flagged it as a dup of this issue and I'm closing #58607. The SSE + DCR angle above wasn't visible in any of the existing reports though, hence cross-posting here.
  • Related: #57674 (HTTP, written-but-not-loaded), #52565 (custom connector / Windows / Cowork), #26675 (pre-configured static client_id for non-DCR providers).
alexjcampbell · 3 months ago

+1 to this issue being super annoying.

Marsssssssssssdsss · 2 months ago

@tpjg The root cause is the token is bound to the terminal session's ephemeral callback URL. Workaround: pre-generate a persistent OAuth token and inject it via env var instead of letting each instance re-auth. This decouples the credential from the terminal session entirely.

Chris8E · 2 months ago

+1 — still hitting this daily, and I have two fresh data points that I think sharpen the root cause and show it survives the 2.1.151 login-flow fix.

Environment

  • Claude Code (CLI), latest
  • macOS (Darwin 25.x)
  • A remote MCP server using OAuth 2.1 + PKCE + Dynamic Client Registration (DCR), type: http, registered in a project .mcp.json as { "type": "http", "url": "https://…/api/mcp" } (no auth block — discovery-only, as intended).

Symptom (matches OP): authorize once via the browser, tools work for the rest of the launch. Quit Claude Code, relaunch → /mcp shows the server back to "Pending approval", full browser auth-code flow required again. Server-side access + refresh tokens are still valid (weeks of TTL); they're just never found on the next launch.

Data point 1 — a fresh dyn_ DCR client per launch, not a reused one. On each new launch the server records a brand-new registered client (e.g. client_id=dyn_<uuid>) paired with a different localhost:<port>/callback. So this isn't only a lookup-key miss against a stored token — the registered DCR client itself is not being reused: Claude Code re-runs DCR every launch and abandons the prior client_id + refresh_token. The server accumulates one orphaned dyn_* client per launch, each holding a stranded-but-valid refresh token. This is the same shape @DownRangeDevOps and @dude84 describe, viewed from the server's client registry.

Data point 2 — for this server, nothing for the MCP lands on disk at all. Audited the same four locations the thread mentions:

  • security find-generic-password -s "Claude Code-credentials" -w | python3 -c 'import json,sys; print(list(json.load(sys.stdin).keys()))'['claudeAiOauth'] only. No mcpOAuth key, no per-server entry.
  • No MCP entry in ~/.claude.json (project mcpServers block has the URL/transport only — no client_id, refresh_token, or registration).
  • No *credential* / *oauth* / *token* file anywhere under ~/.claude/.
  • claude mcp get <server>Status: ⏸ Pending approval.

So on this install the "write the token to the Keychain on auth completion" step appears to not happen at all for this server (same surface @dude84 reported for SSE transport), while OP's HTTP case writes but keys by the ephemeral callback port. Two different write paths, one user-visible symptom.

Why this is broader than any one server. This isn't specific to my MCP provider — it's the generic remote-MCP OAuth path. Every remote-MCP server that uses OAuth 2.1 + PKCE + DCR is affected the same way (Atlassian, Notion, self-hosted, and others reported in this thread). Re-authing 10+ times across a normal week of work is the steady state.

Note on the 2.1.151 fix (#47219). That fix addressed the login flow clearing stored MCP OAuth state. The failure here is a different one and persists past it: even with state not being cleared, (a) the stored entry is keyed by the ephemeral loopback callback port (per OP), and (b) the DCR client is re-registered fresh each launch rather than reused. Neither is resolved by stopping the login flow from wiping state.

Concrete ask (restating OP's, with the DCR angle):

  1. Persist the registered DCR client (client_id / client_secret) alongside the refresh token, keyed by MCP server URL — never by the ephemeral callback port. Reuse the stored client_id on the next launch instead of re-running DCR.
  2. On launch, attempt a refresh-token grant before falling back to the full auth-code flow.
  3. Use a stable loopback redirect (fixed/registered port, or omit the port from the storage key) so a different port next launch is a non-event.
  4. Make sure SSE and HTTP transports both hit the write path (per @dude84, SSE appears to skip it entirely).

Happy to provide more redacted traces (dyn_* client churn, the empty mcpOAuth keychain dump) if useful.

github-actions[bot] · 1 month ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.