# Client ID metadata (CIMD)

> How FrontMCP accepts MCP clients whose client_id is the URL of a metadata document instead of a registration, what it fetches and checks, caching, SSRF protection, every cimd option, and what happens with a bad document.

Source: https://frontmcp.dev/reference/auth/cimd

With Client ID Metadata Documents (CIMD), an MCP client doesn't register with your server. Its `client_id` is an `https` URL, and at that URL the client publishes a small JSON document about itself: its name, its logo, and the redirect URIs it signs users in with. When such a client starts a sign-in, FrontMCP fetches the document, checks it, shows the client's name to the user, and sends the authorization code only to a redirect URI the document lists. Nothing is stored on your server, so it works in production, where [dynamic registration](https://frontmcp.dev/reference/auth/local) is usually off, and a client can connect to a server it has never seen without a registration step. It applies when FrontMCP is the authorization server, in [`local`](https://frontmcp.dev/reference/auth/local) and [`remote`](https://frontmcp.dev/reference/auth/remote) mode, and it's on by default.

```ts
@FrontMcp({ auth: { mode: "local" | "remote", cimd?: { enabled?, cache?, security?, network? }, requireRegisteredClients? } })
```

---

## Reference

### `auth.cimd`

CIMD needs no configuration: in `local` and `remote` mode it's on with the settings below. Set `cimd` to change them. What makes CIMD protect anything is [`requireRegisteredClients`](#cimd-registration-and-requireregisteredclients), which is on by default:

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { NotesApp } from "./notes.app";

@FrontMcp({
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: {
    mode: "local",
    local: { issuer: "https://notes.example.com" },
    cimd: {
      cache: { defaultTtlMs: 600_000 },
      security: { blockedDomains: ["untrusted.example.org"] },
      network: { timeoutMs: 3000 },
    },
  },
})
export default class Server {}
```

[See more examples below.](#usage)

#### Which client ids are metadata URLs

A `client_id` is treated as a metadata URL when it's an `https` URL with a path other than `/`, like `https://notes.example.com/oauth/client.json`. Anything else is an ordinary client id, and FrontMCP fetches nothing for it: `http://…` URLs (unless [`allowInsecureForTesting`](#options) is on and the host is `localhost`), `https://notes.example.com/` with no path, and plain strings. What happens to ordinary ids depends on [`requireRegisteredClients`](#cimd-registration-and-requireregisteredclients).

#### What FrontMCP checks

At `/oauth/authorize`, after the request itself is valid and before the login page (in `local` mode) or the redirect to your provider (in `remote` mode):

1. **The URL.** `security.allowedDomains` and `security.blockedDomains`, then the host itself: `localhost` names and private, loopback and other [blocked addresses](#blocked-addresses) are refused.
2. **The cache.** A document fetched earlier and still fresh is used as it is, and steps 3 to 5 are skipped.
3. **DNS.** Where Node's `node:dns` is available, FrontMCP looks the host up and refuses it if any address it resolves to is blocked, or if it doesn't resolve at all.
4. **The fetch.** A `GET` with `Accept: application/json`, within `network.timeoutMs`, reading at most `network.maxResponseSizeBytes`. Redirects follow `network.redirectPolicy`, and every hop is checked again from step 1.
5. **The document.** It must be JSON, match the [document format](#the-metadata-document), and have a `client_id` equal to the URL it was fetched from, character for character.
6. **The redirect URI.** The request's `redirect_uri` must be one the document lists. See [matching redirect URIs](#holding-the-client-to-its-redirect-uris).

Any failure answers `400` with an HTML page, "Authorization Error", `invalid_request` and a message naming the problem, and never redirects: the redirect URI hasn't been checked, so FrontMCP won't send anyone to it. [Troubleshooting](#troubleshooting) lists every message.

After that, the sign-in goes on as for any client. On `local` mode's login page, the client is shown by its `client_name` and `logo_uri` instead of its URL. At `/oauth/token`, the client sends its URL as `client_id` and proves itself with PKCE; there's no secret.

#### The metadata document

A client publishes a JSON object at its URL:

```json client.json
{
  "client_id": "https://notes.example.com/oauth/client.json",
  "client_name": "Notes Desktop",
  "logo_uri": "https://notes.example.com/logo.png",
  "redirect_uris": ["http://127.0.0.1:33418/callback", "https://notes.example.com/oauth/callback"]
}
```

| Field | Type | Required | What FrontMCP does with it |
| --- | --- | --- | --- |
| `client_id` | URL | Yes | Must equal the URL the document was fetched from. |
| `client_name` | `string`, not empty | Yes | Shown on the login page. |
| `redirect_uris` | URL[], at least one | Yes | The only redirect URIs the client may use. |
| `logo_uri` | URL | | Shown on the login page. |
| `token_endpoint_auth_method` | `"none"`, `"client_secret_basic"`, `"client_secret_post"` or `"private_key_jwt"` | | Checked against that list, and otherwise not used: FrontMCP doesn't authenticate CIMD clients at the token endpoint beyond PKCE, whatever this says. Defaults to `"none"`. |
| `grant_types`, `response_types` | `string[]` | | Not enforced: a document with `grant_types: ["authorization_code"]` can still refresh. |
| `scope` | `string` | | Not enforced: the client can ask for other scopes, and gets the ones the server's [`allowedScopes`](https://frontmcp.dev/reference/auth/local#scopes) lists. |
| `client_uri`, `tos_uri`, `policy_uri`, `jwks_uri` | URL | | Checked to be URLs, and not used. |
| `jwks` | `{ keys: object[] }` | | Checked for shape, and not used. |
| `contacts` | email[] | | Checked to be email addresses, and not used. |
| `software_id`, `software_version`, `software_statement` | `string` | | Not used. |

Other fields are ignored.

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | `false` stops FrontMCP from fetching documents, and `client_id_metadata_document_supported` in its metadata becomes `false`. A metadata URL used as a `client_id` is then refused with `CimdDisabledError`'s message, `CIMD (Client ID Metadata Documents) is disabled on this server`, whatever `requireRegisteredClients` says, unless it's one of your registered clients. Changed in 1.9.3: before, such URLs counted as ordinary ids, and `requireRegisteredClients: false` let them in with any redirect URI. |
| `cache.defaultTtlMs` | `number` | `3600000` (1 hour) | How long a document is kept when its response has no caching headers. |
| `cache.minTtlMs` | `number` | `60000` (1 minute) | The shortest time a document is kept, even with `Cache-Control: no-cache` or `no-store`. |
| `cache.maxTtlMs` | `number` | `86400000` (1 day) | The longest time a document is kept, whatever its headers say. |
| `cache.type`, `cache.redis` | `"memory"` or `"redis"`, `{ url }` or `{ host, port, password?, db?, tls? }`, with `keyPrefix?` | `"memory"`, and `keyPrefix` `"cimd:"` | Where fetched documents are kept: in each server instance's memory, or in Redis, shared by every instance. With `"redis"`, the server connects when it starts, and doesn't start without `redis` or when Redis can't be reached. See [Sharing the cache between instances](#sharing-the-cache-between-instances). Changed in 1.9.3: before, documents were cached in memory whatever `type` said. |
| `security.blockPrivateIPs` | `boolean` | `true` | Refuse `localhost` names and [blocked addresses](#blocked-addresses), literal or through DNS. |
| `security.allowedDomains` | `string[]` | | Only these hosts may serve documents. `example.com` matches `example.com` and every subdomain; `*.example.com` does the same. |
| `security.blockedDomains` | `string[]` | | These hosts, and their subdomains, may not. |
| `security.warnOnLocalhostRedirects` | `boolean` | `true` | Log a warning when all of a document's redirect URIs are on `localhost`, `127.0.0.1` or `::1`: `CIMD client "<url>" has only localhost redirect URIs - this may be a development client`. Nothing else changes. |
| `security.allowInsecureForTesting` | `boolean` | `false` | Accept `http://` metadata URLs on `localhost`, and turn **every** address check off. For tests on your own machine only. |
| `network.timeoutMs` | `number`, at least `100` | `5000` | How long a fetch may take, redirects included. |
| `network.maxResponseSizeBytes` | `number`, at least `1024` | `65536` (64 KB) | The largest document FrontMCP reads. |
| `network.redirectPolicy` | `"deny"`, `"same-origin"` or `"allow"` | `"deny"` | Whether to follow a redirect when fetching the document. The document found at the end must still name the original URL as its `client_id`. |
| `network.maxRedirects` | `number` | `5` | How many redirects `"same-origin"` and `"allow"` follow. |

A number below its minimum is a configuration error, like `Too small: expected number to be >=100` at `auth.cimd.network.timeoutMs`, and the server doesn't start.

#### Caching

FrontMCP keeps each document it fetched, in memory, for a time it works out from the response:

- `Cache-Control: max-age=N`: N seconds, less the response's `Age`. `s-maxage` wins over `max-age`.
- `Cache-Control: no-cache` or `no-store`: `cache.minTtlMs`, so the document is still reused for a minute by default.
- No `Cache-Control`, but `Expires`: until then.
- Neither: `cache.defaultTtlMs`.

The result is kept between `cache.minTtlMs` and `cache.maxTtlMs`. When a document with an `ETag` or `Last-Modified` goes stale, FrontMCP asks again with `If-None-Match` or `If-Modified-Since`, and a `304 Not Modified` keeps the document it has. By default each server instance has its own cache, and a restart empties it; with `cache.type: "redis"`, every instance shares one, and it outlives restarts.

#### Blocked addresses

With `security.blockPrivateIPs` on, FrontMCP refuses to fetch from:

- `localhost`, `localhost.localdomain` and any name ending in `.localhost`;
- IPv4 `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16` (which includes cloud metadata services), `172.16.0.0/12`, `192.0.0.0/24`, `192.168.0.0/16`, `198.18.0.0/15`, `224.0.0.0/4`, `240.0.0.0/4` and `255.255.255.255`;
- IPv6 `::`, `::1`, `fc00::/7`, `fe80::/10`, `fec0::/10`, and IPv6 addresses that embed a blocked IPv4 address, like `::ffff:10.0.0.1`.

Before each fetch, a host name is also looked up in DNS, and refused if any of its addresses is blocked (`Host "localtest.me" resolves to a blocked address (::1): IPv6 loopback address (::1) is not allowed`) or if it doesn't resolve (`DNS resolution failed for "…"; refusing to fetch an unvalidated host`). The lookup needs `node:dns`: where it isn't available, as in this site's Playground in the browser, only literal addresses and `localhost` names are checked. And the lookup is separate from the one `fetch()` makes, so a DNS server that answers differently the second time can still steer the fetch.

#### CIMD, registration and `requireRegisteredClients`

A client can identify itself to FrontMCP in three ways:

| | Metadata URL (CIMD) | Registered (`POST /oauth/register`) | Unregistered id |
| --- | --- | --- | --- |
| `client_id` | An `https` URL | A UUID FrontMCP issued | Anything |
| What FrontMCP stores | Nothing but a cache | The registration, in memory | Nothing |
| Its redirect URIs | Listed in its document | Given at registration | Whatever each request says |
| In production | Works | Off unless you set [`dcr.enabled`](https://frontmcp.dev/reference/auth/local#dcr) in `local` mode; always off in `remote` mode | Refused, like everywhere by default |
| With `requireRegisteredClients` on (the default) | Accepted | Accepted | Refused: `Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL` |
| With `requireRegisteredClients: false` | Accepted | Accepted | Accepted, with any redirect URI |

In `local` mode, `dcr.allowedClientIds` doesn't apply to metadata URLs, but `dcr.allowedRedirectUris` does: a document's redirect URI outside the list is refused with `redirect_uri "…" is not in the configured allowlist`.

So in production, where registration is off, a `remote` server with the defaults serves CIMD clients only, and a `local` server CIMD clients and the ones in `dcr.clients`.

> **Pitfall: Without requireRegisteredClients, CIMD protects nothing**
CIMD holds a client to the redirect URIs in its document. With `requireRegisteredClients: false`, FrontMCP also accepts client ids it knows nothing about, with any redirect URI at all, so a client that doesn't want to be checked just uses a plain id, or an `http://` URL, which isn't a metadata URL. Keep it on, as it is by default since 1.8.3: then every client is either registered or has a document, and every code goes only where one of them says.

#### Caveats

- **Tools can't tell which client is calling.** FrontMCP's token has no claim for the client, and `this.context.authInfo.clientId` is the user's `sub`. The client's name is shown at sign-in and not kept.
- **Only four fields count.** `client_id`, `client_name`, `logo_uri` and `redirect_uris`. A document's `token_endpoint_auth_method`, `jwks`, `scope` and `grant_types` don't restrict the client.
- **The cache is per instance, in memory, unless you choose Redis.** A client that changes its document may wait up to its cache time, a day at most, on each instance.
- **Not in `transparent` mode.** There your identity provider is the authorization server, and it has to support CIMD itself. `cimd` isn't a `transparent` option.

---

## Usage

To test a CIMD client against a server on your own machine, `MockCimdServer` from `@frontmcp/testing` serves its document at `http://localhost`: see [Testing a CIMD client](https://frontmcp.dev/reference/testing/auth#testing-a-cimd-client).

> **Note**
In these Playgrounds, the client "Notes Desktop" publishes its document at `https://203.0.113.10/notes/client.json`, and a `notes-client.example.ts` file answers FrontMCP's fetch in-process, since the Playground can't reach the internet. It's an IP address, from a range reserved for documentation, rather than a name like `notes.example.com`, because FrontMCP looks names up in DNS before it fetches, in Node, and these names don't exist. A real client uses its own domain; your server doesn't need the file. Each Playground's own server is public, so its Call tab works; the tests start the server in `server.ts` with [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler). The test that signs in also plays the user's browser, which keeps the [sign-in cookie](https://frontmcp.dev/reference/auth/local#the-sign-in-cookie) and sends it back with the login form.

### Accepting a client by its metadata URL

The server is in `local` mode with CIMD's defaults. The tests follow Notes Desktop through a whole sign-in, with its URL as `client_id`:

```ts server.ts active
import "./notes-client.example";
import { NotesApp } from "./notes.app";

// In your project: @FrontMcp(config) export default class Server {}
export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "local" as const, allowedScopes: ["notes:read"] },
};
```

```ts notes.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { email: this.auth.user.email ?? null, scopes: this.auth.scopes };
  }
}

@App({ id: "notes", name: "Notes", tools: [WhoAmI] })
export class NotesApp {}
```

```ts notes-client.example.ts
// Stands in for Notes Desktop's web server, which the Playground can't reach: it answers
// FrontMCP's request for the client's metadata document in-process, and records it.
export const CLIENT_ID = "https://203.0.113.10/notes/client.json";

export const document = {
  client_id: CLIENT_ID,
  client_name: "Notes Desktop",
  logo_uri: "https://203.0.113.10/notes/logo.png",
  redirect_uris: ["http://127.0.0.1:33418/callback"],
};

/** Every request FrontMCP made to the client's server, oldest first. */
export const fetches: { url: string; headers: Record<string, string> }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
  if (!url.startsWith("https://203.0.113.10/")) return realFetch(input, init);
  const headers: Record<string, string> = {};
  new Headers(init?.headers).forEach((value, name) => (headers[name] = value));
  fetches.push({ url, headers });
  if (url !== CLIENT_ID) return new Response("Not Found", { status: 404, statusText: "Not Found" });
  return Response.json(document, { headers: { "cache-control": "max-age=3600" } });
};
```

```ts sign-in.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";
import { CLIENT_ID, fetches } from "./notes-client.example";

type Handler = (request: Request) => Promise<Response>;
const CALLBACK = "http://127.0.0.1:33418/callback";
// PKCE from RFC 7636's example
const VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";

async function authorize(handler: Handler) {
  const query = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: CALLBACK, code_challenge: CHALLENGE, code_challenge_method: "S256", scope: "notes:read", state: "st-1" });
  const res = await handler(new Request(`https://notes.example.com/oauth/authorize?${query}`));
  // the sign-in cookie, which the user's browser keeps
  const cookie = res.headers.getSetCookie()[0]?.split(";")[0];
  return { status: res.status, page: await res.text(), cookie };
}

test("FrontMCP says it supports metadata URLs", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const metadata = await (await handler(new Request("https://notes.example.com/.well-known/oauth-authorization-server"))).json();
  expect(metadata.client_id_metadata_document_supported).toBe(true);
});

test("the login page names the client from its document", async () => {
  fetches.length = 0;
  const { status, page } = await authorize(await FrontMcpInstance.createFetchHandler(config));
  expect(status).toBe(200);
  expect(page).toContain("Notes Desktop");
  expect(page).toContain("https://203.0.113.10/notes/logo.png");
  expect(fetches).toEqual([{ url: CLIENT_ID, headers: { accept: "application/json" } }]);
});

test("the client signs in with its URL as client_id, and no secret", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const { page, cookie } = await authorize(handler);
  const pending = page.match(/name="pending_auth_id"\s+value="([^"]+)"/)![1];

  // The user submits the login page, from the browser that opened it
  const login = await handler(
    new Request("https://notes.example.com/oauth/callback", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie! },
      body: new URLSearchParams({ pending_auth_id: pending, email: "nour@example.com" }),
    }),
  );
  const back = new URL(login.headers.get("location")!);
  expect(back.origin + back.pathname).toBe(CALLBACK);

  // The client exchanges the code
  const res = await handler(
    new Request("https://notes.example.com/oauth/token", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ grant_type: "authorization_code", code: back.searchParams.get("code")!, redirect_uri: CALLBACK, client_id: CLIENT_ID, code_verifier: VERIFIER }),
    }),
  );
  const token = await res.json();
  expect(token).toMatchObject({ token_type: "Bearer", scope: "notes:read" });

  // …and calls a tool with the token
  const call = await handler(
    new Request("https://notes.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "whoami", authorization: `Bearer ${token.access_token}` },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  expect((await call.json()).result.structuredContent).toEqual({ email: "nour@example.com", scopes: ["notes:read"] });
});

test("a second sign-in uses the cached document", async () => {
  fetches.length = 0;
  const handler = await FrontMcpInstance.createFetchHandler(config);
  await authorize(handler);
  await authorize(handler);
  expect(fetches).toHaveLength(1);
});
```

A metadata URL counts as a known client, so the default `requireRegisteredClients` doesn't get in the way, and the token has the `notes:read` scope because `allowedScopes` lists it. In [`remote`](https://frontmcp.dev/reference/auth/remote) mode the checks are the same, and a valid client is then sent on to your identity provider instead of the login page.

### Holding the client to its redirect URIs

The code goes only to a redirect URI in the client's document. The comparison ignores a trailing slash and the case of the host, and, for a loopback address, the port, because a desktop app picks a free port each time it signs in:

```ts server.ts active
import "./notes-client.example";
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "local" as const },
};
```

```ts notes.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { email: this.auth.user.email ?? null };
  }
}

@App({ id: "notes", name: "Notes", tools: [WhoAmI] })
export class NotesApp {}
```

```ts notes-client.example.ts
// Stands in for Notes Desktop's web server (see the first example on this page).
export const CLIENT_ID = "https://203.0.113.10/notes/client.json";

const document = {
  client_id: CLIENT_ID,
  client_name: "Notes Desktop",
  redirect_uris: ["http://127.0.0.1:33418/callback", "https://notes.example.com/oauth/callback"],
};

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
  return url === CLIENT_ID ? Response.json(document) : realFetch(input, init);
};
```

```ts redirects.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";
import { CLIENT_ID } from "./notes-client.example";

async function authorize(redirectUri: string) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const query = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: redirectUri, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const res = await handler(new Request(`https://notes.example.com/oauth/authorize?${query}`));
  return { status: res.status, location: res.headers.get("location"), page: await res.text() };
}

test("a listed redirect URI is accepted", async () => {
  expect((await authorize("https://notes.example.com/oauth/callback")).status).toBe(200);
  expect((await authorize("https://NOTES.example.com/oauth/callback/")).status).toBe(200);
});

test("a loopback redirect URI may use any port", async () => {
  expect((await authorize("http://127.0.0.1:51044/callback")).status).toBe(200);
});

test("anything else is refused, without a redirect", async () => {
  const res = await authorize("https://attacker.example.net/callback");
  expect(res.status).toBe(400);
  expect(res.location).toBeNull();
  expect(res.page).toContain(`Redirect URI &quot;https://attacker.example.net/callback&quot; is not registered for client &quot;${CLIENT_ID}&quot;`);
});

test("localhost isn't 127.0.0.1", async () => {
  expect((await authorize("http://localhost:33418/callback")).status).toBe(400);
});
```

A desktop client should list its loopback redirect with the same host it uses: `127.0.0.1` and `localhost` are different hosts to FrontMCP.

### What happens with a bad document

Each of these clients publishes something FrontMCP won't accept. Every one gets the same kind of answer, a `400` error page naming the problem, and the user never reaches the login page:

```ts server.ts active
import "./clients.example";
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "local" as const },
};
```

```ts notes.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { email: this.auth.user.email ?? null };
  }
}

@App({ id: "notes", name: "Notes", tools: [WhoAmI] })
export class NotesApp {}
```

```ts clients.example.ts
// Stands in for the web servers of several clients, each publishing a broken document.
const HOST = "https://203.0.113.10";
const valid = (id: string) => ({ client_id: id, client_name: "Notes Desktop", redirect_uris: ["http://127.0.0.1:33418/callback"] });

const answers: Record<string, () => Response> = {
  "/copied.json": () => Response.json(valid(`${HOST}/notes/client.json`)), // someone else's document
  "/incomplete.json": () => Response.json({ client_id: `${HOST}/incomplete.json`, redirect_uris: [] }),
  "/page.json": () => new Response("<html>Welcome!</html>", { headers: { "content-type": "text/html" } }),
  "/gone.json": () => new Response("Not Found", { status: 404, statusText: "Not Found" }),
  "/moved.json": () => new Response(null, { status: 301, headers: { location: `${HOST}/notes/client.json` } }),
  "/huge.json": () => Response.json({ ...valid(`${HOST}/huge.json`), client_uri: `${HOST}/${"x".repeat(70_000)}` }),
};

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
  const answer = url.startsWith(HOST) ? answers[new URL(url).pathname] : undefined;
  return answer ? answer() : realFetch(input, init);
};
```

```ts bad-documents.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";

/** The message on FrontMCP's error page for a sign-in by this client. */
async function refusal(clientId: string) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const query = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: "http://127.0.0.1:33418/callback", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const res = await handler(new Request(`https://notes.example.com/oauth/authorize?${query}`));
  expect(res.status).toBe(400);
  const messages = [...(await res.text()).matchAll(/<p>([^<]*)<\/p>/g)].map((m) => m[1].replace(/&quot;/g, '"').replace(/&gt;/g, ">"));
  return messages.at(-1);
}

test("a document that names another URL", async () => {
  expect(await refusal("https://203.0.113.10/copied.json")).toBe(
    'CIMD client_id mismatch: URL is "https://203.0.113.10/copied.json" but document contains "https://203.0.113.10/notes/client.json"',
  );
});

test("a document without the required fields", async () => {
  expect(await refusal("https://203.0.113.10/incomplete.json")).toBe(
    "CIMD document validation failed: client_name: Invalid input: expected string, received undefined; redirect_uris: Too small: expected array to have >=1 items",
  );
});

test("a page that isn't JSON", async () => {
  expect(await refusal("https://203.0.113.10/page.json")).toBe("Failed to fetch CIMD document from https://203.0.113.10/page.json: Invalid JSON response");
});

test("a URL that answers 404", async () => {
  expect(await refusal("https://203.0.113.10/gone.json")).toBe("Failed to fetch CIMD document from https://203.0.113.10/gone.json: HTTP 404 Not Found");
});

test("a redirect", async () => {
  expect(await refusal("https://203.0.113.10/moved.json")).toContain("CIMD fetch redirected to \"https://203.0.113.10/notes/client.json\" but redirects are disabled.");
});

test("a document over 64 KB", async () => {
  expect(await refusal("https://203.0.113.10/huge.json")).toMatch(/^CIMD response from https:\/\/203\.0\.113\.10\/huge\.json exceeds maximum size of 65536 bytes/);
});
```

The messages come from FrontMCP's internal errors, and include the client's URL. The page is shown in production too.

### Keeping the fetch off your network

A metadata URL is chosen by whoever starts the sign-in, and FrontMCP fetches it from inside your network. So before anything is fetched, private and loopback addresses are refused, and so is a redirect to one. `allowedDomains` and `blockedDomains` narrow it further:

```ts server.ts active
import "./clients.example";
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: {
    mode: "local" as const,
    cimd: {
      security: { blockedDomains: ["untrusted.example.org"] },
      network: { redirectPolicy: "allow" as const },
    },
  },
};
```

```ts notes.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { email: this.auth.user.email ?? null };
  }
}

@App({ id: "notes", name: "Notes", tools: [WhoAmI] })
export class NotesApp {}
```

```ts clients.example.ts
// Stands in for a client whose metadata URL redirects to a cloud metadata service,
// and records every request FrontMCP makes.
export const fetches: string[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
  if (!url.startsWith("https://203.0.113.10/")) return realFetch(input, init);
  fetches.push(url);
  return new Response(null, { status: 302, headers: { location: "https://169.254.169.254/latest/meta-data/" } });
};
```

```ts ssrf.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";
import { fetches } from "./clients.example";

async function refusal(clientId: string, server: object = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server as typeof config);
  const query = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: "http://127.0.0.1:33418/callback", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const res = await handler(new Request(`https://notes.example.com/oauth/authorize?${query}`));
  expect(res.status).toBe(400);
  const messages = [...(await res.text()).matchAll(/<p>([^<]*)<\/p>/g)].map((m) => m[1].replace(/&quot;/g, '"').replace(/&gt;/g, ">"));
  return messages.at(-1);
}

test("private, loopback and link-local addresses are refused before any fetch", async () => {
  fetches.length = 0;
  expect(await refusal("https://localhost/client.json")).toBe("CIMD security check failed for https://localhost/client.json: Localhost addresses are not allowed");
  expect(await refusal("https://10.0.0.5/client.json")).toBe("CIMD security check failed for https://10.0.0.5/client.json: Private IP addresses (10.x.x.x) are not allowed");
  expect(await refusal("https://[::1]/client.json")).toBe("CIMD security check failed for https://[::1]/client.json: IPv6 loopback address (::1) is not allowed");
  expect(await refusal("https://169.254.169.254/latest/meta-data/")).toBe(
    "CIMD security check failed for https://169.254.169.254/latest/meta-data/: Link-local addresses (169.254.x.x) are not allowed",
  );
  expect(fetches).toEqual([]);
});

test("a redirect is checked like the first URL", async () => {
  expect(await refusal("https://203.0.113.10/notes/client.json")).toBe(
    "CIMD security check failed for https://169.254.169.254/latest/meta-data/: Link-local addresses (169.254.x.x) are not allowed",
  );
});

test("blockedDomains covers subdomains", async () => {
  expect(await refusal("https://app.untrusted.example.org/client.json")).toBe(
    'CIMD security check failed for https://app.untrusted.example.org/client.json: Domain "app.untrusted.example.org" is blocked',
  );
});

test("with allowedDomains, other hosts are refused", async () => {
  const onlyOurs = { ...config, auth: { ...config.auth, cimd: { security: { allowedDomains: ["clients.example.com"] } } } };
  expect(await refusal("https://203.0.113.10/notes/client.json", onlyOurs)).toBe(
    'CIMD security check failed for https://203.0.113.10/notes/client.json: Domain "203.0.113.10" is not in the allowed domains list',
  );
});
```

A host name that passes these checks is then looked up in DNS, in Node, and refused if it resolves to a blocked address. See [Blocked addresses](#blocked-addresses) for the full list, and what the DNS check can't catch.

> **Pitfall: allowInsecureForTesting turns every check off**
`security.allowInsecureForTesting: true` lets a test client publish its document at `http://localhost:…`. It also skips every address check, for every client: with it on, FrontMCP fetches `https://10.0.0.5/…` or `https://169.254.169.254/…` if a sign-in asks it to. Never set it on a server other people can reach.

### Caching the document

A document is fetched once and reused until it's stale. When it has an `ETag`, FrontMCP then asks whether it changed rather than downloading it again. This server keeps documents for no less than `0` ms, so the second sign-in finds the `no-cache` document stale at once:

```ts server.ts active
import "./notes-client.example";
import { NotesApp } from "./notes.app";

export const config = {
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: { mode: "local" as const, cimd: { cache: { minTtlMs: 0 } } },
};
```

```ts notes.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { email: this.auth.user.email ?? null };
  }
}

@App({ id: "notes", name: "Notes", tools: [WhoAmI] })
export class NotesApp {}
```

```ts notes-client.example.ts
// Stands in for two clients' web servers: one sends max-age, the other no-cache with an ETag
// and answers 304 when FrontMCP already has the current version.
export const CACHED = "https://203.0.113.10/cached/client.json";
export const REVALIDATED = "https://203.0.113.10/revalidated/client.json";
export const fetches: { url: string; ifNoneMatch?: string; status: number }[] = [];

const document = (id: string) => ({ client_id: id, client_name: "Notes Desktop", redirect_uris: ["http://127.0.0.1:33418/callback"] });

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
  const ifNoneMatch = new Headers(init?.headers).get("if-none-match") ?? undefined;
  let response: Response;
  if (url === CACHED) response = Response.json(document(url), { headers: { "cache-control": "max-age=600" } });
  else if (url === REVALIDATED && ifNoneMatch === '"v1"') response = new Response(null, { status: 304, headers: { etag: '"v1"' } });
  else if (url === REVALIDATED) response = Response.json(document(url), { headers: { "cache-control": "no-cache", etag: '"v1"' } });
  else return realFetch(input, init);
  fetches.push({ url, ifNoneMatch, status: response.status });
  return response;
};
```

```ts cache.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./server";
import { CACHED, REVALIDATED, fetches } from "./notes-client.example";

async function signInTwice(clientId: string) {
  fetches.length = 0;
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const query = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: "http://127.0.0.1:33418/callback", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const statuses = [];
  for (const _ of [1, 2]) {
    statuses.push((await handler(new Request(`https://notes.example.com/oauth/authorize?${query}`))).status);
    await new Promise((resolve) => setTimeout(resolve, 5));
  }
  return statuses;
}

test("max-age: fetched once", async () => {
  expect(await signInTwice(CACHED)).toEqual([200, 200]);
  expect(fetches).toEqual([{ url: CACHED, ifNoneMatch: undefined, status: 200 }]);
});

test("no-cache with an ETag: asked again, and a 304 keeps the document", async () => {
  expect(await signInTwice(REVALIDATED)).toEqual([200, 200]);
  expect(fetches).toEqual([
    { url: REVALIDATED, ifNoneMatch: undefined, status: 200 },
    { url: REVALIDATED, ifNoneMatch: '"v1"', status: 304 },
  ]);
});
```

With the default `minTtlMs` of a minute, the `no-cache` document would have been reused without asking. Each server instance keeps its own cache in memory, and a restart empties it, unless the cache is in Redis.

### Sharing the cache between instances

With several instances, `cache.type: "redis"` keeps the documents in Redis, so a document one instance fetched is used by all of them, and a restart keeps them:

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { NotesApp } from "./notes.app";

@FrontMcp({
  info: { name: "notes", version: "1.0.0" },
  apps: [NotesApp],
  auth: {
    mode: "local",
    local: { issuer: "https://notes.example.com" },
    cimd: {
      cache: { type: "redis", redis: { url: process.env.REDIS_URL!, keyPrefix: "notes:cimd:" } },
    },
  },
})
export default class Server {}
```

The Playground has no Redis, so this was checked in Node against a local Redis-compatible server: two servers with the same `redis` fetched a client's document once between them, where two servers with the default cache fetched it once each. Each document is stored under `keyPrefix` and the SHA-256 of its URL. The server connects to Redis when it starts, and fails to start rather than fall back to memory: without `redis`, with `Redis configuration is required when cache type is "redis"`, and when Redis can't be reached, with `StorageConnectionError: Failed to connect to Redis: …`.

### Requiring clients to be known

CIMD only means something together with `requireRegisteredClients`, which is on by default. Turned off, it lets FrontMCP accept client ids it knows nothing about, and an `http://` URL is one of those, so a client can skip the document check altogether:

```ts server.ts active
import "./notes-client.example";
import { NotesApp } from "./notes.app";

const info = { name: "notes", version: "1.0.0" };

// The default: requireRegisteredClients is on
export const strict = { info, apps: [NotesApp], auth: { mode: "local" as const } };

// 🚩 For development only
export const lenient = { info, apps: [NotesApp], auth: { mode: "local" as const, requireRegisteredClients: false } };
```

```ts notes.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "whoami", description: "Say who the server thinks is calling", inputSchema: {} })
export class WhoAmI extends ToolContext {
  async execute() {
    return { email: this.auth.user.email ?? null };
  }
}

@App({ id: "notes", name: "Notes", tools: [WhoAmI] })
export class NotesApp {}
```

```ts notes-client.example.ts
// Stands in for Notes Desktop's web server (see the first example on this page).
export const CLIENT_ID = "https://203.0.113.10/notes/client.json";
export const fetches: string[] = [];

const document = { client_id: CLIENT_ID, client_name: "Notes Desktop", redirect_uris: ["http://127.0.0.1:33418/callback"] };

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
  if (!url.startsWith("https://203.0.113.10/")) return realFetch(input, init);
  fetches.push(url);
  return url === CLIENT_ID ? Response.json(document) : new Response("Not Found", { status: 404 });
};
```

```ts known-clients.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { lenient, strict } from "./server";
import { CLIENT_ID, fetches } from "./notes-client.example";

async function authorize(server: typeof strict, clientId: string, redirectUri: string) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const query = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirectUri, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const res = await handler(new Request(`https://notes.example.com/oauth/authorize?${query}`));
  return { status: res.status, page: await res.text() };
}

test("by default, a plain id or an http:// URL is refused", async () => {
  fetches.length = 0;
  for (const clientId of ["notes-desktop", "http://203.0.113.10/notes/client.json"]) {
    const res = await authorize(strict, clientId, "https://attacker.example.net/cb");
    expect(res.status).toBe(400);
    expect(res.page).toContain("Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL");
  }
  expect(fetches).toEqual([]); // neither is a metadata URL
});

test("…and a metadata URL signs in", async () => {
  expect((await authorize(strict, CLIENT_ID, "http://127.0.0.1:33418/callback")).status).toBe(200);
});

test("with requireRegisteredClients: false, they sign in with any redirect URI", async () => {
  fetches.length = 0;
  expect((await authorize(lenient, "notes-desktop", "https://attacker.example.net/cb")).status).toBe(200);
  expect((await authorize(lenient, "http://203.0.113.10/notes/client.json", "https://attacker.example.net/cb")).status).toBe(200);
  expect(fetches).toEqual([]); // no document was ever fetched
});

test("enabled: false refuses metadata URLs, even with requireRegisteredClients: false", async () => {
  fetches.length = 0;
  const off = (server: typeof strict) => ({ ...server, auth: { ...server.auth, cimd: { enabled: false } } });
  for (const server of [off(strict), off(lenient)]) {
    const refused = await authorize(server, CLIENT_ID, "https://attacker.example.net/cb");
    expect(refused.status).toBe(400);
    expect(refused.page).toContain("CIMD (Client ID Metadata Documents) is disabled on this server");
  }
  expect(fetches).toEqual([]);
});
```

With `requireRegisteredClients` on, a client is either registered, with the redirect URIs it gave at registration, or has a document listing them. There's no third way in.

---

## Troubleshooting

Every CIMD failure is a `400` error page at `/oauth/authorize`, "Authorization Error", with `invalid_request` and one of these messages.

### `DNS resolution failed for "…"; refusing to fetch an unvalidated host`

The full message is `CIMD security check failed for <url>: DNS resolution failed for "<host>"; refusing to fetch an unvalidated host`. The server couldn't look the client's host up, so FrontMCP refuses to fetch from it rather than fetch unchecked. Check that the name exists and that the server can reach a DNS server. In tests, publish the stand-in document at a public IP address, as this page's examples do, or at `http://localhost` with [`allowInsecureForTesting`](#options), on your own machine only.

### `resolves to a blocked address`

`CIMD security check failed for <url>: Host "<host>" resolves to a blocked address (<address>): …`. The client's host name points at a private or loopback address. A document meant for your own network can't be used as a metadata URL; register that client instead.

### `Localhost addresses are not allowed`, `Private IP addresses (10.x.x.x) are not allowed`, …

The metadata URL's host is `localhost` or a [blocked address](#blocked-addresses). Clients have to publish their documents on a public `https` host.

### `Domain "…" is not in the allowed domains list` or `Domain "…" is blocked`

`security.allowedDomains` doesn't cover the host, or `security.blockedDomains` does. Both match the domain and its subdomains.

### `Redirect URI "…" is not registered for client "…"`

The sign-in's `redirect_uri` isn't in the document's `redirect_uris`. Trailing slashes and the host's case don't matter, and neither does the port of a loopback address; everything else must match, and `localhost` and `127.0.0.1` are different hosts. If the client just added the URI to its document, FrontMCP may still have the old one: see [the next entry](#a-changed-document-isnt-picked-up).

### A changed document isn't picked up

FrontMCP caches documents, for as long as the response's `Cache-Control` or `Expires` says, or an hour without them, and never less than `cache.minTtlMs`, a minute by default. Wait for it, restart the server, or lower `cache.maxTtlMs`. To have changes picked up quickly, serve the document with a short `max-age` and an `ETag`.

### `CIMD client_id mismatch: URL is "…" but document contains "…"`

The document's `client_id` must be exactly the URL it's served from: same scheme, host, path, and no extra slash. After a redirect, it must still be the first URL.

### `CIMD document validation failed: …`

The document is JSON but doesn't have what [the format](#the-metadata-document) requires: usually a missing `client_name`, an empty `redirect_uris`, or a relative URL where a full one is needed. The message lists each problem, by field.

### `Failed to fetch CIMD document from …`

The rest of the message says why:

- `HTTP 404 Not Found`, or another status: the URL doesn't serve the document.
- `Invalid JSON response`: it served something else, like an HTML page.
- `Request timeout`: it took longer than `network.timeoutMs`, 5 seconds by default.
- `CIMD fetch redirected to "…" but redirects are disabled.`: serve the document at the `client_id` URL itself, or set `network.redirectPolicy`.
- `…which is not the same origin as "…"`: with `"same-origin"`, the redirect went to another host.
- `CIMD fetch exceeded max redirects (5).`: raise `network.maxRedirects`, or fix the redirect loop.

### `CIMD response from … exceeds maximum size of 65536 bytes`

The document is larger than `network.maxResponseSizeBytes`. A real document is a few hundred bytes; check that the URL serves the document and not a page.

### `CIMD (Client ID Metadata Documents) is disabled on this server`

The server has `cimd: { enabled: false }`, and the `client_id` is a metadata URL that isn't one of your registered clients. Turn CIMD back on, or register the client, in `local` mode with [`dcr.clients`](https://frontmcp.dev/reference/auth/local#dcr), under that URL as its `clientId`.

### `Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL`

`requireRegisteredClients` is on, as it is by default, and the `client_id` is neither registered nor a metadata URL. An `http://` URL or a URL without a path counts as neither; use `https` and a path like `/oauth/client.json`.

### The login page shows the client's URL instead of its name

FrontMCP didn't treat the `client_id` as a metadata URL: it isn't `https`, or has no path. FrontMCP fetched nothing and checked nothing, and with `requireRegisteredClients: false` it let the client in anyway. See [Requiring clients to be known](#requiring-clients-to-be-known).
