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

Open 💬 10 comments Opened Apr 3, 2026 by tpjg

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 ↗

This issue has 10 comments on GitHub. Read the full discussion on GitHub ↗