Client ID Metadata Documents (CIMD)
September 3, 2026 · View on GitHub
ZeroID implements Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document, adopted by the OAuth WG in October 2025) — the client-onboarding model the MCP Authorization specification (2025-11-25) names as its preferred default.
CIMD lets an OAuth client complete an authorization_code + PKCE flow against ZeroID with zero pre-registration: no admin console, no RFC 7591 dynamic-registration call, no shared secret. The client uses a stable https:// URL as its client_id; ZeroID fetches the JSON metadata document published at that URL, validates it, and treats it as an ephemeral public PKCE client for the duration of the flow.
For where CIMD sits relative to ZeroID's other onboarding paths, see the DCR reference.
What it solves
Open agent ecosystems (MCP is the motivating example) break both classic onboarding models:
- Pre-registration — a human registers the client with every authorization server it will ever talk to. Unworkable when an agent connects to hundreds of servers it discovers at runtime.
- Dynamic Client Registration (RFC 7591) — automates registration but grows an unbounded row per (client × server), needs a write endpoint exposed, and transmits a claimed identity without verifying it (anyone can
POST"client_name": "Claude Desktop").
CIMD flips the trust model from "central authority database" to "domain ownership, verified through TLS + DNS." The client self-publishes its metadata at a URL it controls; the redirect_uris array in that document is the primary anti-impersonation control. An attacker who copies a legitimate client's client_id still cannot receive its authorization codes, because the attacker's callback host won't appear in the legitimate document's redirect_uris.
Wire shape
1. The client publishes a metadata document
At a stable HTTPS URL it controls, e.g. https://app.example.com/oauth/client.json:
{
"client_id": "https://app.example.com/oauth/client.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"redirect_uris": [
"http://127.0.0.1:3000/callback",
"http://localhost:3000/callback"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "notebook:read notebook:write"
}
2. The client starts the flow with its URL as client_id
GET or POST /oauth2/authorize (unchanged from the normal PKCE flow — the only difference is the client_id value).
A browser-based client uses the RFC 6749 §4.1.1 redirect:
GET /oauth2/authorize?client_id=https%3A%2F%2Fapp.example.com%2Foauth%2Fclient.json
&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback
&response_type=code
&code_challenge=<S256 challenge>
&code_challenge_method=S256
&state=<opaque> HTTP/1.1
Cookie: <your session cookie>
state is optional here. PKCE is mandatory on this endpoint and carries the
CSRF binding that state was originally needed for, so OAuth 2.1 leaves state
to carry application state only. ZeroID round-trips it verbatim when present and
does not require it. It is shown because most clients have somewhere to return
the user to.
The browser leg needs a GET-capable PrincipalResolver, which ZeroID does not
ship. A browser cannot set a custom header on a top-level navigation, and the
resolver-facing Form accessor is bound to the POST body, so a resolver that
reads req.Form(...) sees nothing on a GET. The deployer must register one that
reads a session cookie (req.Cookie(...)) and own the login and consent screens
behind it.
ZeroID cannot detect this for you. Its AS metadata omits the
authorization_code grant when no resolver is registered, but it cannot
introspect what a registered resolver reads — so a deployment whose resolvers are
all form-based advertises the grant and then 401s every browser redirect. If that
is you, call Server.SetAuthorizationCodeAvailable(func() bool { return false })
until a GET-capable resolver exists; otherwise the metadata promises a flow the
endpoint cannot finish, which is exactly the failure this is meant to prevent.
A false answer turns the flow off, not merely unadvertised:
/oauth2/authorize answers 503 on both GET and POST. Reach for it if you run a
cookie-based resolver and are not yet ready for the CSRF obligations below — a
cookie resolver is safe while POST is the only route, because SameSite=Lax
withholds the cookie on a cross-site POST, and becomes reachable by cross-site
top-level navigation once GET is mounted.
Errors are not redirected to a CIMD client. RFC 6749 §4.1.2.1 says report most
/oauth2/authorize failures by redirecting to the client's registered
redirect_uri, and ZeroID does — for clients somebody registered. A CIMD client's
redirect_uris come from a document it published itself, so with allowed_domains
empty the destination is attacker-chosen, and honouring the rule would make the
endpoint an unauthenticated redirector: the failure being reported is "you have no
credential", so no credential is needed to trigger it, and the first hop carries
your origin. CIMD clients therefore get the §5.2 JSON body instead, and the
interactive-login redirect is refused for them too — an unvetted client does not
get to borrow your login surface's credibility.
The cost is real and worth naming: a browser-driven CIMD client cannot learn its
error from the callback and has to read the JSON body. Setting
cimd.allowed_domains restores the redirect, because vetting which hosts may
publish restores the assumption §4.1.2.1 is built on. The gate is provenance, not
CIMD.
That hatch only works on a single-tenant deployment. allowed_domains is one
deployment-wide set — domainAllowed takes no tenant — so on a multi-tenant AS it
cannot express one customer's policy, and setting it accepts one customer's
publishers on behalf of all of them. There it is effectively all-or-nothing, which
in practice means CIMD clients do not get error redirects. Tracked in zeroid#286;
the tenant is not even known at the point CIMD resolves, so this is a design
question rather than a missing config field.
Three things a deployer must handle:
-
The resolver starts the interaction; it does not render it.
PrincipalResolverreturns(*Principal, error)and has noResponseWriter, so it cannot render a consent screen — but it can ask for one. ReturnErrPrincipalInteractionRequiredand ZeroID redirects the user agent to the surface you registered withServer.SetInteractiveLoginURL, appendingreturn_toso the flow resumes at/oauth2/authorizeonce you have authenticated them. Your resolver then only has to recognise the session that established.return_tois rebuilt from the parameters ZeroID validated, never copied from the inbound URL, so nothing extraneous a caller appended — including a credential in the wrong channel — is forwarded to your login surface.Only GET is redirected: a POST caller has no user agent. With no target configured the sentinel degrades to
access_denied, because a resolver cannot conjure a surface the deployment does not have — and it is refused outright for a CIMD client, per the provenance rule above.Use
Server.Usemiddleware instead if you want to own the whole interaction including the 302.Server.Usechains, so registering the consent gate alongside whatever else you already use it for is safe: middleware runs in registration order, first registered outermost. (It used to replace, silently dropping everything but the last registration — if you are reading older notes that say to compose manually at the call site, that is no longer necessary. Fixed in zeroid#276.) -
CSRF. A cookie-authenticated
GETis reachable by top-level navigation from any site (SameSite=Laxstill sends the cookie), and CIMD accepts any attacker-publishedclient_id+ its ownredirect_uri. Require an explicit user interaction — a consent screen with a CSRF token — before issuing a code. This is the same reason a real AS never mints on the bare redirect. -
Consent content. CIMD's premise is that the AS shows the user something about a client it has never seen, and marks it unverified. ZeroID does not hand you that metadata today:
client_name/client_uri/logo_uriare parsed into the internal document type but dropped when it is synthesized into a client (domain.OAuthClienthas no such fields), and the document type is unexported. Your consent surface must fetch and validate the CIMD document itself — applying the same rules ZeroID does, in particular the draft §4 self-reference check that the document's ownclient_idequals the URL it was fetched from, without which the screen can be made to display someone else's branding.
A CLI client can post the same parameters as a form instead:
POST /oauth2/authorize HTTP/1.1
Content-Type: application/x-www-form-urlencoded
client_id=https%3A%2F%2Fapp.example.com%2Foauth%2Fclient.json
&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback
&response_type=code
&code_challenge=<S256 challenge>
&code_challenge_method=S256
&state=<opaque>
&api_key=zid_sk_...
Credentials never travel in the query string. On GET, the principal must
arrive in a header (X-API-Key, or Authorization: Bearer zid_sk_…) or a
cookie — a URL ends up in access logs, browser history, and Referer. The
protocol parameters above are fine there; the credential is not.
ZeroID detects the client_id is a CIMD URL, fetches + validates the document, checks the redirect_uri against the document's redirect_uris, mints the auth code, and 302s back to the callback with ?code=…&state=….
3. The client exchanges the code
POST /oauth2/token with grant_type=authorization_code, the same CIMD URL as client_id, the code_verifier, and the redirect_uri. No client_secret — CIMD clients are public; PKCE is the proof of possession. ZeroID re-resolves the document (served from cache) and issues the token.
4. Refresh, introspect, revoke
When the document's grant_types includes refresh_token, the exchange also returns a rotating refresh token. On grant_type=refresh_token ZeroID re-resolves the document before rotating — which gives CIMD its lifecycle story: a client that unpublishes its metadata document can no longer rotate (the presented token is not consumed on resolution failure, so a transient outage doesn't brick the session). The introspection and revocation endpoints likewise accept a CIMD client_id as a public-client identification (RFC 7662/7009 §2.1), so stock OAuth libraries that attach client_id to those calls work unmodified.
How ZeroID resolves + validates a document
Implemented in internal/service/cimd.go; wired into the auth-code flow at OAuthService.IssueAuthCode (/oauth2/authorize) and OAuthService.authorizationCode (/oauth2/token).
-
Detect — registry first. The client registry is always consulted first; CIMD resolution only runs when the registry misses and the
client_idis an absolutehttps://URL with a host and a non-root path (IsCIMDClientID). Registry-first keeps any pre-existing registry client whoseclient_idhappens to be a URL working unchanged, and gives deployers a pinning mechanism: registering a client under a CIMD URL overrides the document. Anything not CIMD-shaped behaves exactly as before — CIMD is purely additive. -
Validate the URL against draft-02 §3:
httpsscheme, non-empty host, path present, no fragment (MUST NOT), no userinfo (MUST NOT), no.or..path segments (MUST NOT), and ≤ 255 characters (theclient_idis persisted intoVARCHAR(255)columns downstream).Two of these carry their own weight beyond conformance. Userinfo is the phishing shape —
https://legit.example.com@evil.example/client.jsonresolves toevil.examplewhile reading aslegit.example.comon a consent screen or in an audit log. Dot segments would give one document many spellings, splitting the resolution cache and handing one client several identities the §4 self-reference check cannot distinguish.ZeroID also rejects a query string, where the draft says only SHOULD NOT. That is deliberate and stricter than required: the
client_ida client presents must stay byte-identical to the URL the document was fetched from, which is exactly what the self-reference check compares. -
Domain policy. If
cimd.allowed_domainsis configured, the host must be in it (exact, case-insensitive). Empty allowlist ⇒ any public HTTPS host — which is the default, and ZeroID warns at startup when CIMD is enabled without one. Note what this control can and cannot do: it constrains which hosts may publish, at domain granularity. It does not establish that the party presenting aclient_idcontrols that document — CIMD has no proof of possession, so any client may present any published URL, and for a native client whose document lists a loopbackredirect_urithe code is delivered to the presenter's own listener. Treat the allowlist as ecosystem scoping, not client authentication. -
Fetch (SSRF-guarded, no redirects).
GETvia the same DNS-rebinding-safe client the OIDC attestation verifier and CIBA dispatch use (attestation.NewSSRFGuardedHTTPClient): the host is resolved once, every answer is checked against the private/loopback/link-local/multicast/CGN/reserved blocklist, and the connection is pinned to the validated IP. TLS still verifies against the original hostname. Response is size-capped (5 KiB default) and timeout-bounded (5 s). HTTP redirects are not followed — theclient_idis a canonical location; a 3xx is a resolution failure. -
Validate the document.
- Self-reference (draft §4): the document's
client_idfield MUST equal the URL it was fetched from. This is what stops a document from claiming someone else's identity. redirect_urisis required and non-empty — CIMD's primary anti-impersonation control. Each entry must satisfy OAuth 2.1 scheme rules:https://, loopbackhttp://, or a private-use scheme (native apps); plaintext non-loopbackhttp://is rejected. The requestedredirect_uriis matched against the list by the existingredirectURIAllowedlogic (exact match, with RFC 8252 §7.3 port-agnostic matching for loopback callbacks).token_endpoint_auth_methodmust benone(omitted defaults tonone). Confidential CIMD clients (private_key_jwt) are not supported in v1.grant_typesdefaults to["authorization_code"], must includeauthorization_code, and may only containauthorization_code/refresh_token.response_types, if present, must includecode.
- Self-reference (draft §4): the document's
-
Synthesize + cache. The document becomes an ephemeral
domain.OAuthClient(client_type: public,token_endpoint_auth_method: none,registration_source: cimd) that is never written to the database. Outcomes are memoized in a bounded in-memory cache (1000 entries): successes for the configured TTL (1 h default, 24 h hard cap), shortened when the document'sCache-Controlmax-ageasks for less (floored at 60 s so a document can't force a fetch per request) — so the/oauth2/authorize→/oauth2/tokenround-trip doesn't fetch twice, and a client rotating itsredirect_uriscan shrink the staleness window; failures are negative-cached (10 s for transient fetch errors, 60 s for deterministic validation failures) so replaying a dead URL can't force a fresh timeout-bounded outbound fetch per request.
Error mapping
| Outcome | OAuth error | HTTP |
|---|---|---|
client_id not a valid CIMD URL (bad scheme, query, fragment) | invalid_request | 400 |
| Document malformed / self-reference mismatch / forbidden field / oversize | invalid_client_metadata | 400 |
Host not in allowed_domains | invalid_client | 401 |
| Document could not be fetched (network, non-200, SSRF block, timeout) | invalid_client | 401 |
CIMD disabled but a URL client_id reached resolution | invalid_client | 401 |
Fetch-failure causes are logged server-side but never echoed to the client (they can reveal the server's DNS view / internal topology).
Configuration
CIMD is on by default. Knobs (all optional):
cimd:
enabled: true # ZEROID_CIMD_ENABLED — set false to disable
allowed_domains: [] # PRODUCTION HARDENING LEVER — allowlist of client_id hosts; empty = any HTTPS host (see below)
allow_private_metadata_endpoints: false # ZEROID_CIMD_ALLOW_PRIVATE_ENDPOINTS — test/dev only
max_document_bytes: 5120 # ZEROID_CIMD_MAX_DOCUMENT_BYTES
cache_ttl_seconds: 3600 # ZEROID_CIMD_CACHE_TTL_SECONDS (clamped to 24 h)
Production hardening — set allowed_domains
allowed_domainsis the primary production hardening lever for CIMD. Set it to the exact hosts you trust.
CIMD ships on and open: with an empty allowed_domains, any request-supplied https:// client_id reaching the public /oauth2/authorize endpoint drives ZeroID to fetch that URL. The fetch is heavily guarded (SSRF blocklist, 5 KiB / 5 s caps, no redirects, negative caching — see Security considerations), but an empty allowlist still means ZeroID will make outbound requests to arbitrary attacker-chosen public hosts.
allowed_domains closes that: only client_id URLs whose host is in the list (exact, case-insensitive) are resolved — every other host is rejected before any outbound fetch. This turns CIMD from an open ecosystem into a closed one bounded to your known clients.
cimd:
allowed_domains:
- client.example.com
- apps.acme.dev
Guidance:
- Enterprise / closed deployments: set
allowed_domainsto your known client hosts. This is the recommended production posture. - Open agent ecosystems (public MCP): leaving it empty is a deliberate choice — additionally rate-limit
/oauth2/authorizeat the edge (see Fetch abuse). - Turning CIMD off entirely:
enabled: false—https://client_idvalues then fall through to the registry (and miss, since CIMD clients are never persisted).
allow_private_metadata_endpoints: true disables the SSRF blocklist so documents can be served from localhost / a private network in single-tenant dev/test. Production MUST keep it false.
Discovery
When enabled, the OAuth 2.0 Authorization Server Metadata document (GET /.well-known/oauth-authorization-server) advertises:
{ "client_id_metadata_document_supported": true }
CIMD-aware clients (MCP 2025-11-25, and any client that walks the draft's discovery) read this to learn they can present a URL client_id and skip registration. The field is omitted when CIMD is disabled.
Specification revision and deviations
Implemented against: draft-ietf-oauth-client-id-metadata-document-02, adopted by the OAuth WG in October 2025.
This is a draft at revision 02 and it will change. The section exists so that when it does, the next reader can tell which behaviours were the spec, which were our choices, and which were deliberately left unbuilt — rather than reverse-engineering that from the validator.
Where ZeroID is deliberately STRICTER than the draft
Both of these will reject a document some other implementation accepts. That is intended, but it is also the part most likely to age badly: a later revision can bless what we refuse, and then we are rejecting valid clients for a reason nobody remembers choosing. Revisit both on each draft bump.
| Rule | Draft says | ZeroID does | Why |
|---|---|---|---|
Query string in client_id | SHOULD NOT | Rejects | The client_id a client presents must stay byte-identical to the URL the document was fetched from — that is exactly what the §4 self-reference check compares. Stripping or tolerating a query gives one document two spellings. |
client_name | RECOMMENDED | Required, non-empty | It is the string a human is asked to trust on a consent screen. Absent, the prompt degrades to a raw URL — and for a URL an attacker chose, that is actively misleading. The publisher is anonymous by construction (no registration, no secret), so this label is most of what consent has to work with. |
Deliberately not implemented
- Confidential CIMD clients —
token_endpoint_auth_method: private_key_jwtwith a publishedjwks_uri. ZeroID accepts noprivate_key_jwttoken-endpoint auth for any client; CIMD is public-PKCE-only. software_statement— signed metadata is not consumed. CIMD trust here is domain-ownership based.
Both are areas the draft is more likely to move in than the core resolution rules, which is part of why they are not built on.
The change most likely to break silently
A rename of the discovery field client_id_metadata_document_supported.
If a later draft renames it, nothing errors. The authorization server keeps advertising a key clients no longer look for, every client falls back to registration_endpoint and DCR, and the whole flow keeps working — via exactly the row-per-client path CIMD exists to remove.
Tests do not help here. They assert the server emits that field, so they stay green while no client can see it. The only guard is tracking the draft.
What makes drift survivable
Two properties worth knowing about before a spec change forces a decision:
- DCR is retained as a fallback.
registration_endpointstays advertised alongside CIMD deliberately, so a client that cannot complete CIMD still has a path. - Registry-first resolution is a pinning mechanism. A client registered under its CIMD URL overrides the document (see step 1 of resolution). So a specific client caught by a spec change can be pinned by registering it, without waiting on a ZeroID release.
Deployment note: the cache is per process
CIMDService holds its resolution cache in memory, so a deployment running N replicas has N independent caches. Concurrent resolutions of the same client_id are coalesced within a process (singleflight), not across them.
Two consequences worth planning around:
- Fan-out is up to N fetches per document per TTL, and negative caching is likewise per replica — so replaying a dead URL costs N times as much. Bounded in practice by edge rate limiting, which is required for an open deployment anyway.
- Staleness is non-uniform. Two replicas can serve different versions of one document for up to the TTL, so a client that removes a
redirect_urimay find the change effective on some replicas and not others, with no way to tell which served it.
A shared cache (Redis) would fix the fan-out and make replicas consistently stale — it would not make them fresher. Revocation latency is governed by the TTL and by the document's Cache-Control (ZeroID takes the shorter, floored at 60s), independent of where the cache lives. Note also that a shared cache holds redirect_uris, the primary anti-impersonation control, so write access to it becomes equivalent to choosing where authorization codes are delivered.
Security considerations
-
Redirect-URI allow-list is the load-bearing control. Copying a
client_idis useless without control of a host in itsredirect_uris. PKCE binds the code to the verifier on top of that. -
SSRF. The document fetch cannot be turned into a probe of internal/metadata addresses — the guard is the same audited implementation used elsewhere in ZeroID, applied at dial time against the resolved IP (DNS-rebinding-safe).
-
Localhost redirects. Loopback
redirect_urisare matched port-agnostically (RFC 8252) so native/CLI callbacks work, but any process on the user's machine can bind a loopback port. Treat localhost CIMD clients with the same caution as any native public client. -
DNS / TLS trust. CIMD's integrity rests on the same DNS + TLS + certificate-transparency assumptions as any HTTPS-based trust model.
allowed_domainsis the hard boundary — see Production hardening. -
No persistence, no secret. CIMD clients are ephemeral and hold no symmetric secret at the broker — there is nothing to leak from ZeroID's side.
-
Fetch abuse. The resolution surface is unauthenticated (
/oauth2/authorizeis public, and client resolution runs before the principal chain), so a request-suppliedclient_idtriggers an outbound fetch. It is deliberately hard to abuse — concurrent resolutions of the sameclient_idare coalesced into one fetch (singleflight), plus a bounded cache (1000 entries), negative caching of failures (10 s fetch / 60 s validation), 5 KiB response cap, 5 s timeout, no redirect following, and the SSRF blocklist.Be precise about what each control bounds. The caps bound the cost of one fetch. Coalescing bounds duplicate concurrent work for one
client_id. Neither bounds a caller cycling distinct URLs: each unique path is a fresh flight, misses the cache, walks past negative caching, and churns cache eviction. So the primary control isallowed_domains, which rejects unknown hosts before any fetch — and a deployment running fully open (empty allowlist) must rate-limit/oauth2/authorizeat the edge. That is the deployer's job; no in-process control substitutes for it.
Limitations / future work
- Unimplemented draft features — confidential CIMD clients (
private_key_jwt+jwks_uri) andsoftware_statement. Both are covered under Specification revision and deviations, which is the single place that records what is and is not built against the draft; they are not repeated here so the two cannot drift apart. - Cross-replica cache coherence — the resolution cache is per process, so N replicas hold N caches. See Deployment note: the cache is per process for the fan-out and staleness consequences, and why a shared cache addresses the first but not the second.
Files
| Concern | File |
|---|---|
| CIMD service (detect / fetch / validate / synthesize / cache) | internal/service/cimd.go |
| Auth-code flow integration | internal/service/oauth.go (IssueAuthCode, authorizationCode, cimdOAuthError) |
| SSRF-guarded HTTP client (shared) | internal/attestation/oidc.go (NewSSRFGuardedHTTPClient) |
| Discovery advertisement | internal/handler/wellknown.go (oauthMetadataOp) |
| Config | config.go (CIMDConfig) |
| Tests | internal/service/cimd_test.go |