Back to Blog

Client ID Metadata Documents in Spring: CIMD instead of DCR

Client ID Metadata Documents replace Dynamic Client Registration for MCP clients. How CIMD works in Spring, and what happened when Claude Code connected.

Lukas GrigisSoftware Architect24 min read
0:00 / 0:00
oauth2mcpai-agentsspring-bootspring-aikeycloaksecurityjava
An MCP client hands a Spring Authorization Server a URL instead of registering, and the server fetches the Client ID Metadata Document behind it
On this page

Key takeaways

  • A Client ID Metadata Document is a JSON file at a URL, and that URL is the client_id. The authorization server fetches it at authorize-time instead of registering anything.
  • The document's client_id must equal the URL it was served from, which is what makes the identity self-verifying: only the URL's owner can publish it.
  • CIMD trades stored client state for a runtime dependency on someone else's host. There is no row to delete, so your trust policy is your revocation mechanism.
  • Spring has the types but no autoconfig, and DCR is on by default: cimd(true) disables nothing, only dynamicClientRegistration(false) does.
  • Claude Code publishes portless loopback callbacks but sends localhost with a random port. RFC 8252 grants the any-port rule to 127.0.0.1, not to the name localhost.

What a Client ID Metadata Document is: a client ID that is a URL

A Client ID Metadata Document (CIMD) is a JSON file an OAuth2 client publishes at an HTTPS URL, and that URL is the client's client_id. The client never registers. It asserts an identity, and your authorization server fetches the document behind that URL to read it. Nothing gets written to your database, because there is nothing to write.

A month ago I published the DCR proxy pattern: a Spring Authorization Server that adds Dynamic Client Registration in front of an IdP that can't do it. That post ended on a promise. It called itself "a DCR broker today, with a deliberate CIMD seam," and said of the CIMD path: "None of that is wired yet."

This post wires it. Same demo, same Keycloak, same MCP server, in a sibling repo you can run with one command. And the switch itself is one line:

java|authorization-server/.../configuration/WebSecurityConfiguration.java
// RFC 7591 registration is switched OFF explicitly: the library enables
// its registration endpoint by default, and this server must expose none.
mcp -> mcp.cimd(true)
    .dynamicClientRegistration(false) 
    .authorizationServer(authzServer -> {

One line to flip. Everything worth reading here is what that line forces you to build around it.

The whole client identity is one small JSON file

Here is the demo client's entire identity. Not an excerpt, every field it has:

json|support/cimd-client/client.json
{
  "client_id": "http://localhost/cimd-client/client.json",
  "client_name": "spring-oauth2-mcp-cimd demo client",
  "redirect_uris": ["http://localhost:6274/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

Look at the second line. The client_id is the URL this file is served from. The document points at itself, and that self-reference is doing all the work.

An attacker can copy this document to a host they control, but the copy now sits at a different URL, so its client_id field no longer matches where it lives. Your server rejects it. Only whoever controls http://localhost/cimd-client/client.json can publish a valid document claiming to be that client. Control of the URL is the credential, and that one property is why everything downstream gets so small.

Why CIMD exists: your MCP server has to accept clients it has never met

Classic OAuth2 pre-registration assumes a human visits a portal, fills in a form, and gets a client_id and secret before any code runs. That works fine when you know every client in advance. An MCP server usually doesn't. Someone installs your server in an editor you've never heard of, and that editor needs an OAuth2 identity before it can ask for a token.

DCR's answer was to let the client register itself at runtime. It works, and it costs you two things: a live registration endpoint that anyone can POST to, and a database row per client instance. My June repo needed a whole proxy in front of Keycloak precisely because most enterprise IdPs won't expose that endpoint at all.

The MCP revision of 2025-11-25 (the change landed as SEP-991) made CIMD a SHOULD and dropped DCR to MAY, and the reasoning is operational rather than cryptographic. On a public server, every client that ever connects writes a row, including the ones that connect once and vanish. Each row is a credential-shaped object you now own and have to expire, there's no natural garbage collection, and the write endpoint itself is an abuse target.

CIMD's answer is to skip the handshake and put identity where it's already published and already owned: on the vendor's own domain. The client already has an identity, because a URL is an identity. Your server just has to be able to read. The client identity database is the web.

DCR gives you a row you have to keep. CIMD gives you a URL you have to trust.

Worth being blunt about when you don't need any of this, though: if you only ever talk to clients you control, pre-register them and move on. Neither mechanism earns its keep. CIMD starts paying the moment you're open to clients you don't control.

What the authorization server does at authorize-time

An MCP client sends /oauth2/authorize?client_id=http://localhost/cimd-client/client.json&... and your server takes over from there. Five steps, and the javadoc in the repo names all of them:

  1. Detect. The client_id is URL-shaped, so the CIMD repository takes the request instead of the JDBC one.
  2. Fetch. It GETs the Client ID Metadata Document from that exact URL.
  3. Validate. The document's own client_id must equal the URL it came from, redirect_uris must be present and valid, and a client_secret is not allowed.
  4. Convert. What comes back becomes a public PKCE RegisteredClient.
  5. Cache. Kept for the TTL in the response's Cache-Control: max-age, or five minutes when the header says nothing.

After that fifth step the normal authorization-code flow runs as if this client had been registered for years, because as far as Spring Authorization Server is concerned, it now is a RegisteredClient like any other. It just happens to be one that will evaporate in five minutes, and its redirect_uri is matched exactly against the list in the document that just arrived.

Resolve, validate, use, discard. The client is never registered, and the registration table stays empty through the whole sequence.

The three kinds of "auth" people conflate here

This is where CIMD gets misread, so it's worth separating three questions that sound like one.

Is the client authenticated? No. Not at all. The document says "token_endpoint_auth_method": "none", which makes this a public client with no secret. There's nothing to authenticate with, and that's deliberate: a client that ships to thousands of laptops can't hold a secret anyway. What carries the weight instead is PKCE, plus the exact match of the request's redirect_uri against the list in the fetched document. A hostile party can start a flow. They can't get the code delivered anywhere the real client's document doesn't already name.

Is the user authenticated? Yes, and this half didn't change at all. The login federates upstream to Keycloak exactly as it did in the DCR repo:

java|authorization-server/.../configuration/WebSecurityConfiguration.java
.oauth2Login(login -> login.loginPage(upstream.authorizationRequestUri()))

One wrinkle worth knowing: the authorization server's own consent page never renders. MCP clients send scopeless authorization requests, and the library skips consent for those, so the only consent screen a user sees is Keycloak's.

What may the token do? Whatever the user's roles allow, and nothing the client asked for. Scopes get stamped at mint time from the upstream realm roles. The comment on the fail-closed branch got sharper under CIMD:

java|authorization-server/.../configuration/ScopeMappingConfiguration.java
// FAIL CLOSED: if we left the claim alone, Spring Authorization Server
// would stamp the client's REQUESTED scopes, and under CIMD the client
// authors its own metadata document, so requested scopes are
// attacker-suppliable. No mappable roles ⇒ no scopes.
context.getClaims().claim(OAuth2ParameterNames.SCOPE, Set.of());

That line is byte-identical to the DCR repo. Only the reason changed: under DCR the client asked your server for scopes, and under CIMD it writes its own metadata file and declares them. The threat got closer, the defence didn't have to move.

Wiring it in Spring: the types exist, the autoconfig doesn't

Before the wiring, the trap. Turning DCR off is one line, and it isn't the line you'd guess. .cimd(true) doesn't disable anything. Open the configurer's source and the two flags are independent fields with opposite defaults:

java|McpAuthorizationServerConfigurer (mcp-security 0.1.13, abridged)
/**
 * Enable or disable dynamic client registration (DCR).
 * @param enabled turn on DCR when true, off otherwise. Defaults to true.
 */
public McpAuthorizationServerConfigurer dynamicClientRegistration(
        boolean enabled) { ... }

/**
 * Enable or disable support for Client ID Metadata Document.
 * @param enabled turn on CIMD when true, off otherwise. Defaults to false.
 */
public McpAuthorizationServerConfigurer cimd(boolean enabled) { ... }

DCR defaults to on. .cimd(true) flips a separate flag that advertises CIMD support and mounts the resolution path; it leaves the registration endpoint exactly where it was. The only off-switch is .dynamicClientRegistration(false), which is why that call sits in the opening snippet doing real work rather than stating the obvious. Grep the Java in this repo for dynamicClientRegistration and you get exactly one hit: the line switching it off.

Past that, there's no Boot-style autoconfiguration for CIMD yet (mcp-security#24 tracks that), so you wire three types by hand:

java|authorization-server/.../configuration/OAuth2StoreConfiguration.java
final var cimdRepository =
    new ClientIdMetadataDocumentRegisteredClientRepository();
cimdRepository.setMetadataDocumentResolver(
    new DefaultClientIdMetadataDocumentResolver(
        cimdMetadataRestClient(cimdFetchAddressFilter)));
// DEMO ONLY: the CIMD draft requires https client_ids and the library
// enforces that by default.
cimdRepository.setClientIdUrlValidator(new ClientIdUrlValidator(true)); 
cimdRepository.setMetadataValidator(
    new DefaultClientMetadataValidator(new DefaultUrlValidator(true)));

// Pre-registered clients (none in the demo); also the save target.
final var jdbcRepository = new JdbcRegisteredClientRepository(jdbcOperations);
final var delegating = new DelegatingRegisteredClientRepository(
    List.of(cimdRepository, jdbcRepository), jdbcRepository);

The delegating repository tries CIMD first, then falls back to JDBC for pre-registered clients. The demo ships none, so that table stays empty, which is the point.

One thing that will confuse you if you go looking for the source. The import reads like core Spring Security:

java|the import that isn't what it looks like
import org.springframework.security.config.annotation.web.configurers
    .oauth2.server.authorization.client.metadata
    .ClientIdMetadataDocumentRegisteredClientRepository;

It isn't. Unzip mcp-authorization-server-0.1.13.jar and that class is inside it. The community project ships classes into Spring Security's own package namespace, which is a normal trick for reaching package-private API and a confusing one to land on from the import statement. Search the Spring Security repo for that class and you'll find nothing.

The bug that only shows up five minutes in

Here's the one I'd want someone to tell me about, because the failure lands nowhere near the cause.

The library's findById only consults its cache. That cache holds a document for five minutes. Refresh tokens in this demo live seven days. So a refresh-token grant made six minutes after the original authorization asks for a client the repository has already forgotten, gets null, and fails. Same for any restart while a persisted authorization is replayed. Three lines fix it:

java|authorization-server/.../configuration/OAuth2StoreConfiguration.java
@Override
public RegisteredClient findById(String id) {
    var client = delegating.findById(id);
    // For CIMD clients the library sets id == client_id == the metadata
    // URL, but its findById only consults the short-TTL cache. A miss on
    // a URL-shaped id (TTL elapsed, or a restart while a JDBC-persisted
    // authorization is replayed, e.g. a refresh-token grant) is
    // re-resolved through the full fetch-and-validate path.
    if (client == null
            && (id.startsWith("http://") || id.startsWith("https://"))) { 
        client = delegating.findByClientId(id);
    }
    return applyDefaults(client);
}

Nothing in the docs mentions this. It's the kind of thing you only meet by leaving a demo running past the coffee break.

The SSRF risk: your server now fetches a URL an attacker picked

Under DCR your authorization server made no outbound calls to identify a client. Under CIMD it dereferences a URL supplied by whoever is starting the flow, which is a textbook server-side request forgery vector: point it at 169.254.169.254 and it reads your cloud metadata endpoint for you.

The guard is one bean:

java|authorization-server/.../configuration/SsrfGuardConfiguration.java
@Bean
InetAddressFilter cimdFetchAddressFilter(Environment environment) {
    // Filter semantics: matches == allowed. externalAddresses() = routable
    // minus multicast, RFC 6890 special-purpose, and the
    // private/link-local/loopback ranges listed above.
    final var externalOnly = InetAddressFilter.externalAddresses(); 
    if (environment.matchesProfiles("demo")) {
        // DEMO ONLY: allow loopback so the locally-served metadata
        // document is fetchable.
        return externalOnly.or("127.0.0.0/8", "::1/128");
    }
    return externalOnly;
}

Boot 4.1's InetAddressFilter gets enforced inside the connection manager's DNS resolver, so it vets the addresses actually being connected to, redirect hops included. A refused host throws FilteredHostException before any socket opens. The offline test drives the real production client construction rather than the filter in isolation, so it proves the wiring and not just the predicate:

java|authorization-server/.../configuration/SsrfGuardConfigurationTest.java
final var restClient =
    OAuth2StoreConfiguration.cimdMetadataRestClient(strictFilter());
assertRefused(() ->
    restClient.get().uri("http://10.255.255.1/client.json")
        .retrieve().toEntity(String.class));
assertRefused(() ->
    restClient.get().uri("http://localhost:1/client.json")
        .retrieve().toEntity(String.class));

What statelessness actually buys you

"Stateless" gets said a lot and cashed out rarely, so here it is in four concrete pieces, each one visible in the running demo.

The same question answered two ways. CIMD trades stored state you own for a runtime dependency on someone else's host.

Nothing accumulates

The oauth2_registered_client table in this repo is real, it's indexed, and it's permanently empty. That's the feature, and one query is the whole argument:

sql|after any number of demo runs
SELECT COUNT(*) FROM oauth2_registered_client;
-- 0

Ten thousand clients cost exactly what one client costs, because the storage cost is zero either way. No pruning job for stale clients, no migration when a client's metadata changes, no capacity question.

The tempting cleanup is to delete that schema entirely, and it was kept on purpose. Authorizations and consents still need JDBC persistence so refresh tokens survive a restart, so the Liquibase changelog stays exactly as it was in the DCR repo. The registered-client table just never fills up, which makes it the most legible proof of the whole thesis. The schema comment says it plainly:

xml|authorization-server/.../db/changelog/db.changelog-master.xml
<!-- These tables back JdbcRegisteredClientRepository,
     JdbcOAuth2AuthorizationService, and
     JdbcOAuth2AuthorizationConsentService.
     CIMD clients are resolved from the metadata document behind their URL
     client_id and are never written to oauth2_registered_client.
     What accumulates here are authorizations and consents:
     SELECT registered_client_id, principal_name FROM oauth2_authorization; -->

One less write path exposed

No registration endpoint means no unauthenticated POST that creates persistent records on your server. But not advertising an endpoint isn't the same as not having one, and it's worth being careful about how you prove the difference.

Leaving registration_endpoint out of the metadata only means well-behaved clients won't go looking for it. A 403 from POST /oauth2/register doesn't settle it either, tempting as that is: a registration endpoint that merely sits behind authentication would return 403 too. The 403 tells you the request was refused, not that the route is absent.

What does settle it is the differential. Probe the path against a route you know doesn't exist, and against one you know does:

PathPOSTGET
/oauth2/register403302
a path that was never mapped403302
/oauth2/jwks, a real endpointn/a200

/oauth2/register answers identically to a path that isn't there and nothing like a path that is. That's the claim worth making, and it's narrower than the one I reached for first: an earlier version of the repo's check task read that bare 403 as proof the route was never mapped, which is more than a 403 can carry.

No onboarding step for the client vendor

Nobody has to run a registration call, store a returned client_id, or handle the case where it got lost. The vendor publishes one file and is done.

Identity updates propagate on their own

When a client vendor adds a redirect URI, they edit their document, and your server picks it up on the next cache miss. Under DCR that same change is either an RFC 7592 management call or a support ticket.

The other side of stateless: revocation, and someone else's uptime

Two costs come with that, and neither should be soft-pedalled.

Revocation has no obvious lever. Under DCR, cutting off a client is a DELETE on a row. Under CIMD there is no row. You can't revoke a client, because you never granted it anything. All you can do is stop accepting its URL, which quietly makes your trust policy the revocation mechanism. The demo ships without one, and that turns out to be its largest gap; more on that below.

You've taken a runtime dependency on a third party's host. This is the trade people miss. DCR's state was a liability you owned. CIMD swaps it for an availability question you don't own: if the client vendor's document host is slow or down at the moment a user logs in, the login fails. Your fetch timeout, your cache TTL, and your behaviour on a stale cache entry stop being tuning knobs and become availability decisions. The demo caches for five minutes because nginx sends no Cache-Control on client.json, and the library defaults to five minutes when the header is absent.

DCRCIMD
Client statea row per client, forevernone; a short-lived cache entry
Registration endpointlive, unauthenticated POSTnone; POST /oauth2/register returns 4xx
Scalestorage grows with client countflat
Revocationdelete the rowno row; your trust policy is the lever
Metadata updatesRFC 7592 call or support ticketvendor edits the document
Outbound dependencynonethe client's host, on every cache miss
New attack surfaceopen registrationSSRF via the fetched URL

What changed between the two repos, and what didn't

The two repos are the same demo with one mechanism swapped, so the diff is the evidence for everything above. The surprise is how little of it there is.

The entire mcp-server module is byte-for-byte identical between them: every tool, every @PreAuthorize, the RFC 9728 metadata, the ES256 validation, the audience check. So is the whole Keycloak setup. On the authorization-server side, one config class was substantially rewritten (OAuth2StoreConfiguration), one DSL line flipped, one guard bean and its test were added, and the rest of the changes are comments. The JWKS config, the token claims config, and the role mapper have zero changed lines; the scope mapper's only diff is the sharpened comment you read above. Swapping how clients identify themselves touched neither the resource server nor the IdP, which is the strongest case I can make that CIMD is a swap and not a rebuild.

What it prints when it runs

One command builds both modules, starts the infrastructure, provisions the Keycloak realm, runs the discovery checks, and then drives the scope matrix live:

bash|the full CIMD flow, headless
mise run demo

The checks confirm the shape of the server: CIMD advertised, no registration endpoint, ES256, and the MCP server's 401 pointing at its Protected Resource Metadata.

text|mise run demo (checks)
AUTHORIZATION SERVER (http://localhost:9000)
  [ OK ] CIMD advertised (client_id_metadata_document_supported: true)
  [ OK ] no registration_endpoint in AS metadata
         (clients bring a URL client_id instead)
  [ OK ] POST /oauth2/register refused (HTTP 403)
  [ OK ] ES256 advertised in OIDC metadata
  [ OK ] JWKS serves an EC P-256 key

MCP SERVER (http://localhost:9200)
  [ OK ] unauthenticated /mcp → 401
  [ OK ] 401 carries WWW-Authenticate → resource_metadata
  [ OK ] Protected Resource Metadata points at the authorization server

Then the headless end-to-end test authorizes with a URL where a registration call used to be, logs both users in through Keycloak, and calls the tools:

text|mise run demo (end-to-end)
CIMD client "spring-oauth2-mcp-cimd demo client" —
  URL client_id: http://localhost/cimd-client/client.json

=== alice (user) ===
  token scopes: [note:read, note:write]
  ✓ ALLOW whoami
  ✓ ALLOW list_notes
  ✓ ALLOW create_note
  ✗ DENY  purge_notes  (Error invoking method: purgeNotes
Access Denied)
  identity: username=alice email=alice@example.com note.author=alice
  PASS: alice (user)

=== bob (admin) ===
  token scopes: [note:admin, note:read, note:write]
  ✓ ALLOW whoami
  ✓ ALLOW list_notes
  ✓ ALLOW create_note
  ✓ ALLOW purge_notes
  identity: username=bob email=bob@example.com note.author=bob
  PASS: bob (admin)

ALL E2E CHECKS PASSED ✅

Alice's scopes come from her user realm role, bob's admin role adds note:admin, and neither client asked for any of it. That's the demo working exactly as it did under DCR, with the client identity resolved from a URL instead of a database row.

Which is fine, but it's my own client talking to my own server. The more interesting question is what happens when a client I didn't write shows up.

Who hosts these documents in production

The vendor does, and you never do. Whoever ships the client publishes its document on their own domain, and your authorization server dereferences that URL at authorize time. You host nothing for clients you have never met, which is most of the operational appeal. The nginx container in this repo exists only because the end-to-end test plays the client role locally, and something has to serve its file.

ThingWho hosts it
The client metadata documentthe client vendor, on their own domain
The MCP serveryou
The authorization serveryou
The outbound fetch and its SSRF guardyou

Which also means an outage on their host is an outage on your login path.

Claude Code: the gap between a published document and a running client

Claude Code publishes a metadata document at https://claude.ai/oauth/claude-code-client-metadata. So I pointed the server at it, expecting the flow to just work. Instead I got a 400, and the reason came down to a single hostname form.

Everything stated here is as of 2026-07-25 with Claude Code 2.1.219. Both the callback template and the environment variables below can change in a patch release.

The document declares its loopback callbacks without a port:

json|https://claude.ai/oauth/claude-code-client-metadata (excerpt)
"redirect_uris": ["http://localhost/callback", "http://127.0.0.1/callback"]

At runtime, Claude Code binds a random port and sends http://localhost:${randomPort}/callback. That URI is not in the document, so a spec-strict server refuses it. Probing the authorize endpoint with Claude Code's real client_id and each candidate redirect URI in turn:

redirect_uri sentresult
http://127.0.0.1/callback302 → Keycloak
http://127.0.0.1:54321/callback302 → Keycloak
http://localhost/callback302 → Keycloak
http://localhost:54321/callback (what it actually sends)400
https://evil.example/steal400

Look at rows two and four. An arbitrary port is fine on 127.0.0.1 and rejected on localhost, and that asymmetry is deliberate. RFC 8252 §7.3 lets a native app use any port on a loopback redirect, but the rule is written for the IP literals 127.0.0.1 and [::1]. The name localhost gets the opposite treatment one section over: §8.3 recommends against it outright, because a name can be repointed by a hosts file or by DNS and a loopback address literal cannot. Spring Authorization Server implements exactly that distinction, so the server is right and the refusal is the security property working.

Worth noticing what did not fail. The CIMD half was flawless throughout: the server fetched Anthropic's document through the SSRF guard, validated it, and accepted the identity every time. Only the shape of the redirect URI was ever in question.

And there's a way through, which is the part I'd have wanted to know first. Claude Code reads MCP_OAUTH_CLIENT_METADATA_URL and MCP_OAUTH_CALLBACK_PORT, so you can point it at a document whose redirect_uris match what it actually sends, on a port you pin:

json|support/cimd-client/claude-code.json
{
  "client_id": "http://localhost/cimd-client/claude-code.json",
  "client_name": "Local identity document for Claude Code (demo)",
  "redirect_uris": [
    "http://localhost:3118/callback",
    "http://127.0.0.1:3118/callback"
  ],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

With that document and a matching MCP_OAUTH_CALLBACK_PORT, the whole chain runs: Claude Code authorizes as a CIMD client, I logged in through Keycloak, the server minted a real ES256 token, and the scope-gated tools answered according to my roles. A client I did not write, identifying itself with nothing but a URL, ran the whole flow.

None of this is a complaint about Anthropic. A published document and a runtime template disagree by one hostname form, the RFC sides with the server, and there's a documented way through. It's what early adoption of a draft actually looks like: the standard works, the fleet hasn't caught up yet, and the gap is smaller than it sounds.

Where this breaks

The rough edges, in the order they'd bother me.

There's no trust policy, and that's the biggest gap between this demo and production. Every syntactically valid document from an allowed address is accepted. Deciding which client URLs you honour, through an allowlist or domain pinning or something with a human in it, is the piece you'd have to build, and it's the piece that turns "anyone can start a flow" into a decision you made rather than one you inherited.

The MCP Inspector sits in the same category as the Claude Code mismatch, one notch further back. It still obtains its identity via DCR, so against a CIMD-only server it can complete discovery and then has nowhere to go:

text|mise run inspector
MCP Inspector and CIMD — the honest status:

  At the time of writing (mid-2026) the MCP Inspector still obtains its
  client identity via Dynamic Client Registration (RFC 7591): it POSTs to a
  registration endpoint instead of presenting a URL client_id. This server
  supports only CIMD and exposes NO registration endpoint, so the Inspector
  can discover the authorization server but cannot complete the OAuth flow
  against it.

The spec says SHOULD, this server implements the SHOULD, and the protocol's own reference client still speaks the MAY. Claude Code is one hostname form away; the Inspector hasn't started.

CIMD is an IETF draft, not an RFC. The MCP 2025-11-25 spec normatively references draft-ietf-oauth-client-id-metadata-document-00; the working-group draft has since moved to -02 (dated 2026-07-06) and is still active. You're building on a moving target, and I'd rather say that than imply stability that isn't there.

Per-client consent is still missing, exactly as it was in the DCR repo. Every CIMD client rides the same federated Keycloak consent, so the confused-deputy problem the MCP spec names applies here unchanged. A shared upstream client with no per-client prompt is the first thing I'd fix before this went anywhere real.

The demo also relaxes things a production server must not. The CIMD draft requires HTTPS client IDs, so the local stack turns on the library's loopback exception to serve its document over plain HTTP, and the SSRF guard allows loopback back in under the default demo profile. Any other profile restores the strict filter, and both shortcuts are marked DEMO ONLY where they live.

Then the small one that will bite someone: the metadata URL is the client_id, so if you change WEB_PORT in support/.env, you've changed the client's identity and have to edit client.json to match.

CIMD is the SHOULD; DCR is still the answer for most people today

If you're deciding between them right now, the honest read is that this isn't a migration you're behind on. CIMD is where the spec points and it's genuinely simpler once it's running: no registration endpoint, no client table, no onboarding call. But the installed base is uneven in a way the spec text doesn't prepare you for. Anthropic's document resolved cleanly and then tripped on a hostname form, needing two environment variables to complete a flow, and the reference Inspector can't start at all because it still registers. A server that speaks only CIMD will turn away clients your users actually have.

So the DCR proxy from the June post isn't obsolete, and I'd still reach for it in front of an IdP that can't register clients. What changed is that the seam I left open there is a working reference now instead of a paragraph of intent, and I know exactly where it frays.

The piece I'd think hardest about isn't the code. CIMD quietly moves a dependency: your login path now runs through a URL somebody else operates, on their uptime and their schedule. That's a fine trade, and it is a trade, which is not how the spec's SHOULD makes it sound.

This is the fifth surface in the Spring Boot and OAuth2 guide, after the DCR proxy, the HTTP edge in Token exchange with Spring Cloud Gateway, and the messaging path in RabbitMQ over AMQP 0.9.1. The source is spring-oauth2-mcp-cimd. Clone it, run mise run demo, and open an issue if something is unclear or broken.

Standards and sources

Plain-English notes on each spec, and how it maps to the code, live in the repo's docs/STANDARDS.md.

Frequently asked questions

What is a Client ID Metadata Document (CIMD)?

A Client ID Metadata Document is a JSON file an OAuth2 client publishes at an HTTPS URL, where that URL is the client's client_id. Instead of registering with the authorization server and being handed an identity, the client asserts one, and the server fetches the document to read the client's name, redirect URIs, and auth method. The document's own client_id field must equal the URL it was fetched from.

What replaces Dynamic Client Registration in the MCP spec?

The MCP authorization spec revised on 2025-11-25 made Client ID Metadata Documents the SHOULD-level mechanism and demoted Dynamic Client Registration (RFC 7591) to MAY, kept for backwards compatibility with earlier revisions. Discovery (RFC 9728) and audience binding (RFC 8707) stayed at MUST. CIMD itself rests on an IETF draft, not a finished RFC.

How do you enable CIMD in Spring Authorization Server?

There is no Boot autoconfiguration for it yet. You enable cimd(true) on the mcpAuthorizationServer() configurer, then wire a ClientIdMetadataDocumentRegisteredClientRepository by hand with a document resolver, a client ID URL validator, and a metadata validator. Switch dynamicClientRegistration(false) explicitly, because the library turns its registration endpoint on by default.

Can Claude Code connect to a CIMD-only authorization server?

Yes, with one adjustment. As of 2026-07-25 (Claude Code 2.1.219) its published metadata document declares portless loopback callbacks like http://localhost/callback, while at runtime it sends http://localhost with a random port. A spec-strict server rejects that, because RFC 8252 grants the any-port loopback rule to the IP literals 127.0.0.1 and [::1], not to the name localhost. Setting MCP_OAUTH_CLIENT_METADATA_URL to a document with matching redirect_uris and pinning MCP_OAUTH_CALLBACK_PORT completes the whole flow.

Is CIMD safer than open Dynamic Client Registration?

It removes the stored state, not the openness. Anyone whose metadata URL passes validation can start an authorization flow, exactly as anyone could POST to an open registration endpoint. What changes is that nothing accumulates server-side and the identity is self-verifying. It also adds a risk DCR never had: the server now dereferences a client-supplied URL, which is a server-side request forgery vector.

Resources

Share this article

Enjoyed this article?

Stay in the Loop

Get notified when I publish new articles. No spam, unsubscribe anytime.

More Posts