Skip to content

Franz Franz

goFranz // personal journal all writing
Opinion · mcp

MCP dropped DCR for CIMD - and handed you an SSRF

The 2026-07-28 MCP spec deprecates Dynamic Client Registration in favour of Client ID Metadata Documents. What changes, why, and what you have to guard now that your authorization server fetches URLs an attacker picks.

I’ve been wiring MCP authorization into Forseti for a while, which meant running an unauthenticated POST /oauth2/register endpoint in front of Hydra and rate-limiting it hard enough that nobody could fill the database with junk. That endpoint is gone now. The MCP spec revision 2026-07-28 formally deprecates Dynamic Client Registration in favour of Client ID Metadata Documents, and Forseti 0.2.0 dropped DCR entirely.

The short version: DCR made the authorization server hold state for every client that ever showed up. CIMD flips it - the client hosts a JSON file, the URL of that file is the client ID, and the AS fetches it on demand. Which is better, and also how you get a server-side request forgery hole in your authorization server.

Why DCR is going away

DCR (RFC 7591) arrived with MCP’s first authorization spec in 2025-03-26, which said implementations SHOULD support it and, further down, “strongly recommend” it. For eight months it was the only zero-touch option: an MCP client that’s never met your server POSTs its metadata to /register, gets back a client_id, and runs the normal authorization code flow.

CIMD arrived in 2025-11-25 (SEP-991), and the same revision dropped DCR to MAY, “included for backwards compatibility with earlier versions of the MCP authorization spec”. 2026-07-28 finished the job: the formal Deprecated.

The reasons were laid out well before that, in an August 2025 writeup on client registration by Paul Carleton - an exploratory post, not the announcement, but it’s where the problems are stated plainly:

  • “Unbounded database growth: Every time a user connects a client to an MCP server, a new registration is created with the authorization server unless the client already has one.” Registrations are per-install, so the same app on your laptop and your desktop is two rows.
  • “There’s no way to tell a client that its ID is invalid without creating an open redirect vulnerability.” So registrations effectively never expire.
  • “An unauthenticated /register endpoint writes to a database within the authorization server, meaning that tenant admins now need to worry about rate limiting or policy controls.” That’s the part that kept me writing rate limiters.

The deprecation itself is in the 2026-07-28 changelog, and the same revision adopted a formal feature lifecycle policy with a twelve-month minimum deprecation window - shortenable to no less than ninety days, but only for a vulnerability with a published advisory or documented exploitation and no in-place mitigation. So DCR keeps working for at least a year, for backwards compatibility with authorization servers that don’t do CIMD yet. It just isn’t the answer anymore.

The two side by side

  DCR (RFC 7591) CIMD (draft-ietf-oauth-client-id-metadata-document-00)
client_id opaque string the AS mints the HTTPS URL of your metadata document
Registration step POST /register before the flow none
Who stores what AS writes a row per registration, with no expiry mechanism client hosts a JSON file; AS caches it
Portable across servers no - one registration per AS yes, the same client_id everywhere
Client auth client secrets public by default; register a JWKS and private_key_jwt makes it confidential
Discovery registration_endpoint in AS metadata client_id_metadata_document_supported: true
Main attack surface unauthenticated write endpoint: spam, storage DoS outbound fetch of an attacker-chosen URL: SSRF
Killing a bad client can’t signal expiry without an open redirect edit or pull the document - though caches lag
MCP status deprecated 2026-07-28, removal no sooner than 12 months SHOULD support, for clients and authorization servers

The document itself is simple:

{
  "client_id": "https://app.example.com/oauth/client-metadata.json",
  "client_name": "Example MCP Client",
  "redirect_uris": ["http://127.0.0.1:3000/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

client_id MUST equal the URL it was fetched from, exactly. Worth being precise about what that buys you: it stops me hosting a copy of your document at my URL. It does nothing to stop me handing the authorization server your URL and claiming to be you - that attack is alive and well, and I’ll come back to it.

The part that bites: SSRF

Here’s what actually changed for the server operator. Under DCR, an attacker sends you data. Under CIMD, an attacker sends you a URL, and your authorization server - which sits inside your network, holding your signing keys - goes and fetches it. That’s textbook SSRF, and the draft says so in section 6.5: “Authorization servers fetching the client metadata document and resolving URLs located in the metadata document should be aware of possible SSRF attacks.” It follows up with the actual instruction - servers SHOULD “avoid fetching any URLs using private or loopback addresses and consider network policies or other measures to prevent making requests to these addresses”, and SHOULD watch for non-HTTP URI schemes. (Section numbers are for -00, the revision MCP normatively cites; in the current -02 the security considerations moved to section 8.)

MCP’s own text wraps that in a MUST-consider (“authorization servers MUST consider the security implications detailed in [Section 6]”) while the SSRF bullet itself stays a SHOULD. So the obligation to think about it is hard; the specific defences are yours to pick. This is what Forseti’s fetcher does, and it’s roughly the list I’d want from anything else:

  • HTTPS only, with the URL capped at 512 bytes. Plain http is allowed only when you explicitly flip allow_private_targets for local development. (The spec also requires the client_id URL to carry a path component; Forseti doesn’t enforce that one yet.)
  • Reject loopback, private, and special-use IPs before the request goes out - and again at connect time through a guarded DNS resolver, because a DNS answer that’s public on the first lookup and 169.254.169.254 on the second is the whole rebinding trick.
  • Don’t follow redirects at all. Forseti sets the redirect policy to none. Following one means re-running every check on the new target; not following is simpler and I haven’t missed it.
  • Cap the body and the clock. 64 KiB and a 5s timeout. The draft suggests 5 KB; I went wider because logo and scope fields add up, and 64 KiB is still far below anything that hurts.
  • Cache, with bounds. Respect HTTP cache headers but clamp the TTL to 60s..24h, single-flight concurrent fetches for the same URL, and serve a last-good document for up to an hour if the fetch fails. Never cache errors or malformed documents.
  • Don’t let the client name itself. client_name is whatever the client wrote about itself, so on Forseti’s consent page the host of the client_id URL is the primary identity and the self-asserted name sits underneath it.

There’s an optional host allowlist ([oauth.cimd].allowed_client_hosts) for anyone who wants a closed set. Empty means open, gated by consent, which is the setting that makes MCP work the way it’s supposed to.

The failure mode of over-blocking is real too: Backstage hit it when a Kubernetes deployment’s own hostname resolved to a 10.x address and the private-IP check rejected a self-referential fetch. Granted, that’s an argument for a deliberate escape hatch, not for loosening the default.

The impersonation hole that’s left

That consent-screen design covers one attack and misses the more interesting one. Showing the client_id host is the right answer to a client lying in its client_name. It does nothing about the attack the spec spells out:

Client ID Metadata Documents cannot prevent localhost URL impersonation by themselves. An attacker can claim to be any client by: 1. Providing the legitimate client’s metadata URL as their client_id 2. Binding to any localhost port, and providing that address as the redirect_uri 3. Receiving the authorization code via the redirect when the user approves

Read that carefully. The client_id host in that attack is the legitimate one - it’s the real client’s document, fetched from the real client’s domain, and it passes every check in the list above. As the spec puts it: “The server will see the legitimate client’s metadata document and the user will see the legitimate client’s name, making attack detection difficult.” The only field that differs is the redirect URI, which is why the spec’s requirement there is a MUST: authorization servers “MUST clearly display the redirect URI hostname during authorization.”

Where my things stand

Forseti is the authorization-server side, so it took the whole change. Since 0.2.0, MCP clients connect with a URL client ID and no registration step; DCR and initial access tokens are removed rather than deprecated in place. On a first authorization request Forseti fetches and validates the document, then upserts a Hydra client row keyed to that URL, so Hydra keeps doing the OAuth work while the identity stays the client’s own URL. The scope set on that row is a ceiling, never a grant - consent is still the authorization.

Stackpit needed nothing, and that’s the more interesting half. It’s a resource server: its MCP endpoint is bearer-only, it publishes /.well-known/oauth-protected-resource/mcp per RFC 9728, and it validates audience, nbf, and scopes on the token. How the client got its client_id is entirely the authorization server’s problem. That separation is the quiet win of the RFC 9728 split - a registration mechanism can be deprecated out from under you and your resource servers don’t notice.

Should you move

If you run an authorization server for MCP clients: yes, but you’ve got a year, and the caveat is that not every client is there yet. Some still hard-require a registration_endpoint and fall over without one - Codex has an open issue exactly on that, and Claude Code has one for a related case where a pre-configured clientId still triggers a DCR attempt.

I dropped DCR from Forseti anyway because support is catching up fast.

If you run an MCP server that’s only a resource server, this isn’t your problem at all.

And note what you’re building on: an active Internet-Draft, currently at -02 (July 2026), while MCP still normatively cites -00. Section numbers have already moved between them, so check which one your implementation is reading. Nothing in CIMD attests that the name and logo in a document belong to whoever wrote them either - the draft has suggestions (prefetch the logo so you can moderate it, weigh domain age and reputation, warn on new domains), but they’re all things you build, not things the protocol gives you. It’s a better shape than DCR, not a solved problem.