[BUG] MCP OAuth with multiple terminals open causes re-auth
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
- Add an HTTP MCP server with OAuth:
claude mcp add --transport http my-server https://my-mcp-server.example.com/mcp
- Open Claude Code in terminal A, run
/mcp, complete the browser OAuth flow. - Verify tools are available and working.
- 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
- Open Claude Code in terminal B (new terminal, same machine).
- Observe: terminal B immediately asks for re-authentication despite the token having a
TTL of days or weeks.
- 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:
- 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.
- 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.
- 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:
- Retrieve the stored entry by
serverName|serverUrl - Reuse the stored
clientIdfrom that entry for subsequent requests (enabling proper
stateful session continuity as a bonus)
- 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, nomcp-remoteproxy) - 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_
11 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
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.
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:
~/.config/mcp-stdio/tokens.json, mode0o600, one entry per server URL (includingclient_id,client_secret, endpoint URLs, refresh token).Claude Code sees this as a plain stdio server, so the
mcpOAuthKeychain /.credentials.jsonpath is never touched for servers routed throughmcp-stdio— no orphaned entries accumulate either.Hitting this daily with the hosted Atlassian MCP (
https://mcp.atlassian.com/v1/mcp,type: httpin~/.claude.json). Re-auth prompt every 2–6 hours despite a valid refresh token. Claude Code2.1.119on 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 itemClaude Code-credentials, account =$USER, holding one JSON blob for all MCP OAuth servers: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.
claudeprocesses that have Atlassian access (teammate-mode tmux makes this trivial; I regularly have 5+:ps aux | rg '^[^ ]+ +\d+.* claude ' | wc -l→ 5).mcpOAuthblob back to keychain.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.
serverName|serverUrl.invalid_grant).Closed #28262 is the earlier Atlassian surface of the same family.
Workarounds that don't work.
"Authorization": "Bearer <PAT>"inheaders— Claude Code doesn't expand${ENV}/keychain refs in HTTP-MCP headers, andmcp.atlassian.comrejects static bearers anyway (OAuth-only).envFile/env— stdio-only, not available fortype: http.Happy to test a patch against a prerelease if useful.
Is there anyone from Anthropic on this? It's so annoying..
I'm also running into this several times a day.
Adding evidence from a self-hosted MCP server (Python
mcpSDK withOAuthAuthorizationServerProvider, 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-credentials→mcpOAuthfor 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:No
mcpOAuthkey, 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_idis minted and stored server-side. After three launches my server's auth-status reports: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.1loopback 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
client_idfor non-DCR providers).+1 to this issue being super annoying.
@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.
+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
type: http, registered in a project.mcp.jsonas{ "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 →
/mcpshows 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 differentlocalhost:<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 priorclient_id+refresh_token. The server accumulates one orphaneddyn_*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. NomcpOAuthkey, no per-server entry.~/.claude.json(projectmcpServersblock has the URL/transport only — noclient_id,refresh_token, orregistration).*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):
client_id/client_secret) alongside the refresh token, keyed by MCP server URL — never by the ephemeral callback port. Reuse the storedclient_idon the next launch instead of re-running DCR.Happy to provide more redacted traces (
dyn_*client churn, the emptymcpOAuthkeychain dump) if useful.Closing for now — inactive for too long. Please open a new issue if this is still relevant.