Anyone Can Register a Client: MCP's Open DCR Problem, and What CIMD Fixes
9 min read Fullmakt Team
- mcp
- authentication
- credentials
- governance
- agentic-actions
MCP’s authorization spec solves a real problem: the ecosystem of MCP clients
is unbounded, so a server can’t pre-provision every client the way a
traditional OAuth app registry does. Its answer is Dynamic Client
Registration — RFC 7591 — and its default
implementation is anonymous. POST /oauth/register with a client_name and
a redirect_uris array, and the server hands back a client_id, no
questions asked. That’s not a bug in any single server’s implementation.
It’s the spec working as designed, and it’s also the reason the name on
your consent screen has never meant what it looked like it meant.
The failure: the name on the consent screen is a string the registrant typed
Walk through what actually happens when a user approves an MCP connector. The authorization page shows a client name — “Claude Code wants access to your workspace,” or whatever the registration called itself — and a redirect URI. The user reads that name, decides it looks legitimate, and approves. Nothing in the open-registration flow verified that name against anything. It’s a field in a JSON body a script sent five seconds earlier, and RFC 7591 places no requirement on the authorization server to check it against the party actually holding the private key on the other end of that redirect — because there isn’t one. A dynamically registered public client has no client secret to hold.
That gap is exploitable in the most direct way possible: register a client
named “Claude Code” or “Slack Connector” pointing at a redirect_uri you
control, send a user your authorization link, and collect the code when
they approve what looks like a familiar consent screen. This isn’t a
spoofed Agent Card or a
poisoned tool description
— those attacks compromise something a user already trusted. This one
never required compromising anything, because open DCR was never asking
the registrant to prove an identity in the first place. It was asking for
a name, and it got one.
Why this is worse for agents than it ever was for browser OAuth
Third-party app impersonation isn’t new to OAuth — phishing pages that mimic a real app’s consent screen predate MCP by over a decade. What’s different here is who’s on the other end of the approval. A human clicking “Allow” on a browser consent screen for a bank or an email provider has years of learned skepticism about mismatched domains and unfamiliar app names. An agent driving the same flow programmatically, or a user rapidly approving a connector because an assistant told them to, brings none of that hesitation. The consent step was designed as a human checkpoint; agentic workflows are actively trying to remove the human from as many of those checkpoints as possible, which means the one signal open DCR gives you to catch impersonation — a suspicious human reading the screen carefully — is disappearing exactly as the risk grows.
It also compounds every other credential problem this series has covered. A phished authorization code redeems for a refresh token an attacker can hold indefinitely, minting fresh access tokens long after the phishing page is gone. The victim’s audit trail shows a legitimately-issued token making calls — because it is one — with no record anywhere that the client it was issued to was never who the consent screen said it was.
Why “just check the client_name looks right” doesn’t scale
The instinctive fix — have the server or the user eyeball the registered
name for anything suspicious — doesn’t hold up, because client_name is
exactly as trustworthy as any other field in a self-submitted JSON body: as
trustworthy as the registrant wants it to be. A denylist of known-bad names
catches yesterday’s impersonation attempt and nothing that renames itself
tomorrow. The problem isn’t that reviewers aren’t looking hard enough at the
name. It’s that the name was never bound to anything a server can verify —
there’s no proof-of-possession step tying “Claude Code” the string to
claude.ai the domain.
Where the fix actually has to live
- Bind client identity to something the registrant has to actually
control, not a string they typed. A
client_idthat is itself an HTTPS URL — a Client ID Metadata Document, or CIMD, from the current MCP authorization spec — forces the claim and the proof into the same place. Claiming to behttps://claude.ai/oauth/claude-code-client-metadatameans the metadata has to actually be served fromclaude.ai, which is a domain the impersonator doesn’t control. - Validate the fetched document against the URL it came from, not just
parse it — the
client_idfield inside the document has to equal the URL it was fetched from, or the whole point of binding identity to a domain is undone by trusting whatever the document claims about itself. - Keep anonymous DCR available, but don’t let it carry the trust
signal. Long-tail clients that don’t publish a metadata document still
need a way to register. That path can stay open without pretending its
client_namefield means anything more than “unverified label the registrant chose.” - Reap stale registrations instead of letting them accumulate indefinitely. A client row with no live refresh token and no recent activity is either abandoned or was never legitimate — either way, it shouldn’t sit in the store as a standing target for confusion with a real, active integration.
- Warn on the properties that actually correlate with abuse — a redirect URI that isn’t the loopback address a native client would use, a client requesting a token-endpoint auth method inconsistent with a public client — rather than relying on a human reading a name field under time pressure.
The business case: Fullmakt’s login handshake doesn’t trust a typed name
Fullmakt’s own MCP OAuth surface (src/Fullmakt.Api/OAuth) implements both
paths side by side, and the split is deliberate. POST /oauth/register
stays open and anonymous, because refusing to support DCR at all would
break every client too small to publish its own metadata document. But
OAuthClientResolver checks first whether a client_id is a plain HTTPS
URL — no userinfo, no fragment, no query string — and if it is, resolves
the client by fetching that URL’s CIMD document instead of trusting a row
some anonymous caller inserted into the database. That’s how Claude Code
and ChatGPT identify themselves against Fullmakt today: their client_id
is https://claude.ai/oauth/claude-code-client-metadata, and the
document at that address has to name itself as that exact URL or the
resolver rejects it outright. Impersonating that client on Fullmakt’s
consent screen means serving a matching document from claude.ai, not
filling in a form field.
The rest of the handshake is built around the same assumption — that nothing about who’s asking should be taken on the asker’s word:
- The consent page names the actual redirect host, and flags loopback
redirects explicitly, so the one thing a user is asked to verify is
something the registrant can’t relabel the way they can a
client_name. - Stale registrations get reaped, not retained indefinitely — a connector client with no live refresh token and no recent activity is swept out on a schedule, so the store of “known clients” doesn’t quietly fill up with abandoned or opportunistic rows that a future review has to sort real integrations out from.
- A connector token is confined to exactly
/mcp, whatever name the client that requested it used — so even a client that talks its way past the consent screen inherits none of the standing reach a compromised identity would need to matter. It still has to go through scoped credential dispensing and policy evaluation for every call it makes after that, the same as any other agent. - Every token issued through this flow lands in the same audit trail as every other credentialed call Fullmakt brokers — so if impersonation ever does get past the identity check, what it did next is still fully reconstructable, not a gap in the record.
The commercial logic is consistent with the rest of this series: a login handshake that asks a human, an agent, or a consent screen to take an unverified claim at face value is a handshake that fails exactly when someone decides to lie to it. Anchoring client identity at a domain the registrant has to actually control — and backstopping everything after that with scoped credentials and one audit trail — means the point of failure moves from “did anyone notice the name looked off” to something a server can check by itself, every time, without a human in the loop to fool.
FAQ
Is open Dynamic Client Registration a bug in the MCP spec?
No — it’s a deliberate design choice to support an unbounded ecosystem of
clients without requiring manual provisioning for each one. The problem
isn’t that DCR exists; it’s treating the client_name it produces as an
identity claim worth trusting on a consent screen.
What is CIMD, concretely?
A Client ID Metadata Document. Instead of registering and getting back an
opaque client_id, the client’s client_id is an HTTPS URL, and the
authorization server fetches a JSON document from that URL describing the
client — name, redirect URIs, auth method. The claim and the proof are the
same request.
Does CIMD replace Dynamic Client Registration entirely?
Not necessarily — servers can support both, resolving by URL shape: an
HTTPS client_id goes through CIMD, anything else through the traditional
registration store. Clients too small to host their own metadata document
still need an anonymous-registration path to work at all.
Can a CIMD document itself be spoofed?
Only by controlling the domain it’s served from. The validation step that
matters is checking the document’s own client_id field equals the URL it
was fetched from — skip that check and you’re back to trusting a
self-asserted claim, just wrapped in an extra HTTP request.
Does this fix phishing consent screens generally, or just the client name? Just the client identity claim. A user still has to read the redirect host and the requested scopes correctly. What CIMD removes is the easiest lie in the flow — the display name — by tying it to something the authorization server can independently verify instead of something the registrant typed.
A login handshake is supposed to answer “who is asking.” Open Dynamic Client Registration answers a different question — “what did the asker type in a form” — and until a server checks the difference, every consent screen built on top of it is trusting a label, not an identity.