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:
- The previously-issued
client_id on the authorization server (it now has zero live tokens but stays on disk forever).
- 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:
- Session N:
authenticate → DCR → client_A, code exchange returns access_A + refresh_A (rotation-eligible).
- 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.
- 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.
- Repeat forever.
Reproduction
Self-hosted MCP server implementing OAuth 2.1 + RFC 7591 DCR + RFC 9728:
/mcp authenticate example — completes successfully, Keychain stores clientId=mcp_client_AAAA, clientSecret=..., accessToken=..., refreshToken=....
- Wait until access_token expires (or kill the session).
/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.
- 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
- 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.
- 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.
- 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.
- 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
Summary
When Claude Code's MCP OAuth client runs the
authenticateflow for a self-hosted MCP server, it always performs a fresh Dynamic Client Registration (POST /oauth/register) — minting a newclient_id/client_secret— even though it already has a perfectly good DCR-issuedclientIdandclientSecretpersisted in Keychain (underClaude Code-credentials → mcpOAuth.<serverName>|<urlHash>) from a previous successful auth.This silently orphans:
client_idon the authorization server (it now has zero live tokens but stays on disk forever).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:
authenticate→ DCR →client_A, code exchange returnsaccess_A+refresh_A(rotation-eligible).refresh_A2(in memory) but client never writes the rotated RT back to Keychain → Keychain still has the now-revokedrefresh_A.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.Reproduction
Self-hosted MCP server implementing OAuth 2.1 + RFC 7591 DCR + RFC 9728:
/mcp authenticate example— completes successfully, Keychain storesclientId=mcp_client_AAAA,clientSecret=...,accessToken=...,refreshToken=..../mcp authenticate exampleagain. Observed: Claude Code callsPOST /oauth/registerand gets a brand newclient_id=mcp_client_BBBB, ignoring the storedmcp_client_AAAA.Expected
If
mcpOAuth.<serverName>|<urlHash>.clientIdis already populated and the auth-server discovery URL hasn't changed, Claude Code should:clientId/clientSecretand skipPOST /oauth/registerentirely.POST /oauth/registeronly if the discovery URL changed, OR if a token endpoint returnsinvalid_clientfor 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/registerlog over 48h on a single MCP server, only one human user, only one Mac: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
oauth_clientsrow. After 30 sessions, the server has 30 orphan clients with no tokens. Manual cleanup required.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:The "force" branch fires when the stored client is rejected (
invalid_clienton the token endpoint) or when the auth server URL has changed. Same protocol contract, no fresh registration per session.Environment
Related