# MCP dropped DCR for CIMD - and handed you an SSRF

Source: https://gofranz.com/blog/mcp-dcr-deprecated-cimd/

I've been wiring MCP authorization into [Forseti](https://github.com/franzos/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](https://blog.modelcontextprotocol.io/posts/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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1296)), 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](https://blog.modelcontextprotocol.io/posts/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](https://modelcontextprotocol.io/specification/2026-07-28/changelog), and the same revision adopted a formal [feature lifecycle policy](https://modelcontextprotocol.io/community/feature-lifecycle) 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](https://datatracker.ietf.org/doc/html/rfc7591)) | CIMD ([draft-ietf-oauth-client-id-metadata-document-00](https://datatracker.ietf.org/doc/html/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:

```json
{
  "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](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00#name-server-side-request-forgery): "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](https://github.com/backstage/backstage/issues/33951) 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](https://github.com/openai/codex/issues/15818) has an open issue exactly on that, and [Claude Code](https://github.com/anthropics/claude-code/issues/67258) 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.
