@better-auth/cimd Package#
@better-auth/cimd is a Better Auth plugin that implements Client ID Metadata Document (CIMD) discovery for OAuth providers. Instead of pre-registering clients, a client's client_id is a URL — the authorization server fetches and validates the OAuth metadata document at that URL, allowing dynamic client registration for MCP (Model Context Protocol) and other ecosystems.
The package lives at packages/cimd/src/ and consists of:
| File | Purpose |
|---|---|
index.ts | Plugin entry point and cimd() factory |
resolver.ts | In-memory cache + fetch rate limiting for metadata documents |
client-store.ts | HTTP fetch, validation, and DB persistence of discovered clients |
node.ts | Node.js transport (fetchClientMetadataResource) with DNS pinning and SSRF protection |
types.ts | CimdOptions configuration interface |
validate-metadata-document.ts | Metadata document schema and origin validation |
The plugin requires the oauth-provider plugin to be installed alongside it .
Metadata Fetch Rate Limiting & Caching (resolver.ts)#
resolver.ts is the core of the plugin's fetch governance. Each cimd() plugin instance owns a private closure with its own cache and rate-limiter state — separate instances never share trust state or conditional validators .
Cache#
The cache is an in-memory LRU Map<clientId, CimdMetadataCacheEntry> capped at maxCacheEntries (default: 1,000) . Each entry stores the metadata, an expiresAt timestamp, and HTTP conditional-request validators (ETag, Last-Modified) .
Cache lifetime is computed from HTTP response headers (Cache-Control, Expires, Age, Date, Vary) against the operator-configurable metadataRevalidationInterval (default "60m"), taking the minimum of the origin's advertised lifetime and the operator's ceiling . Responses with no-store, private, or Vary: * are never cached .
On a cache hit that is still fresh, an existing DB client is returned immediately without any network fetch . On a cache hit that is stale, a conditional revalidation request is sent (If-None-Match / If-Modified-Since); a 304 Not Modified response extends the cache entry .
Concurrent requests for the same clientId are de-duplicated via an in-flight Promise map — only one fetch is ever outstanding per client at a time .
Rate Limiting (metadataFetchPolicy)#
The default policy :
| Limit | Default |
|---|---|
minimumFetchInterval | 1 second per client |
maximumConcurrentFetches | 16 (global) |
maximumConcurrentFetchesPerOrigin | 4 |
maximumFetchesPerMinute | 120 (global) |
maximumFetchesPerOriginPerMinute | 30 |
Exceeding any limit returns HTTP 429 temporarily_unavailable . The sliding-window counter prunes timestamps older than 60 seconds . All limits are configurable via CimdOptions.metadataFetchPolicy.
Known gotcha: Metadata documents served with
Cache-Control: private, max-age=0(e.g., some Vercel Connect documents) are never cached, so every authorization triggers a live fetch that immediately hits theminimumFetchIntervalon back-to-back requests. See issue #11136.
Node Transport: ERR_INVALID_IP_ADDRESS on Node ≥ 20#
Background#
@better-auth/cimd/node exports fetchClientMetadataResource, a secure HTTPS transport that resolves DNS once, validates all returned addresses are publicly routable (SSRF protection), then pins the connection to that address while preserving the original hostname for TLS SNI and Host header. It also sets redirect: "error" to prevent open-redirect abuse.
The Bug#
The transport's custom lookup callback used the legacy three-argument form :
lookup: (_hostname, _options, callback) => {
callback(null, pinnedAddress.address, pinnedAddress.family);
}
Since Node 20, net.autoSelectFamily defaults to true. When this is enabled, net.connect calls the lookup override with { all: true } and expects an array of address objects back. Receiving a plain string instead causes Node to index [0] on the string (getting a single character), then read .address off it — yielding undefined — and throw ERR_INVALID_IP_ADDRESS: Invalid IP address: undefined .
Every CIMD metadata fetch fails on Node 20, 22, 24, 25, and 26 with default runtime flags . The error surfaces to callers as {"error":"invalid_client","error_description":"Failed to fetch metadata document (network error or redirect blocked)"}, which falsely implicates the client's document hosting rather than the transport itself.
The bug is present in @better-auth/cimd@1.7.1 and 1.7.2 .
Fix (PR #10730)#
PR #10730 fixes the transport by branching on options.all:
- When
options.allis truthy (Node ≥ 20 default):callback(null, [pinnedAddress])— returns the pinned address as a one-entry array. - Otherwise: keeps the legacy
callback(null, pinnedAddress.address, pinnedAddress.family)form.
The PR also adds a regression test (node.test.ts) that drives the lookup override with { all: true } and asserts the list-response path .
Temporary Workaround#
Until the fix ships, set the process-wide flag:
NODE_OPTIONS=--no-network-family-autoselection
Or pass a custom fetchClientMetadataResource built on plain fetch with HTTPS-only + private-hostname checks — this forgoes DNS pinning but unblocks registration .
Localhost Redirect URI Mismatch for MCP Clients (PR #11090)#
The Bug#
Native clients (e.g., Claude Code) register using http://localhost/callback as a redirect URI — a port-less localhost form. The registration validator correctly accepts this for native clients. However, at authorization time the client requests redirect_uri=http://localhost:<ephemeral-port>/callback (per RFC 8252 §7.3 loopback redirect behavior). The authorization endpoint's redirect matcher applied RFC 8252 port variance only to loopback IP literals (127.0.0.0/8, [::1]), not to the localhost hostname — so the request was rejected with invalid_redirect .
This meant clients could register successfully but never complete authorization.
Claude Code's CIMD metadata document registers "redirect_uris": ["http://localhost/callback", "http://127.0.0.1/callback"] and then requests an ephemeral port at authorization time, directly triggering this gap .
Fix (PR #11090)#
PR #11090 extends findRegisteredRedirectUri() in packages/oauth-provider/src/authorize.ts to treat the localhost hostname identically to a loopback IP literal for port-variance purposes:
- Scheme, host, path, and query must still match exactly.
- Only the port is allowed to differ when the registered URI uses
localhost. - Fragments and userinfo are explicitly rejected before port-variance logic runs.
localhost-lookalike hostnames (e.g.,localhost.evil.example) are not extended this variance .
The fix ships with 6 regression tests covering the new matching paths and boundary conditions .