
In the 2026-07-28 Authorization spec announcement, MCP shipped a batch of authorization-hardening changes in one go: RFC 9207 issuer validation is now mandatory, client credentials are bound to the issuer that minted them (no reuse across authorization servers), and — the headline item — Dynamic Client Registration (DCR) is formally deprecated in favor of CIMD (Client ID Metadata Documents). DCR stays functional for backward compatibility for at least 12 months, but the direction is set: a future version will remove it.
Half a year ago I wrote Introduction to OAuth Client ID Metadata Document (CIMD), covering the three pain points CIMD solves (pre-registration doesn’t scale, DCR’s unbounded client database and self-asserted trust problem, and MCP’s “no pre-existing relationship” scenario). That was the concept piece; now that the spec has put CIMD front and center, it’s time for the hands-on companion — running a complete CIMD authorization flow end to end, watching an HTTPS URL travel all the way into the client_id claim of an access token.
This post takes apart the 03-oauth-mcp/cimd sample from go-training/mcp-workshop: two runnable Go programs (cimd-client, cimd-server) with Signet as the authorization server — from minting a local certificate with mkcert, publishing the client metadata document, Authorization Code + PKCE, RFC 9207 iss validation, to finally calling the MCP who_am_i tool with a Bearer token. And as a bonus: swapping the OAuth client for Claude Code itself, logging in against the CIMD URL with claude mcp add.
If you haven’t read the concept piece yet, spend five minutes on Introduction to OAuth Client ID Metadata Document (CIMD) first — this post won’t repeat the “why CIMD” story and jumps straight into “how to run it”. As for the RFC 9207
issvalidation that shows up throughout, the theory is in When an MCP Client Trusts Multiple Authorization Servers: Stopping Mix-Up Attacks with RFC 9207 and the live attack demo in Taking the MCP Mix-Up Attack Apart — those two posts are also the backstory for why this announcement madeissmandatory.
1. What Actually Changed on 2026-07-28
The announcement’s points relevant to this post, in one table:
| Change | What it says | What it means for you |
|---|---|---|
| DCR formally deprecated | Explicitly “deprecated in favor of CIMD”; at least a 12-month compatibility window; removed in a future version | New projects go straight to CIMD; existing DCR flows should start planning migration |
| CIMD becomes the standard | The client uses an HTTPS URL as its client_id; the AS fetches the JSON document at that URL on the fly to complete “registration” | No more registering with every AS, no client secrets to manage |
| RFC 9207 mandatory | The AS must return iss on the authorization response; the client must validate it before redeeming the code | Closes the mix-up attack hole; the check is the client’s job |
| Issuer-bound credentials | Client credentials are bound to the AS that minted them; no reuse across authorization servers | One credential is valid against exactly one AS |
One naming clarification up front — CIMD is not CIDR. CIMD is a public HTTPS JSON document describing an OAuth client; CIDR (the 10.0.0.0/8 kind) only shows up later, in the SSRF-guard part of the story on the AS side.
CIMD’s core idea in one sentence: the client no longer “registers with the AS” — it “publishes its own identity”. The client hosts a metadata JSON document at an HTTPS URL it controls; that URL is the client_id. The AS fetches the document while handling the authorization request and materializes the client from it. No registration API, no client database, no client secret.
2. The Three Roles in the Sample
The sample ships three Go programs plus an external Signet (the same one used by the RFC 9207 attack demo):
| Program | Role | Default address |
|---|---|---|
cimd-client/ | Plays two roles at once: HTTPS metadata origin (serving the document) + MCP OAuth client (RFC 8414 discovery, Auth Code + S256 PKCE, RFC 9207 iss validation, Bearer-authenticated who_am_i) | origin :9443, callback :8085 |
cimd-server/ | An ordinary MCP resource server (RFC 9728 protected resource metadata + local JWKS verification of Signet-issued JWTs) | :8095 |
claude-code/ | A standalone metadata origin for testing with Claude Code (section 8) | :9443 |
| Signet (external) | The authorization server that fetches and validates the metadata document | :8080 |
cimd-server has a design choice worth pausing on: it deliberately contains nothing CIMD-specific. A resource server never sees the metadata document — it only verifies the resulting access token (issuer, audience, signature). CIMD is strictly between the client and the AS; the cleaner that boundary, the less your resource server has to change when registration mechanisms evolve.
The full flow:
sequenceDiagram
participant C as cimd-client<br/>(also metadata origin :9443)
participant M as cimd-server<br/>(:8095)
participant A as Signet<br/>(:8080)
participant B as Browser
C->>C: publish client.json over HTTPS (client_id = its URL)
C->>A: GET /.well-known/oauth-authorization-server
A-->>C: endpoints + client_id_metadata_document_supported: true
C->>B: open /oauth/authorize?client_id=https://localhost:9443/oauth/client.json&…
B->>A: authorization request
A->>C: GET https://localhost:9443/oauth/client.json (SSRF-guarded fetch)
C-->>A: 200 metadata JSON (client_id, redirect_uris, auth method none)
A->>B: login + consent (client shown by document domain)
A-->>B: 302 callback?code=…&state=…&iss=http://localhost:8080
B-->>C: callback
C->>C: validate state + RFC 9207 iss
C->>A: POST /oauth/token (code, PKCE verifier, resource)
A-->>C: access JWT (aud = http://localhost:8095/mcp)
C->>M: MCP who_am_i (Authorization: Bearer)
M-->>C: verified claims (client_id is the CIMD URL)
Look at the A->>C step in the middle: the direction is reversed. In classic OAuth the AS never initiates a connection to the client; with CIMD, the AS turns around mid-authorization-request and fetches the document the client published. That one step is the soul of the mechanism — and the origin of every rule that follows, from SSRF protection to the HTTPS mandate.
3. Why Only the Metadata URL Needs HTTPS
Almost everything in the sample runs over plain HTTP — except the metadata document URL itself:
| Component | Scheme | Why |
|---|---|---|
Signet issuer (localhost:8080) | HTTP OK | dev deployment |
MCP resource server (localhost:8095) | HTTP OK | dev deployment |
OAuth callback (127.0.0.1:8085) | HTTP OK | loopback redirect URIs may be HTTP even under strict mode |
CIMD document URL (= client_id) | HTTPS required | Signet’s IsCIMDClientID predicate only recognizes https URLs — no development-mode exception. An http://…/client.json client_id is treated as an unknown regular client and fails with unauthorized_client |
Why no backdoor here? Because the HTTPS domain is CIMD’s trust anchor. The only evidence of “who this client is” in the entire mechanism is “who controls this HTTPS URL”; allowing HTTP would let any man-in-the-middle claim to be any client, severing the trust chain at its root.
For local development, mkcert solves it in two lines — it installs a local CA into the system trust store, which is exactly what Signet’s fetcher (and the client’s own preflight check) uses to verify TLS:
-cert / -key are resolved relative to the working directory, so run these commands from wherever you’ll run go run later (the repo root). .gitignore already covers *.pem, but never commit the key.
4. Signet Configuration: Three Environment Variables
Run a Signet at http://localhost:8080 — my own OAuth2 / OIDC authorization server (not open source at the moment), the same one used in the kubelogin × k3s and Kong MCP unified gateway tutorials. The three environment variables below are Signet implementation details, but the three security decisions behind them (capability advertisement, resource allowlist, SSRF protection) apply to any authorization server implementing CIMD:
One by one, because each maps to a security decision:
CIMD_ENABLED— off by default. When off, Signet doesn’t advertiseclient_id_metadata_document_supportedin its metadata, and the client aborts early — before the browser ever opens. That’s capability declaration by design: the client discovers first, confirms AS support, then starts the flow.CIMD_ALLOWED_RESOURCES— the RFC 8707resourcea CIMD client requests must appear in this list byte-for-byte — a trailing slash counts. Anything not on the list fails the authorize request withinvalid_target. This is the AS-side allowlist controlling which resources an anonymous client can obtain tokens for.CIMD_ALLOW_PRIVATE_NETWORKS— Signet’s SSRF guard rejects loopback and private-network addresses at dial time by default. Think about it:client_idis a URL an attacker can choose freely, and the AS will fetch it — without this guard, an attacker could pointclient_idat anything on your internal network (https://169.254.169.254/…, internal APIs…) and have the AS make requests on their behalf. This flag disables the guard purely so Signet can fetchhttps://localhost:9443/…; only ever set it in isolated local development.
Once configured, verify the capability is actually on:
5. Quick Start: Two Terminals
Both commands run from the repo root (where mkcert wrote the pems):
| |
Pause on that terminal-2 command and compare it to any OAuth sample you’ve run before: no -client_id flag, no registration step, no client secret. The client publishes this document at https://localhost:9443/oauth/client.json, and that URL is the client’s identity:
The browser opens Signet’s login and consent page automatically; after consent, the client completes the token exchange and calls who_am_i — the output shows the token’s verified client_id is exactly that CIMD URL.
6. The Rules the Document Must Follow
The JSON looks unremarkable, but Signet’s validation is anything but lenient. The rules (the sample client’s validateCIMDURL and its startup preflight self-check mirror the same set, so mistakes explode here with a readable error instead of surfacing as an opaque unauthorized_client after a full browser round-trip):
client_idmust be byte-identical to the URL the document is fetched from. One character off and the document is no longer “this URL’s self-description”.- URL shape: HTTPS, a hostname, a path more specific than
/, no./..segments, no fragment, no userinfo. - Served directly with
200— no redirects, no auth. Signet caps the document at 64 KiB (the draft recommends < 5 KB). token_endpoint_auth_methodmust be empty ornone— a CIMD client is always a public client. It makes sense once you think about it: there is no step in the flow where a secret could be exchanged safely, so PKCE (S256) is the only proof at the token endpoint.- 1–10
redirect_uris, exact-match comparison. So the callback listener uses a fixed port, never:0— there is no registration step where a new port could be announced. - Scopes get intersected: Signet intersects the declared
scopewith its user-safe set (openid profile email offline_access); custom scopes likemcp:toolsare silently dropped in the current implementation.
In code, the URL shape rules look like this (cimd-client/cimd.go, excerpt):
| |
And when building the document, client_id is always set to the document URL itself, structurally ruling out the typo class of errors:
| |
After startup the client also runs a preflight self-check: using the same system trust store Signet uses, it fetches its own freshly published URL and requires a direct 200, no redirects, and a response byte-for-byte equal to the document it just built. Comparing the whole document rather than just client_id also pins redirect_uris and scope — if a cache or another process is answering that URL, it gets caught right here, instead of after Signet has silently resolved a different client.
7. Reading the Flow from the Logs (RFC 9207 Included)
Once it’s running, watch for these log lines in order — each one maps to a security mechanism:
- client:
client metadata document published— preflight passed (direct 200, byte-identicalclient_id). - client:
opening browser for authorization — Signet will now fetch the metadata document to resolve the client— a reminder that the AS is about to fetch in reverse. - Signet’s consent page identifies the client by the document’s domain (
localhost), not the self-assertedclient_name— anyone can type any name; a name proves nothing. The domain is the HTTPS trust anchor, and this is the CIMD trust model showing up directly in the UI. - client:
iss OK— the RFC 9207 issuer check passed before the code was sent to the token endpoint. - server:
audience verified— the JWT’saudis the MCP resource, bound by the RFC 8707resourceparameter; the token is useless against any other resource server. - client: the
who_am_istructured output showsclient_id=https://localhost:9443/oauth/client.json— the CIMD URL made it all the way into the issued token.
Step 4 deserves a closer look. The announcement made RFC 9207 mandatory, and the check is the client’s responsibility — the sample’s implementation covers all four branches analyzed in the attack demo post:
| |
Signet advertises authorization_response_iss_parameter_supported, so in this sample a missing iss is also a failure — not “validate if present, shrug if absent”.
8. Swapping the OAuth Client for Claude Code
The cimd-client above is a hand-rolled teaching implementation; in the real world you’d rather have your actual MCP client — say, Claude Code — walk the CIMD path directly. Claude Code supports URL-shaped client_ids: you hand it a CIMD URL, it passes that URL as-is through the OAuth flow, and Signet fetches the document to resolve the client — exactly the same machinery as before.
The one gap: a CLI cannot host an HTTPS origin of its own. In cimd-client the OAuth client and the metadata origin are the same process; with Claude Code the roles split, and the sample’s claude-code/ folder is the stand-in origin — one HTTPS listener, one JSON document, nothing else:
| Role | cimd sample | Claude Code test |
|---|---|---|
| OAuth client (browser flow, PKCE, token) | cimd-client | Claude Code |
Metadata origin (https://localhost:9443/oauth/client.json) | cimd-client (embedded) | the claude-code/ binary |
| MCP resource server | cimd-server | cimd-server (unchanged) |
| Authorization server | Signet | Signet (unchanged) |
Prerequisites are the same as sections 3 and 4 (mkcert certificate + the same Signet environment variables). To run:
| |
Then register the MCP server with Claude Code, pointing its client_id at the published document:
Or the equivalent .mcp.json:
Authenticate — inside a Claude Code session via /mcp, pick cimd-server → Authenticate, or straight from the shell:
| |
The browser opens Signet’s consent page, again identifying the client by the document’s domain; after consent, Claude Code exchanges the code with PKCE S256 (no client secret — the document pins token_endpoint_auth_method to none), calls who_am_i, and the returned claims show client_id = https://localhost:9443/oauth/client.json.
Two Claude Code-specific gotchas:
- The callback port must be fixed. By default Claude Code picks a random port for its OAuth callback, but CIMD redirect URIs are compared by exact match and there’s no registration step where a new port could be announced — a random port will never match the document. So both sides pin
8085: Claude Code via--callback-port 8085(oroauth.callbackPortin.mcp.json), and the origin’s-redirect-urisdefaults cover bothhttp://localhost:8085/callbackandhttp://127.0.0.1:8085/callbackloopback spellings. Note8085is alsocimd-client’s default callback port — stopcimd-clientbefore testing, or they’ll fight over the port. - The origin must stay running. Signet re-fetches the
client_idURL on every authorization request — the document is the registration, and the registration is checked live. Stop the origin after the first successful login and the next re-auth fails.
9. Troubleshooting
The sample README’s error table, condensed — all of these are either ones I hit myself or ones the design guarantees you’ll hit:
| Symptom | Likely cause | Check |
|---|---|---|
client aborts early: does not advertise client_id_metadata_document_supported | CIMD disabled on Signet | CIMD_ENABLED=true, restart Signet |
metadata self-check failed + TLS error | certificate not trusted | mkcert -install; cert covers the -cimd-url hostname |
unauthorized_client at authorize | client_id not recognized as a CIMD URL | scheme must be https, path more specific than / |
invalid_client during authorization | Signet’s fetch failed | Signet component=cimd logs; loopback origins need CIMD_ALLOW_PRIVATE_NETWORKS=true; direct 200; byte-identical client_id |
invalid_target | resource not allowlisted | CIMD_ALLOWED_RESOURCES contains -resource byte-for-byte (trailing slash counts) |
| scopes went missing | custom scopes dropped by intersection | only openid profile email offline_access survive for CIMD clients |
| token obtained but cimd-server returns 401 | audience / issuer mismatch | -resource matches on both sides; both point at the same Signet |
| (Claude Code) redirect mismatch | random callback port | --callback-port 8085 aligned with the origin’s -redirect-uris |
| (Claude Code) first login works, later fails | origin was stopped | Signet re-fetches the document on every authorization — keep the origin running |
You can also verify the rules without starting any server — the sample ships tests covering the CIMD URL shape rules, the byte-exact client_id binding, the origin handler’s response contract, and every branch of the RFC 9207 iss validation:
| |
Wrapping Up
Map what you just ran back onto the announcement and you’ll find this sample is essentially the 2026-07-28 spec made concrete:
- “DCR deprecated, CIMD takes over” on the command line looks like that startup command with no
-client_id, no registration step, no client secret — theclient_idis an HTTPS URL you control, fetched and validated by the AS on the fly. The registration wall is gone. - The trust anchor moves from “a registration record” to “an HTTPS domain”: Signet’s consent page identifies the client by document domain rather than self-asserted
client_name, redirect_uris are pinned inside the document, and public client + PKCE replaces the secret. - RFC 9207 is the client’s homework: the
isscheck gates the code before it’s ever sent, and “the AS advertises support but sent no iss” is also a failure — this is precisely the conclusion of the two mix-up posts, now written into the spec. - The AS pays with an SSRF surface: the AS fetches a URL the client chose, so Signet’s SSRF guard (rejecting loopback / private networks) is a necessary production defense, and
CIMD_ALLOW_PRIVATE_NETWORKS=truebelongs to local experiments only.
If you operate an MCP service still on DCR, the 12-month compatibility window is for migrating, not for waiting. The minimal migration path is genuinely short: pick a stable HTTPS URL for your client, host a few hundred bytes of JSON there, switch client_id to that URL — the six rules in section 6 are your acceptance checklist. Clone the sample and run it once with your own hands, then log in against it from Claude Code — your intuition for “the URL is the identity” will be more solid than ten readings of the draft.
Full code and runbook: https://github.com/go-training/mcp-workshop/tree/main/03-oauth-mcp/cimd
References
- MCP announcement: 2026-07-28 Authorization changes
- draft-ietf-oauth-client-id-metadata-document
- RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification
- RFC 9728 — OAuth 2.0 Protected Resource Metadata
- RFC 8707 — Resource Indicators for OAuth 2.0
- RFC 7636 — Proof Key for Code Exchange
- Series: CIMD concepts, RFC 9207 theory, Mix-Up attack demo
- Signet — OAuth2 / OIDC Authorization Server
- Signet tutorials: kubelogin × Signet × k3s, Kong × Signet unified enterprise OAuth2 gateway