Skip to content

Hosted MCP OAuth: stale DCR client_id is permanently unrecoverable — /v1/oauth/authorize returns 422 {"message":"Unrecognized client_id"} #355

Description

@BobbyWang0120

Bug report

Describe the bug

Once a dynamically-registered OAuth client for the hosted MCP server (https://mcp.supabase.com/mcp) stops being recognized by the authorization server, the client is permanently stuck and cannot recover on its own. Every re-auth attempt replays the dead client_id, the browser lands on a raw JSON blob, and the server is unusable indefinitely.

What the user sees in the browser after the client opens the auth URL:

{"message":"Unrecognized client_id"}

…and claude mcp list reports:

plugin:supabase:supabase: https://mcp.supabase.com/mcp (HTTP) - ! Needs authentication

Retrying authentication reproduces the identical page every time. There is no in-product path out of this state.

To Reproduce

The trigger is a stored DCR registration that the authorization server no longer recognizes. I confirmed all three cases below with curl against the live endpoints.

1. A client_id that was never registered → 422 with a non-OAuth error body

curl -s -i "https://api.supabase.com/v1/oauth/authorize?\
client_id=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d&response_type=code&\
redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&scope=organizations%3Aread&\
state=x&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256"
HTTP/2 422
content-type: application/json; charset=utf-8

{"message":"Unrecognized client_id"}

2. A freshly registered client works immediately

curl -s -X POST "https://api.supabase.com/platform/oauth/apps/register" \
  -H "Content-Type: application/json" \
  -d '{"client_name":"probe","redirect_uris":["http://localhost:54545/callback"],
       "grant_types":["authorization_code","refresh_token"],"response_types":["code"],
       "token_endpoint_auth_method":"client_secret_post","scope":"organizations:read projects:read"}'

201, and that client_id immediately returns 303 → https://supabase.com/dashboard/authorize?auth_id=… as expected. So DCR itself is healthy.

3. My actual stored registration is the one that died

My MCP client had two cached registrations for the same serverUrl (https://mcp.supabase.com/mcp). Replaying each against /v1/oauth/authorize:

stored client_id result
6e1463bb-9600-4640-947d-c1eb4fa2c239 303 — still valid
07a99c22-a701-4af5-9df7-3f59e4a3d2c8 422 {"message":"Unrecognized client_id"}

(Client IDs are not secrets; the corresponding client_secrets are withheld. Feel free to look up 07a99c22-… on your side — that record should show whatever happened to it.)

So the failing state is not a malformed request from the client. It is a previously-valid, server-issued registration that the authorization server later stopped recognizing.

Expected behavior

Two separate things are broken here, and the second is what makes it unrecoverable.

1. Registrations should not silently stop being recognized

The DCR response advertises a non-expiring credential:

{
  "id": "",
  "client_id": "",
  "client_secret": "",
  "client_secret_expires_at": 0,
  "redirect_uris": ["http://localhost:54545/callback"]
}

Per RFC 7591 §3.2.1, client_secret_expires_at: 0 means "will not expire." Clients cache the registration on that promise. If these registrations are in fact pruned, GC'd, or invalidated after some period, that contract is being broken — and clients have no way to know it happened.

Either registrations should genuinely persist, or client_secret_expires_at should advertise the real lifetime so clients can re-register before it lapses.

2. The error must be machine-readable so clients can self-heal

This is the core issue. 422 {"message":"Unrecognized client_id"} is neither a human-facing page nor an OAuth-conformant error — so it helps nobody:

  • For the user: they get a bare JSON blob on a white page. No explanation, no "reconnect" link, no indication that the fix is to re-authorize.
  • For the client: there is no error field. RFC 6749 §5.2 defines invalid_client for exactly this condition, and the MCP authorization spec builds on RFC 7591 DCR. A client that received error: "invalid_client" could discard the dead registration, re-run DCR, and recover transparently. Given a bespoke {"message": …} body, it cannot distinguish "your stored registration is gone, re-register" from any other failure — so it retries the same dead client_id forever.

I recognize that RFC 6749 §4.1.2.1 correctly says the AS MUST NOT redirect to an unvalidated redirect_uri and SHOULD instead inform the resource owner. Not redirecting is right. But the current response satisfies neither half of that: it doesn't inform the user in any usable way, and it doesn't give the client a standard error code to act on.

Suggested fix, roughly in priority order:

  1. Return a standard OAuth error code (invalid_client) alongside the message, so MCP clients can detect a dead registration and re-register automatically. This alone turns a permanent dead end into a self-healing hiccup.
  2. Render an actual HTML error page for the /authorize browser entry point explaining what happened and linking back to re-authorization, instead of serving raw JSON to a human.
  3. Make registration lifetime honest — either persist registrations as client_secret_expires_at: 0 promises, or advertise the real expiry.
  4. Consider returning registration_client_uri + registration_access_token (RFC 7592) so clients can verify a stored registration is still live before starting a user-facing browser flow.

Additional context

Environment

MCP server https://mcp.supabase.com/mcp (hosted, "Supabase MCP (Beta)")
Authorization server https://api.supabase.com
Client Claude Code 2.1.223 (official supabase plugin 0.1.13), macOS 15 / arm64
Redirect URI http://localhost:3118/callback

Discovery itself is fine — /.well-known/oauth-protected-resource/mcp and /.well-known/oauth-authorization-server both resolve correctly, and the 401 challenge on /mcp carries a proper WWW-Authenticate header with resource_metadata. The failure is confined to replaying a stale client_id at /v1/oauth/authorize.

Workaround for anyone else hitting this: the stale registration is cached client-side, so you have to clear it. In Claude Code the cached entry lives in the OS keychain (Claude Code-credentialsmcpOAuthplugin:supabase:supabase|…); removing that server's entry and re-authenticating forces a fresh DCR registration, which works. Not something a typical user will discover on their own — hence this report.

Possibly related to #177, which reports a different non-standard {"message": …} error shape from the same OAuth server.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions