Skip to content

Client ID Metadata Documents ​

A way for a client to register itself without registering: its client_id is an https:// URL, and that URL serves a JSON document describing the client. The authorization server fetches the document, validates it, and treats it as the registration.

No registration endpoint. No registration access tokens. No secret to paste into a connector setup screen, and no per-install provisioning step. Both Claude and ChatGPT prefer this over RFC 7591 dynamic client registration.

It is off by default, and turning it on has a cost that is spelled out below — the server starts making outbound HTTP requests to a URL an unauthenticated request parameter chose.

Turning it on ​

ts
const oauth = createOAuthHost({
  // …the rest of your config…
  clientIdMetadata: {
    enabled: true,
    allowedHosts: ['claude.ai', 'chatgpt.com'],
  },
})

allowedHosts is required. Enabling CIMD with an empty or missing list is a boot error, not an implicit "any host" — see the SSRF section for why that is the one default this package refuses to guess.

With it on, discovery advertises three extra things, which is how Claude and ChatGPT know to skip registration entirely:

json
{
  "token_endpoint_auth_methods_supported":
    ["client_secret_basic", "client_secret_post", "none", "private_key_jwt"],
  "token_endpoint_auth_signing_alg_values_supported": ["RS256", "ES256"],
  "client_id_metadata_document_supported": true
}

All three appear only when CIMD is enabled. Advertising none or private_key_jwt unconditionally would tell every client that secretless authentication is available, when the only clients that could use either are ones this server would refuse.

Claude requires BOTH, and says nothing when one is missing

Claude selects CIMD only when the authorization server advertises "client_id_metadata_document_supported": true and lists "none" in token_endpoint_auth_methods_supported. Either one alone is not enough. With only one present, Claude silently falls back to dynamic client registration — which this server does not implement — and the whole connection presents as a confusing DCR attempt with no mention of CIMD anywhere in it.

This package emits the two together or not at all, so you cannot get this wrong through clientIdMetadata. It is named here because a reader hand-rolling discovery metadata somewhere else — a gateway rewriting the document, a static /.well-known file, a second authorization server — will get it wrong, and the failure gives no hint about which of the two flags is missing.

What the client's document must contain ​

Fetched from the client_id URL, application/json:

json
{
  "client_id": "https://claude.ai/.well-known/oauth-client",
  "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "none",
  "logo_uri": "https://claude.ai/logo.png",
  "client_uri": "https://claude.ai",
  "tos_uri": "https://claude.ai/terms",
  "policy_uri": "https://claude.ai/privacy",
  "scope": "openid profile contacts.read"
}
FieldRule
client_idRequired, and must equal the URL it was fetched from. This is the entire binding. Without it any allowlisted host could serve a document claiming to be some other client.
client_nameRequired, non-empty. A client with no name cannot be meaningfully consented to, and defaulting it to the hostname puts a URL in front of a user being asked to trust something.
redirect_urisRequired, non-empty. Every entry absolute and https, except http://localhost / http://127.0.0.1 for development. No fragments (RFC 6749 §3.1.2).
token_endpoint_auth_method / token_endpoint_auth_methods_supportedOptional. Together they describe the client's offered auth methods — see private_key_jwt client assertions for how the two combine. The document is refused only if none of the offered methods are ones this server supports for a CIMD client (none and private_key_jwt) — a CIMD client holds no secret, so client_secret_basic/client_secret_post never apply.
jwks_uri / jwksRequired when private_key_jwt is the only usable method; optional (and ignored) otherwise. Exactly one of the two — a document naming both is refused. See private_key_jwt client assertions.
scopeOptional. Narrows what the client may request — intersected with clientIdMetadata.allowedScopes and your catalog. A scope you do not have is dropped, not refused.
logo_uri client_uri tos_uri policy_uriOptional, and each must be https. They map onto branding and reach your consent screen as attributes — a javascript: or data: logo URI is refused here because this is the last place that can.

A document that fails any of these is rejected with an error naming the offending field, and the authorization does not proceed.

What you get ​

A row in oauth_clients like any other client, with three differences:

ts
{
  clientId: 'https://claude.ai/.well-known/oauth-client',
  type: 'public',              // no secret; PKCE is the binding
  registration: 'cimd',        // re-derived from the document
  metadataUrl, metadataFetchedAt, metadataEtag,
  secrets: [],
  // The usable (intersected) auth method set. Absent on a manual
  // registration. `['none']` for a document like Claude's above;
  // `['none', 'private_key_jwt']` for one like ChatGPT's, below.
  tokenEndpointAuthMethods: ['none'],
  // Set only when `private_key_jwt` is usable — exactly one of the two,
  // mirroring the document's own `jwks_uri` / `jwks`.
  jwksUri: undefined,
  jwks: undefined,
}

It appears in oauth.clients.list(), shows on a user's connected-apps screen, and is revoked by oauth.clients.disable(clientId) exactly like a manual one.

Because a cimd row is re-derived on every cache miss, anything you edit on one through clients.update() is overwritten on the next fetch — with one deliberate exception, below.

disable() survives a re-fetch ​

A disabled CIMD client stays disabled. The status check runs before the fetch, so a disabled client is an answer rather than a cache miss, and the write-back never sets status at all — a brand-new row gets active on insert and nothing ever puts it back.

This is the most likely bug in the whole feature (revoke a client, its document gets re-fetched an hour later, it quietly reactivates) and there is a test named after exactly that failure.

Public clients ​

CIMD and "public" are two different things, and conflating them is the mistake this section exists to prevent.

Question it answersValues
registrationHow the registration was discoveredmanual — you called clients.create(). cimd — derived from the client's own metadata document.
typeHow the client authenticates at /tokenconfidential — presents a secret. public — presents client_id alone, with PKCE standing in for the secret.

Every CIMD client is public, because a metadata document has no secret in it. The reverse does not hold: a public client does not have to be a CIMD client.

Codex CLI is the counter-example, and it is why this distinction has to be written down. codex mcp login takes exactly three OAuth settings — oauth_client_id, oauth_resource, bearer_token_env_var — and there is no oauth_client_secret among them. It wants a public registration. But it publishes no metadata document anywhere, so CIMD cannot serve it: CIMD requires the client to host the document, and only some clients do (Claude does, at claude.ai; Codex does not). Left with neither, it falls through to dynamic registration and stops with Dynamic client registration not supported.

The path for it is a public client registered by hand, with CIMD left off:

ts
const { clientId } = await oauth.clients.create({
  name: 'Codex CLI',
  type: 'public',                                  // no secret is generated
  redirectUris: ['http://localhost/callback'],     // loopback; the port may vary
  allowedScopes: ['openid', 'contacts.read'],
})
// → { client, clientId, type: 'public' }   — there is no clientSecret to print

type defaults to 'confidential', so nothing you already wrote changes.

The two rules that make it safe ​

A public client authenticates at /token by presenting client_id alone. There is no secret; PKCE is what stands in for it, and PKCE is already mandatory package-wide (S256 only, code_challenge_method required).

Two symmetric rules, neither optional:

  • A public client that presents a secret is refused, not tolerated.
  • A confidential client that omits its secret is refused.

The second is the one that matters. If omitting a secret were enough to authenticate, every confidential registration in your database would be downgradeable to public by anyone who knows a client_id — which is a public value by design. Both violations answer the identical 401 invalid_client, so neither is an oracle for which kind of client an id names.

clients.rotateSecret() on a public client throws, whether it got there through CIMD or through create({ type: 'public' }). There is no secret to rotate, and returning one would hand a provisioning script a credential the token endpoint refuses.

Refresh-token rotation is not optional for these clients either. It is the only protection a public client's refresh token has — there is no second credential an attacker would also need — so every refresh issues a new token and replaying a spent one kills the whole family.

One thing to know about discovery ​

"none" appears in token_endpoint_auth_methods_supported when CIMD is enabled, not when a public client happens to be registered. A hand-registered public client therefore works on a server whose metadata never advertises none, which is fine for a client like Codex that is configured with a client_id rather than reading the document. A client that reads the metadata and refuses to proceed without none needs CIMD enabled as well.

private_key_jwt client assertions ​

OpenAI documents private_key_jwt client assertions (RFC 7523 §2.2) as its preferred CIMD auth method — a client proves itself with a JWT signed by a private key, verified against a public key published in its own metadata document, rather than with a shared secret or with nothing. It is strictly stronger than none+PKCE: asymmetric proof of key possession instead of no credential at all.

This package implements it. A metadata document describes its auth method two ways:

  • token_endpoint_auth_method (singular) — a legacy preference.
  • token_endpoint_auth_methods_supported (plural) — the actual set of methods the client can use.

When the plural field is present, it wins: this server takes its intersection with what it supports for CIMD clients (none and private_key_jwt) and stores that intersection as the usable set. A document offering only the singular field is treated as offering that one method. Absent both, the offered set is ['none'], same as always. The intersection, not the preference, is enforced at /token — a client whose usable set includes both methods may authenticate with either one on any given request.

ChatGPT's live metadata document is the case this exists for. Its singular field prefers private_key_jwt, but its plural field is ["none", "private_key_jwt"] and it serves a jwks_uri:

json
{
  "client_id": "https://chatgpt.com/oauth/kDmjx77UnKOM/client.json",
  "token_endpoint_auth_method": "private_key_jwt",
  "token_endpoint_auth_methods_supported": ["none", "private_key_jwt"],
  "token_endpoint_auth_signing_alg": "RS256",
  "jwks_uri": "https://chatgpt.com/oauth/jwks.json"
}

The resulting client registers exactly like any other CIMD client (type: 'public', registration: 'cimd', no secrets), plus tokenEndpointAuthMethods: ['none', 'private_key_jwt'] and jwksUri: 'https://chatgpt.com/oauth/jwks.json'. It can authenticate at /token either way — client_id + PKCE, or a signed assertion.

Key material: jwks_uri or inline jwks ​

Exactly one of the two, never both — RFC 7591 §2 forbids a document carrying both, and this server refuses one that does. When neither is present:

  • If none is also usable, private_key_jwt is quietly dropped from the usable set and the client registers as none-only. A client that can still authenticate is not refused over a method it merely cannot use here.
  • If none is not also usable, the document is refused outright — there would be no way for this client to ever authenticate.

jwks_uri is fetched through the same hardened path as the CIMD document itself (see What this costs: SSRF below): https: only, no credentials, no fragment, and its host must pass the same allowedHosts check as client_id — a document can point jwks_uri at any host it likes, and the allowlist is what stops that from being a second SSRF vector hiding inside the first. The fetched JWK Set is cached in memory, keyed by jwks_uri, for cacheTtlMs (the same setting the CIMD document cache uses) — a restart means a refetch, since this cache is not persisted.

Verifying an assertion ​

A client_assertion (RFC 7521 §4.2, type urn:ietf:params:oauth:client-assertion-type:jwt-bearer) at /token is verified as follows, and every failure along the way answers the identical 401 invalid_client the secret-based paths already use — there is no different error for "wrong signature" versus "unknown client" versus "expired," because a distinguishable answer is an oracle an attacker can use to enumerate client ids or probe for weaknesses:

  1. The assertion must be client-assertion-type:jwt-bearer, a well-formed compact JWS, and no more than 8KB. A Basic header or client_secret presented alongside it is refused — a client disagreeing with itself about its own auth method is a bug, not a fallback to paper over.
  2. The client is identified by the assertion's unverified iss claim (a client_id in the request body, if present, must agree). The client must exist, be active, and have private_key_jwt in its stored tokenEndpointAuthMethods — a client registered before this feature, or any manually-registered client, has no such field and is refused here, every time.
  3. The header's alg must be RS256 or ES256 — explicitly allowlisted, never read from the key or trusted from the header alone. none and every HMAC algorithm are structurally impossible, not merely rejected.
  4. Candidate keys are selected by kid (when the assertion names one), use (sig or absent), and a key-declared alg (when present). An unknown kid earns one forced refetch of jwks_uri — key rotation is normal, not suspicious — rate-limited to once per jwks_uri per minute so a stream of assertions naming random kids cannot turn this into an outbound-traffic amplifier against whatever host serves that URL.
  5. The signature is verified (jose's jwtVerify, algorithms locked to step 3's allowlist) with audience accepting either the token endpoint URL or the bare issuer, and a 60-second clock tolerance.
  6. iss and sub must both equal the client's id, exp must be no more than one hour out (bounding how long the replay-cache entry below has to live), and jti is required and checked against a bounded in-memory replay cache (10,000 entries, keyed clientId:jti, expiring at the assertion's exp + 60s) — a reused jti is refused and logged as a replay.

None of this reaches the wire: a jose verification error, a fetch failure, a malformed stored jwks — every one is logged server-side at debug and answered with the same opaque failure a wrong client_secret gets.

What is intentionally out of scope ​

  • client_secret_jwt — there is no shared secret with a CIMD client to check an HMAC against.
  • DPoP, mTLS — not implemented anywhere in this package yet.
  • Persisting the fetched JWKS — the cache is in-memory only; a restart refetches. The CIMD document itself is persisted (so a restart does not need to re-fetch that), but its jwks_uri/jwks fields are cheap to re-resolve and rotate on a schedule the vendor controls, not this server's.
  • private_key_jwt for a manually-registered client — clients.create() has no jwks/jwksUri parameter today. The client fields exist to support this later; nothing about the schema forecloses it.

Caching ​

SuccessPersisted in oauth_clients, trusted for cacheTtlMs (default 1h). Survives a restart, so a process boot does not re-fetch for every in-flight authorization.
ETagStored and replayed as If-None-Match. A 304 refreshes the timestamp and reuses the row.
FailureCached in memory for 60s, per instance, bounded at 1000 entries.

Negative caching is a security control, not a performance one. Without it, a bad or hostile client_id can be replayed in a loop to make your authorization server hammer a third party — the endpoint becomes a traffic amplifier pointed at whichever allowlisted host is having a bad day.

What this costs: SSRF ​

Enabling CIMD makes your authorization server issue an outbound HTTP request whose destination comes from an unauthenticated request parameter. That is server-side request forgery by construction, and it is the honest description of the feature rather than a caveat at the bottom of the page.

The allowlist contains most of the risk. Everything else exists because an allowlist alone has been enough to lose before:

ControlWhat it stops
https: only, checked before any socketfile:, gopher:, http: to an internal address
Hostname matched by equality, never suffixevilclaude.ai matching an entry of claude.ai
Subdomains only for a .example.com entryAn allowlist that silently covers a host you did not intend
No URL credentialshttps://a@allowed.host@evil.host/ parser differentials
No non-default port unless the entry names oneReaching an internal service on the allowlisted host
Redirects not followed (redirect: 'manual')An allowlisted host becoming an open proxy to anything, including cloud metadata endpoints. A 3xx is a failure, not a hop.
AbortSignal.timeout(fetchTimeoutMs)A slow endpoint holding your request workers
Body cap enforced while readingA gigabyte "document"; Content-Length is a claim by the party you are defending against
JSON content type requiredBeing fed something that is not a metadata document
Negative cachingReplay-driven outbound amplification

Two things this does not do, and you should know it:

  • No IP-level filtering. An allowlisted host that resolves to a private address will be fetched. The allowlist is a statement of trust in specific hosts; if that trust is misplaced, this feature does not save you.
  • No DNS-rebinding defense. Hostname is validated, then fetch resolves it again. Both are reasons the allowlist should be short and should name vendors you would trust with an outbound request anyway.

If any of that is unacceptable in your environment, leave enabled off and register clients manually with oauth.clients.create(). That path is unchanged and remains the default.

Configuration reference ​

KeyDefaultNotes
enabledfalseOff is the safe default; the SSRF surface does not exist until you turn it on.
allowedHosts—Required when enabled. claude.ai matches that host exactly; .claude.ai also admits subdomains; localhost:8080 names a port. A URL or a * wildcard is a boot error.
cacheTtlMs3_600_000How long a fetched document is trusted.
fetchTimeoutMs5_000Hard ceiling on the outbound request.
maxBytes65_536Response body cap, enforced while reading.
allowedScopesthe full catalogScopes a CIMD client may ever request, whatever its document says. The document's own scope narrows further.

Released under the MIT License.