Client ID Metadata Documents (CIMD,
draft-ietf-oauth-client-id-metadata-document)
let an OAuth client use an HTTPS URL as its client_id. The URL points
at a JSON document the client hosts:
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}When such a client_id arrives at /oauth/authorize, this server
fetches the document, validates it, and treats the result as the
client's registration — no /oauth/register call needed. This is the
client registration mechanism the MCP spec (2026-07-28) recommends;
Dynamic Client Registration is deprecated there and kept for backwards
compatibility.
Enabling it
use AshAuthentication.Oauth2Server,
# ...
cimd_enabled?: trueTwo prerequisites:
The
reqdependency (for the default fetcher):{:req, "~> 0.5"}CIMD support on your client resource. New installs get this from the installer; existing apps add:
attribute :cimd_url, :string, public?: true identity :by_cimd_url, [:cimd_url] create :register_cimd do upsert? true upsert_identity :by_cimd_url accept [:cimd_url, :client_name, :redirect_uris, :grant_types, :response_types, :token_endpoint_auth_method, :scope] endthen
mix ash.codegen add_cimd_to_oauth_clients+ migrate.
With CIMD on, the RFC 8414 metadata document advertises
client_id_metadata_document_supported: true, which is how clients
discover they can skip registration.
How it works
- Authorize — a URL-shaped
client_idtriggers a fetch (through an in-memory cache that honours the document's HTTP cache headers, capped at one hour). The document is validated — itsclient_idmust match the URL exactly,client_nameand validredirect_urisare required, and the grant/response/auth-method fields must be within what this server supports (public clients only) — then upserted into your client resource keyed bycimd_url. From there the flow is identical to any other client: exact-match redirect URI checks, consent (persisted per user + client), audience-bound codes. - Token / revocation — the client presents the same URL as its
client_id; it's resolved against the database only (never fetched). Access tokens minted for CIMD clients carry the URL in theirclient_idclaim. - Cache —
AshAuthentication.Oauth2Server.Supervisorruns the document cache. Without it everything still works; each authorize request just re-fetches.
SSRF and the fetcher
Fetching an attacker-suppliable URL from inside your infrastructure is
the risky part of CIMD. The default fetcher
(AshAuthentication.Oauth2Server.CIMD.ReqFetcher) enforces an outbound
policy: HTTPS on port 443 only, a required path component, DNS
resolution up front with every resolved address required to be
publicly routable (loopback, RFC 1918, link-local/cloud-metadata, CGNAT,
NAT64/6to4/v4-mapped embeddings, and friends are rejected), a connect
pinned to the validated IP (closing the DNS-rebinding window) with TLS
verification kept on the hostname, no redirects, and a 64 KiB / 5s
response cap.
If you need a different policy — an egress proxy, a host allowlist, or a
stub in tests — implement AshAuthentication.Oauth2Server.CIMD.Fetcher
and set :cimd_fetcher. The fetcher options (:cimd_fetch_options) are
passed through to fetch/2.
Because a fetch can be triggered by an unauthenticated GET /oauth/authorize, rate-limit that endpoint the same way you rate-limit
the other protocol endpoints (see the "Rate limiting" section in
AshAuthentication.Oauth2Server).
Relationship to DCR
CIMD and Dynamic Client Registration coexist; enable either or both.
Clients following the MCP 2026-07-28 spec prefer CIMD when
client_id_metadata_document_supported is advertised and fall back to
DCR via registration_endpoint. If you serve older MCP clients, keep
dcr_enabled?: true alongside cimd_enabled?: true during the
transition window.