MCP OAuth: Claude Code re-runs DCR on every authenticate, orphaning the previously-issued client_id and its refresh_token
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_idon 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 returnsaccess_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-revokedrefresh_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:
// ~/.claude.json
{
\"mcpServers\": {
\"example\": { \"type\": \"http\", \"url\": \"https://example.com/mcp\" }
}
}
/mcp authenticate example— completes successfully, Keychain storesclientId=mcp_client_AAAA,clientSecret=...,accessToken=...,refreshToken=....- Wait until access_token expires (or kill the session).
/mcp authenticate exampleagain. Observed: Claude Code callsPOST /oauth/registerand gets a brand newclient_id=mcp_client_BBBB, ignoring the storedmcp_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/clientSecretand skipPOST /oauth/registerentirely. - Re-run
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/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_clientsrow. 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
- #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)
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗