claude-code - 💡(How to fix) Fix MCP OAuth: Claude Code re-runs DCR on every authenticate, orphaning the previously-issued client_id and its refresh_token

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

When Claude Code's MCP OAuth client runs the authenticate flow for a self-hosted MCP server, it always performs a fresh Dynamic Client Registration (POST /oauth/register) — minting a new client_id / client_secret — even though it already has a perfectly good DCR-issued clientId and clientSecret persisted in Keychain (under Claude Code-credentials → mcpOAuth.<serverName>|<urlHash>) from a previous successful auth.

This silently orphans:

  1. The previously-issued client_id on the authorization server (it now has zero live tokens but stays on disk forever).
  2. Any refresh_token bound to the old client_id — server-side per RFC 6749 §6, refresh_tokens are scoped to their issuing client, so the new client can never use them.

Combined with refresh-token rotation on the server side, this creates a forced re-auth loop:

  1. Session N: authenticate → DCR → client_A, code exchange returns access_A + refresh_A (rotation-eligible).
  2. Session N+1 starts, access_A expired. Refresh works once. Server rotates → refresh_A2 (in memory) but client never writes the rotated RT back to Keychain → Keychain still has the now-revoked refresh_A.
  3. Session N+2: Keychain has refresh_A, server rejects (revoked), Claude Code triggers full re-auth → another DCR → client_B. Now both client_A and client_B exist server-side; client_A is orphaned.
  4. Repeat forever.

Root Cause

When Claude Code's MCP OAuth client runs the authenticate flow for a self-hosted MCP server, it always performs a fresh Dynamic Client Registration (POST /oauth/register) — minting a new client_id / client_secret — even though it already has a perfectly good DCR-issued clientId and clientSecret persisted in Keychain (under Claude Code-credentials → mcpOAuth.<serverName>|<urlHash>) from a previous successful auth.

This silently orphans:

  1. The previously-issued client_id on the authorization server (it now has zero live tokens but stays on disk forever).
  2. Any refresh_token bound to the old client_id — server-side per RFC 6749 §6, refresh_tokens are scoped to their issuing client, so the new client can never use them.

Combined with refresh-token rotation on the server side, this creates a forced re-auth loop:

  1. Session N: authenticate → DCR → client_A, code exchange returns access_A + refresh_A (rotation-eligible).
  2. Session N+1 starts, access_A expired. Refresh works once. Server rotates → refresh_A2 (in memory) but client never writes the rotated RT back to Keychain → Keychain still has the now-revoked refresh_A.
  3. Session N+2: Keychain has refresh_A, server rejects (revoked), Claude Code triggers full re-auth → another DCR → client_B. Now both client_A and client_B exist server-side; client_A is orphaned.
  4. Repeat forever.

Fix Action

Fix / Workaround

Workarounds

Code Example

// ~/.claude.json
{
  \"mcpServers\": {
    \"example\": { \"type\": \"http\", \"url\": \"https://example.com/mcp\" }
  }
}

---

mcp_client_HzpTW59SRgpP-CkO3th  2026-05-14 11:47:26
mcp_client__nyNwtiOQ7q6yRiG22u  2026-05-14 11:48:16
mcp_client_9v64hQw6jnitSJcZxjC  2026-05-14 11:52:34  Claude Code (scalarly-memory)
mcp_client_rs80IIyAbpeZe_PWnO-  2026-05-14 11:55:55  Claude Code (scalarly-memory)
mcp_client_D6bFpKkwKK9OW8pKkWI  2026-05-14 11:57:43  Claude Code (scalarly-memory)
...30+ rows over 48h, all with client_name='Claude Code (...)', all but the latest with zero live tokens.

---

scalarly-memory|<hash>:
    serverName: \"scalarly-memory\"
    serverUrl: \"https://example.com/mcp\"
    discoveryState: {authorizationServerUrl: \"...\", oauthMetadataFound: true}
-   clientId: \"mcp_client_AAAA\"
-   clientSecret: \"...\"
-   accessToken: \"<expired>\"
+   clientId: \"mcp_client_BBBB\"   # ← fresh DCR despite AAAA being valid
+   clientSecret: \"...\"
+   accessToken: \"<fresh>\"
+   refreshToken: \"<fresh>\"
+   expiresAt: <now+3600s>
+   scope: \"read write admin\"

---

if keychain.mcpOAuth[serverKey].clientId and not force_re_register:
    use persisted clientId+clientSecret, skip DCR
else:
    POST /oauth/register, store result
RAW_BUFFERClick to expand / collapse

Summary

When Claude Code's MCP OAuth client runs the authenticate flow for a self-hosted MCP server, it always performs a fresh Dynamic Client Registration (POST /oauth/register) — minting a new client_id / client_secret — even though it already has a perfectly good DCR-issued clientId and clientSecret persisted in Keychain (under Claude Code-credentials → mcpOAuth.<serverName>|<urlHash>) from a previous successful auth.

This silently orphans:

  1. The previously-issued client_id on the authorization server (it now has zero live tokens but stays on disk forever).
  2. Any refresh_token bound to the old client_id — server-side per RFC 6749 §6, refresh_tokens are scoped to their issuing client, so the new client can never use them.

Combined with refresh-token rotation on the server side, this creates a forced re-auth loop:

  1. Session N: authenticate → DCR → client_A, code exchange returns access_A + refresh_A (rotation-eligible).
  2. Session N+1 starts, access_A expired. Refresh works once. Server rotates → refresh_A2 (in memory) but client never writes the rotated RT back to Keychain → Keychain still has the now-revoked refresh_A.
  3. Session N+2: Keychain has refresh_A, server rejects (revoked), Claude Code triggers full re-auth → another DCR → client_B. Now both client_A and client_B exist server-side; client_A is orphaned.
  4. Repeat forever.

Reproduction

Self-hosted MCP server implementing OAuth 2.1 + RFC 7591 DCR + RFC 9728:

// ~/.claude.json
{
  \"mcpServers\": {
    \"example\": { \"type\": \"http\", \"url\": \"https://example.com/mcp\" }
  }
}
  1. /mcp authenticate example — completes successfully, Keychain stores clientId=mcp_client_AAAA, clientSecret=..., accessToken=..., refreshToken=....
  2. Wait until access_token expires (or kill the session).
  3. /mcp authenticate example again. Observed: Claude Code calls POST /oauth/register and gets a brand new client_id=mcp_client_BBBB, ignoring the stored mcp_client_AAAA.
  4. Server-side: 2 client records, only the new one has a refresh_token.

Expected

If mcpOAuth.<serverName>|<urlHash>.clientId is already populated and the auth-server discovery URL hasn't changed, Claude Code should:

  • Reuse the persisted clientId / clientSecret and skip POST /oauth/register entirely.
  • Re-run POST /oauth/register only if the discovery URL changed, OR if a token endpoint returns invalid_client for the stored client (i.e. the client was revoked / DB wiped server-side).

This matches MCP spec §6.1.4 ("The client SHOULD store the client_id ... and reuse it for subsequent authorization flows") and how every well-behaved OAuth 2.1 client (Postman, VS Code's MCP client, Cline, etc.) handles DCR persistence.

Evidence

Server-side /oauth/register log over 48h on a single MCP server, only one human user, only one Mac:

mcp_client_HzpTW59SRgpP-CkO3th  2026-05-14 11:47:26
mcp_client__nyNwtiOQ7q6yRiG22u  2026-05-14 11:48:16
mcp_client_9v64hQw6jnitSJcZxjC  2026-05-14 11:52:34  Claude Code (scalarly-memory)
mcp_client_rs80IIyAbpeZe_PWnO-  2026-05-14 11:55:55  Claude Code (scalarly-memory)
mcp_client_D6bFpKkwKK9OW8pKkWI  2026-05-14 11:57:43  Claude Code (scalarly-memory)
...30+ rows over 48h, all with client_name='Claude Code (...)', all but the latest with zero live tokens.

Keychain snapshot of an established slot (redacted) BEFORE and AFTER a fresh authenticate:

  scalarly-memory|<hash>:
    serverName: \"scalarly-memory\"
    serverUrl: \"https://example.com/mcp\"
    discoveryState: {authorizationServerUrl: \"...\", oauthMetadataFound: true}
-   clientId: \"mcp_client_AAAA\"
-   clientSecret: \"...\"
-   accessToken: \"<expired>\"
+   clientId: \"mcp_client_BBBB\"   # ← fresh DCR despite AAAA being valid
+   clientSecret: \"...\"
+   accessToken: \"<fresh>\"
+   refreshToken: \"<fresh>\"
+   expiresAt: <now+3600s>
+   scope: \"read write admin\"

Impact

  1. Forced re-auth loop: combined with server-side refresh-token rotation (which is recommended by RFC 6749 §10.4), users SSO every session. Many self-hosted MCP servers behind Cloudflare Access / Okta / similar mean every re-auth requires an interactive browser SSO step. UX disaster.
  2. Server DB bloat: each authenticate adds a permanent oauth_clients row. After 30 sessions, the server has 30 orphan clients with no tokens. Manual cleanup required.
  3. No way to map auth events to users: server logs show "client X authorized", but X changes every session, breaking per-client analytics / audit trails.
  4. Risk vector: an attacker who compromises the auth server can't easily tell which mcp_client_* is the user's current one, but neither can the user. Token-leak triage becomes harder.

Workarounds

Server-side: disable refresh-token rotation (downgrades RFC 6749 §10.4 protection but eliminates the forced-reauth pattern).

Client-side: none — the orphaning happens regardless of any server config.

Suggested fix in Claude Code

In the MCP OAuth client path that handles authenticate:

if keychain.mcpOAuth[serverKey].clientId and not force_re_register:
    use persisted clientId+clientSecret, skip DCR
else:
    POST /oauth/register, store result

The "force" branch fires when the stored client is rejected (invalid_client on the token endpoint) or when the auth server URL has changed. Same protocol contract, no fresh registration per session.

Environment

  • Claude Code version: latest stable (Opus 4.7 main session, 2026-05-15)
  • macOS 25.4 / Darwin 25.4.0
  • MCP server: open-source mcp-memory-service (FastAPI + RFC 9728 + DCR + PKCE + refresh rotation)

Related

  • #26675 — pre-configured OAuth client credentials without DCR (complementary: that issue asks to skip DCR entirely for providers that don't support it; this issue asks to reuse DCR-issued creds when they exist)

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING