# Custom login UI

> The sign-in, consent and error pages FrontMCP serves in local mode, how to change or replace them, what data each one gets, and what protects them.

Source: https://frontmcp.dev/reference/auth/login-ui

In [local mode](https://frontmcp.dev/reference/auth/local), the user doesn't see your MCP server's tools first: they see pages FrontMCP serves while their client signs in. The sign-in page comes from `/oauth/authorize`, a consent screen and error pages from `/oauth/callback`, and a few more pages for progressive auth. You can change the sign-in page's text and fields with `login`, write its HTML yourself with `login.render`, set up the consent screen with `consent`, or replace any page with a React component with `auth.ui`. FrontMCP keeps the OAuth side: the pending sign-in, the checks, the code and the redirect.

```ts
auth: {
  mode: "local",
  login?: { title, subtitle, logoUri, fields, render, subject },
  consent?: { enabled, excludedTools, defaultSelectedTools, customMessage, ... },
  ui?: { login?, consent?, incremental?, federated?, error? },
  extras?: { [name]: handler },
}
```

---

## Reference

### The pages

| Page | URL | Shown | Change it with |
| --- | --- | --- | --- |
| Sign-in | `GET /oauth/authorize` | At the start of every sign-in. | [`login`](#login), [`login.render`](#loginrenderctx), [`ui.login`](#authui-react-pages) |
| Consent | `POST /oauth/callback` | After sign-in, when [`consent.enabled`](#the-consent-screen) is on. | `consent`, `ui.consent` |
| Provider picker | `GET /oauth/authorize` | Instead of the sign-in page, when local mode has upstream `providers` ([Upstream providers](https://frontmcp.dev/reference/auth/upstream-providers)). | `ui.federated` |
| Authorize one more app | `GET /oauth/authorize` | When a signed-in user follows a [progressive auth](https://frontmcp.dev/reference/auth/progressive) link. | `ui.incremental` |
| Connect a credential | `GET /oauth/connect` | When a tool [asks for a credential](https://frontmcp.dev/reference/auth/progressive#connecting-a-credential-later). It reuses the first of `login.fields`. | `login.fields` |
| Error | any of them | When a request is refused before it can be sent back to the client. | `ui.error` |

The sign-in, consent and provider pages post their forms back to FrontMCP, like the connect page. The sign-in form carries a hidden `pending_auth_id`, the id of this sign-in, which FrontMCP made when it showed the page, and the fields the user filled in, and the browser sends the [sign-in cookie](https://frontmcp.dev/reference/auth/local#the-sign-in-cookie) that came with the page. FrontMCP looks the sign-in up by that id, checks the cookie, runs [`authenticate`](https://frontmcp.dev/reference/auth/local#checking-users-yourself), and only then redirects to the client with a code. The page never sees the client's `redirect_uri` checks or the code.

### `login`

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `title` | `string` | `"Sign In"` | The heading, and the tab's title with ` - FrontMCP` after it. |
| `subtitle` | `string` | `Authorize access to <client>` | The line under the heading. |
| `logoUri` | `string` | none | An image shown above the heading. |
| `fields` | `Record<string, LoginFieldConfig>` | email and name | The form's fields, by name. They replace the email and name fields. |
| `render` | `(ctx) => string` | none | Your own page, as a whole HTML document. The options above are then not used. See [`login.render(ctx)`](#loginrenderctx). |
| `subject` | `{ fromField?, strategy? }` | `per-session` | With `strategy: "per-account"`, a user whose `authenticate` returns no `sub` gets one hashed from the field `fromField`, so the same value is always the same user. |

A field (`LoginFieldConfig`) is `{ type, label?, required?, placeholder?, options? }`, where `type` is `"text"`, `"password"`, `"email"`, `"select"` or `"hidden"`, and `options` is `[{ value, label }]` for a `select`. The page shows the scopes it will grant (the ones the client asked for that [`allowedScopes`](https://frontmcp.dev/reference/auth/local#scopes) lists) under "Requested permissions" when there are any, and the client's id at the bottom.

> **Pitfall: Custom fields need authenticate**
`fields` replaces the email field, but without [`authenticate`](https://frontmcp.dev/reference/auth/local#checking-users-yourself) FrontMCP still requires an email, so every sign-in fails with `Email is required`. And an `authenticate` that returns `{ ok: true }` without a `sub`, with no `login.subject`, signs everyone in as the same user: they all get the `sub` of `anonymousSubject`. Return a `sub`, or set `login.subject: { fromField, strategy: "per-account" }`.

### `login.render(ctx)`

Return a complete HTML document. FrontMCP sends it as the sign-in page, with the [headers](#security) of the built-in pages except the Content-Security-Policy, so your page can load what it needs.

| `ctx` | What it is |
| --- | --- |
| `pendingAuthId` | The id of this sign-in. Your form must send it back as `pending_auth_id`. |
| `callbackPath` | Where the form goes: `/oauth/callback`. Use `method="POST"`, so what the user types stays out of the URL. `GET` works too. |
| `fields` | Your `login.fields`, `{}` without them. |
| `clientId`, `clientName` | The OAuth client. `clientName` is the `client_id`, even for a client that registered a `client_name`, unless the client is a [CIMD](https://frontmcp.dev/reference/auth/cimd) URL whose document gives a name. |
| `logoUri` | The client's logo, from its CIMD document. |
| `scopes` | The scopes the sign-in will grant: the ones the client asked for that `allowedScopes` lists. |
| `error`, `values` | When `authenticate` refused the last attempt: its message, and what the user typed, by field name, without `password` fields. |

`render` is only for the sign-in page. The consent screen and the others keep their built-in HTML unless you use `auth.ui`.

> **Pitfall: Escape everything you put in the page**
FrontMCP puts nothing into your HTML for you, so nothing is escaped for you. `clientName` and `logoUri` can come from a client's own metadata document, `values` and `error` repeat what a user typed, and `scopes` are whatever the client asked for. Escape each one before it goes into the page, as `render` does in [the example](#writing-the-page-yourself). In Node, `escapeHtml` and page builders like `buildLoginPage` and `baseLayout` (which takes a `theme` of colors and fonts) are exported by `@frontmcp/auth`, the package local mode is built on.

### The consent screen

With `consent: { enabled: true }`, `/oauth/callback` doesn't redirect right after sign-in. It shows a screen listing the server's tools, and the user picks the ones this client may call. The token gets a `consent` claim with the picked tools, and FrontMCP refuses a call to any other tool with a `TOOL_NOT_CONSENTED` error result. `tools/list` still lists every tool.

FrontMCP keeps the sign-in it has just checked on the server, with the pending sign-in, so the screen's form carries no sign-in fields and `authenticate` doesn't run again. The form carries the sign-in's `pending_auth_id` and a CSRF token, `csrf`: a choice sent without it is `400 Invalid or missing CSRF token`.

| Option | Default | What it does |
| --- | --- | --- |
| `enabled` | `false` | Shows the screen and enforces the choice. |
| `excludedTools` | none | Tools that aren't on the screen and are always allowed. |
| `defaultSelectedTools` | every tool | The tools checked when the screen opens. |
| `requireSelection` | `true` | An empty choice shows the screen again with "Please select at least one tool to continue." With `false`, it's accepted, and the token allows no tool but the excluded ones. |
| `rememberConsent` | `true` | The next sign-in of the same user with the same client skips the screen and reuses the choice, so a user can't change it by signing in again. `false` shows the screen every time. |
| `groupByApp` | `true` | Groups tools under their app, with a "Toggle All" per app. `false` is one flat list. |
| `showDescriptions` | `true` | Shows each tool's description. |
| `allowSelectAll` | `true` | Shows the "Select all tools" and per-app toggles. |
| `customMessage` | `Choose which tools <client> can access. You can change this later.` | The line under the heading. |

Tools are listed and recorded by name (`close_ticket`). The remembered choices live in [`tokenStorage`](https://frontmcp.dev/reference/auth/local#storage), memory by default. To let a user change their mind, set `rememberConsent: false`: the client signs in again, and the user sees the screen.

### `auth.ui`: React pages

`ui` maps a page to a `.tsx` or `.jsx` file whose default export is a React component: `ui: { login: "./auth/login.tsx" }`. The pages are `login`, `consent`, `incremental`, `federated` and `error`; the others keep their built-in HTML. A relative path is resolved from the file that declares `@FrontMcp` or `@App`, or from the working directory when FrontMCP can't tell (it logs a warning then).

FrontMCP transpiles the file on the server, once, and serves a page that renders it in the browser:

- The page's state, as `window.__FRONTMCP_AUTH__`: `slot`, `pendingAuthId`, `clientId`, `clientName`, `scopes`, `redirectUri`, `csrfToken`, `submitUrl` (`/oauth/callback`), `extraUrl` (`/oauth/ui/extra`) and `addedItems`, plus `tools` on the consent page, `providers` on the provider picker, `error` on the error page, and `extras` for anything else, like the logo.
- An import map that loads `react`, `react-dom` and `@frontmcp/ui/auth` from `esm.sh`, and a script that mounts your component with `mountAuthPage()` from `@frontmcp/ui/auth`.
- The headers `Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://esm.sh; connect-src 'self' https://esm.sh; style-src 'self' 'unsafe-inline' https://esm.sh; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' <origin>`, where `<origin>` is the origin of the client's `redirect_uri`, so the form's redirect can reach the client, and `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin` and `Cache-Control: no-store`. A form your component renders itself should use `method="POST"`. A `redirect_uri` with an IPv6 address can't be named in the policy, so loopback clients should use `127.0.0.1` or `localhost`.
- A CSRF token for this sign-in. The form must send it back as `csrf`, or the answer is `400 Invalid or missing CSRF token`.

`@frontmcp/ui/auth` (in the `@frontmcp/ui` package) gives the component `useAuthFlow()`, which returns the state and `submitFinish` for the form's `onSubmit`; `useExtraField(name)` and `useAddedItems(name)` for [extras](#extras); `<AuthPageWrapper>`, which by default renders the `<form>` with the hidden `pending_auth_id` and `csrf`; and, without React, `getAuthFlow()`, `submitFinish()` and `submitExtra()` from `@frontmcp/ui/auth/vanilla`.

If a file can't be built, FrontMCP logs `Failed to build auth.ui page for slot "login": …` and serves the built-in page, and doesn't try that file again.

#### `extras`

`extras` maps a name to a server function that the page can call before it submits: to check a field, or to collect a list the user adds to one item at a time.

```ts
extras: {
  "teams:add": async (input, ctx) => {
    const team = String(input.team ?? "").trim();
    if (!team) return { ok: false, error: "Enter a team." };
    return { ok: true, addedItems: [{ team }] };
  },
},
```

The page posts `{ action: "teams:add", pending_auth_id, csrf, ...fields }` to `POST /oauth/ui/extra`. The function gets the fields and `ctx`: `{ name, pendingAuthId, current }`, where `current` is what it accepted so far in this sign-in. It returns `{ ok, error?, addedItems?, sideEffects? }`; FrontMCP appends `addedItems` to that list and answers with every list, `{ ok: true, addedItems: { "teams:add": [{ team: "billing" }] } }`. A wrong `csrf` is `400 Invalid or missing CSRF token`; a function that throws gives `Validation failed. Please try again.` Without any extras, the route answers `404 No extras are configured.` The lists and CSRF tokens are kept in the server's memory for the sign-in, not in `tokenStorage`.

### Security

What FrontMCP does on every page:

- **The code only goes to a known place.** `/oauth/authorize` checks the client, and its `redirect_uri` against its registration, before it shows anything, and a problem found there is an error page, not a redirect. An error found later, like a wrong `resource`, goes back to the redirect URI the client registered. Only with [`requireRegisteredClients: false`](https://frontmcp.dev/reference/auth/local#letting-only-known-clients-in) can a client that never registered name any redirect URI.
- **A sign-in finishes only in the browser that started it.** The page comes with a [sign-in cookie](https://frontmcp.dev/reference/auth/local#the-sign-in-cookie), and `/oauth/callback` refuses a form without it: `400 This sign-in was started in another browser, or at another address. Start it again from the app.`
- **A sign-in can't be finished twice, or by guessing.** `pending_auth_id` is random, kept on the server, and deleted when the code is issued: an unknown or used id is `400 Authorization request has expired. Please try again.`
- **Cross-site submits are refused.** A submit to `/oauth/callback` whose `Origin` or `Referer` names another host is `400 Cross-origin request blocked`. Browsers set those headers themselves, so another site can't fake them; a request with neither is let through. (Checked on a Node server: a script in the Playground can't set `Origin`.)
- **What the user types stays out of URLs.** The forms post, so the fields aren't in the browser's history or in access logs. A sign-in page shown again after a failed `authenticate` puts back what the user typed, except in `password` fields, and so does `login.render`'s `values`.
- **The consent screen doesn't carry the sign-in.** Its form has the `pending_auth_id` and a CSRF token, and FrontMCP keeps the sign-in it checked on the server.
- **The built-in pages send strict headers:** `Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin` and `Cache-Control: no-store`. So no other site can show them in a frame. A `login.render` page gets them all but the Content-Security-Policy.
- **Custom pages get a CSRF token, and strict headers**, as described in [`auth.ui`](#authui-react-pages).

The built-in pages load Tailwind from `cdn.jsdelivr.net` and fonts from Google, so the user's browser has to reach both.

> **Note**
Changed in 1.8.3: the built-in forms used `GET`, which put everything the user typed, passwords too, in the URL of `/oauth/callback`; a refused sign-in wrote the password back into the page; the consent form carried the sign-in fields; and the built-in pages had no security headers. A `login.render` page of your own should now post its form too.

---

## Usage

As on [Local auth](https://frontmcp.dev/reference/auth/local#usage), the Playground runs the app without `auth`, and the tests start it with local auth and send the requests a browser would. `oauth-client.ts` is the small client from [Signing in, step by step](https://frontmcp.dev/reference/auth/local#signing-in-step-by-step), which keeps the sign-in cookie like a browser.

### Changing the sign-in page

Help desk agents sign in with a desk key, not an email. The page gets a title, a line of explanation, the desk's logo and two fields, and `authenticate` checks the key.

```ts server.ts active
import { HelpDesk } from "./help-desk.app";

const agents: Record<string, string> = { "dk-7f3a": "agent-nour", "dk-91c0": "agent-sam" };

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "local" as const,
    allowedScopes: ["tickets:read", "tickets:write"],
    login: {
      title: "Sign in to Help Desk",
      subtitle: "Use the desk key from your welcome email.",
      logoUri: "https://desk.example.com/logo.png",
      fields: {
        deskKey: { type: "password" as const, label: "Desk key", required: true, placeholder: "dk-…" },
        queue: {
          type: "select" as const,
          label: "Queue",
          options: [{ value: "billing", label: "Billing" }, { value: "hardware", label: "Hardware" }],
        },
      },
    },
    authenticate: async ({ fields }: { fields: Record<string, string> }) => {
      const agent = agents[fields.deskKey];
      if (!agent) return { ok: false as const, message: "That desk key isn't valid." };
      return { ok: true as const, sub: agent, claims: { queue: fields.queue } };
    },
  },
};
```

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

@Tool({ name: "my_queue", description: "Show the caller's queue", inputSchema: {} })
export class MyQueue extends ToolContext {
  async execute() {
    return { agent: this.auth.user.sub, queue: this.auth.claims.queue ?? null };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [MyQueue] })
export class HelpDesk {}
```

```ts login-page.test.ts
import { test, expect } from "@frontmcp/testing";
import { OAuthClient, claimsOf } from "./oauth-client";
import { config } from "./server";

test("the page has the title, the explanation, the logo and the fields", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { page } = await client.authorize();
  expect(page).toContain("<title>Sign in to Help Desk - FrontMCP</title>");
  expect(page).toContain("Use the desk key from your welcome email.");
  expect(page).toContain('<img src="https://desk.example.com/logo.png"');
  expect(page).toContain('<input type="password" id="field-deskKey" name="deskKey" required placeholder="dk-…"');
  expect(page).toContain('<option value="hardware">');
  expect(page).not.toContain('name="email"');
  expect(page).toContain('<form method="POST" action="/oauth/callback">');
});

test("the page lists the scopes it will grant", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { page } = await client.authorize({ scope: "tickets:read tickets:write admin" });
  expect(page).toContain("Requested permissions");
  expect(page).toContain("tickets:read, tickets:write</p>"); // admin isn't in allowedScopes
});

test("a refused sign-in shows the message, and puts back what the user typed but the password", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { pendingAuthId } = await client.authorize();
  const { status, page } = await client.submit({ pending_auth_id: pendingAuthId!, deskKey: "dk-0000", queue: "hardware" });
  expect(status).toBe(200);
  expect(page).toContain("That desk key isn&#39;t valid.");
  expect(page).toContain('<option value="hardware" selected>');
  expect(page).not.toContain("dk-0000");
});

test("the built-in page is sent with security headers, and loads styles from CDNs", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const response = await client.send(`/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: client.clientId, redirect_uri: "http://localhost:3000/callback", code_challenge: "x".repeat(43) })}`);
  expect(response.headers.get("content-security-policy")).toBe(
    "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
  );
  expect(response.headers.get("x-frame-options")).toBe("DENY");
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
  expect(response.headers.get("referrer-policy")).toBe("same-origin");
  expect(response.headers.get("cache-control")).toBe("no-store");
  const page = await response.text();
  expect(page).toContain('<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>');
  expect(page).toContain("https://fonts.googleapis.com");
});

test("without a sub, everyone is one user, unless login.subject derives one", async () => {
  const noSub = async () => ({ ok: true as const });
  const same = await OAuthClient.for({ ...config, auth: { ...config.auth, authenticate: noSub } });
  const a = claimsOf((await same.signIn({ deskKey: "dk-7f3a" })).access_token).sub;
  const b = claimsOf((await same.signIn({ deskKey: "dk-91c0" })).access_token).sub;
  expect(a).toBe(b);

  const perAccount = { ...config.auth, authenticate: noSub, login: { ...config.auth.login, subject: { fromField: "deskKey", strategy: "per-account" as const } } };
  const derived = await OAuthClient.for({ ...config, auth: perAccount });
  const nour = claimsOf((await derived.signIn({ deskKey: "dk-7f3a" })).access_token).sub;
  expect(claimsOf((await derived.signIn({ deskKey: "dk-91c0" })).access_token).sub).not.toBe(nour);
  expect(claimsOf((await derived.signIn({ deskKey: "dk-7f3a" })).access_token).sub).toBe(nour);
});

test("a right key signs the agent in", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ deskKey: "dk-91c0", queue: "hardware" });
  const call = await client.callTool(access_token, "my_queue");
  expect(call.body.result.structuredContent).toEqual({ agent: "agent-sam", queue: "hardware" });
});
```

```ts oauth-client.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

export const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://localhost:3000/callback";

/** An MCP client's side of local-mode OAuth, and the user's browser: one request per step. */
export class OAuthClient {
  clientId = "";
  /** PKCE: the client keeps this secret and only sends its SHA-256 to /oauth/authorize. */
  readonly verifier = "a-random-string-of-43-to-128-characters-0123456789";
  /** The browser's cookies for the server: /oauth/authorize sets one, and the form must send it back. */
  readonly cookies = new Map<string, string>();

  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new OAuthClient((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  async send(path: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(SERVER + path, { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const name = pair.slice(0, pair.indexOf("="));
      const value = pair.slice(pair.indexOf("=") + 1);
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** 1. Register (dynamic client registration). */
  async register(metadata: Record<string, unknown> = {}) {
    const response = await this.send("/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], grant_types: ["authorization_code", "refresh_token"], ...metadata }),
    });
    const body = await response.json();
    if (response.status === 201) this.clientId = body.client_id;
    return { status: response.status, body };
  }

  /** 2. Open the sign-in page, and find the pending_auth_id its form carries. */
  async authorize(params: Record<string, string> = {}) {
    const query = new URLSearchParams({
      response_type: "code",
      client_id: this.clientId,
      redirect_uri: REDIRECT_URI,
      state: "xyz",
      code_challenge: await sha256(this.verifier),
      code_challenge_method: "S256",
      ...params,
    });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] };
  }

  /** 3. Submit the sign-in form, as the user's browser does: a POST, with the cookie. */
  async submit(fields: Record<string, string>) {
    const response = await this.send("/oauth/callback", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams(fields),
    });
    const location = response.headers.get("location");
    return { status: response.status, page: await response.text(), location, code: location ? new URL(location).searchParams.get("code") : null };
  }

  /** 4. Trade the code and the PKCE verifier for tokens. */
  exchange(code: string) {
    return this.token({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  refresh(refreshToken: string) {
    return this.token({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: this.clientId });
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return { status: response.status, body: await response.json() };
  }

  /** Steps 1 to 4, for tests that only need a token. */
  async signIn(fields: Record<string, string>, params: Record<string, string> = {}) {
    if (!this.clientId) await this.register();
    const { pendingAuthId } = await this.authorize(params);
    const { code } = await this.submit({ pending_auth_id: pendingAuthId!, ...fields });
    return (await this.exchange(code!)).body;
  }

  /** An MCP tools/call, with or without an access token. */
  async callTool(accessToken: string | undefined, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return { status: response.status, headers: response.headers, body: await response.json() };
  }
}

/** The claims of a JWT, without checking it. */
export function claimsOf(jwt: string) {
  return JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
}

async function sha256(text: string) {
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
  return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
```

### Writing the page yourself

`login.render` returns the whole page. It must post `pending_auth_id` and the fields to `callbackPath`, and escape everything it prints.

```ts server.ts active
import { HelpDesk } from "./help-desk.app";

const escape = (text: string) =>
  text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);

type RenderContext = { pendingAuthId: string; callbackPath: string; clientName: string; scopes: string[]; error?: string; values?: Record<string, string> };

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "local" as const,
    allowedScopes: ["tickets:*"],
    login: {
      fields: { deskKey: { type: "password" as const, label: "Desk key", required: true } },
      render: (ctx: RenderContext) => `<!doctype html>
<html lang="en">
  <head><meta charset="utf-8"><title>Help Desk sign-in</title></head>
  <body>
    <h1>Help Desk</h1>
    <p>${escape(ctx.clientName)} wants to reach your queue${ctx.scopes.length ? ` (${escape(ctx.scopes.join(", "))})` : ""}.</p>
    ${ctx.error ? `<p role="alert">${escape(ctx.error)}</p>` : ""}
    <form method="POST" action="${escape(ctx.callbackPath)}">
      <input type="hidden" name="pending_auth_id" value="${escape(ctx.pendingAuthId)}">
      <label>Desk key <input type="password" name="deskKey" required></label>
      <button>Sign in</button>
    </form>
  </body>
</html>`,
    },
    authenticate: async ({ fields }: { fields: Record<string, string> }) =>
      fields.deskKey === "dk-7f3a" ? { ok: true as const, sub: "agent-nour" } : { ok: false as const, message: "Unknown desk key <try again>" },
  },
};
```

```ts help-desk.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 { sub: this.auth.user.sub };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] })
export class HelpDesk {}
```

```ts render.test.ts
import { test, expect } from "@frontmcp/testing";
import { OAuthClient } from "./oauth-client";
import { config } from "./server";

test("the sign-in page is yours", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { status, page, pendingAuthId } = await client.authorize({ scope: "tickets:read" });
  expect(status).toBe(200);
  expect(page).toMatch(/^<!doctype html>/);
  expect(page).toContain("<title>Help Desk sign-in</title>");
  expect(page).toContain(`wants to reach your queue (tickets:read)`);
  expect(page).toContain('<form method="POST" action="/oauth/callback">');
  expect(pendingAuthId).toMatch(/^[0-9a-f-]{36}$/);
});

test("it's sent with the built-in pages' headers, but no Content-Security-Policy", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const response = await client.send(`/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: client.clientId, redirect_uri: "http://localhost:3000/callback", code_challenge: "x".repeat(43) })}`);
  expect(response.headers.get("content-security-policy")).toBeNull();
  expect(response.headers.get("x-frame-options")).toBe("DENY");
  expect(response.headers.get("referrer-policy")).toBe("same-origin");
  expect(response.headers.get("cache-control")).toBe("no-store");
});

test("a refused sign-in renders it again with the error", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { pendingAuthId } = await client.authorize();
  const { page } = await client.submit({ pending_auth_id: pendingAuthId!, deskKey: "nope" });
  expect(page).toContain('<p role="alert">Unknown desk key &lt;try again&gt;</p>');
});

test("without extras, /oauth/ui/extra is a 404", async () => {
  const client = await OAuthClient.for(config);
  const response = await client.send("/oauth/ui/extra", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "teams:add" }) });
  expect(response.status).toBe(404);
  expect(await response.json()).toEqual({ ok: false, error: "No extras are configured." });
});

test("the form signs the user in", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ deskKey: "dk-7f3a" });
  expect((await client.callTool(access_token, "whoami")).body.result.structuredContent).toEqual({ sub: "agent-nour" });
});
```

```ts oauth-client.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

export const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://localhost:3000/callback";

/** An MCP client's side of local-mode OAuth, and the user's browser: one request per step. */
export class OAuthClient {
  clientId = "";
  /** PKCE: the client keeps this secret and only sends its SHA-256 to /oauth/authorize. */
  readonly verifier = "a-random-string-of-43-to-128-characters-0123456789";
  /** The browser's cookies for the server: /oauth/authorize sets one, and the form must send it back. */
  readonly cookies = new Map<string, string>();

  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new OAuthClient((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  async send(path: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(SERVER + path, { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const name = pair.slice(0, pair.indexOf("="));
      const value = pair.slice(pair.indexOf("=") + 1);
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** 1. Register (dynamic client registration). */
  async register(metadata: Record<string, unknown> = {}) {
    const response = await this.send("/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], grant_types: ["authorization_code", "refresh_token"], ...metadata }),
    });
    const body = await response.json();
    if (response.status === 201) this.clientId = body.client_id;
    return { status: response.status, body };
  }

  /** 2. Open the sign-in page, and find the pending_auth_id its form carries. */
  async authorize(params: Record<string, string> = {}) {
    const query = new URLSearchParams({
      response_type: "code",
      client_id: this.clientId,
      redirect_uri: REDIRECT_URI,
      state: "xyz",
      code_challenge: await sha256(this.verifier),
      code_challenge_method: "S256",
      ...params,
    });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] };
  }

  /** 3. Submit the sign-in form, as the user's browser does: a POST, with the cookie. */
  async submit(fields: Record<string, string>) {
    const response = await this.send("/oauth/callback", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams(fields),
    });
    const location = response.headers.get("location");
    return { status: response.status, page: await response.text(), location, code: location ? new URL(location).searchParams.get("code") : null };
  }

  /** 4. Trade the code and the PKCE verifier for tokens. */
  exchange(code: string) {
    return this.token({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  refresh(refreshToken: string) {
    return this.token({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: this.clientId });
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return { status: response.status, body: await response.json() };
  }

  /** Steps 1 to 4, for tests that only need a token. */
  async signIn(fields: Record<string, string>, params: Record<string, string> = {}) {
    if (!this.clientId) await this.register();
    const { pendingAuthId } = await this.authorize(params);
    const { code } = await this.submit({ pending_auth_id: pendingAuthId!, ...fields });
    return (await this.exchange(code!)).body;
  }

  /** An MCP tools/call, with or without an access token. */
  async callTool(accessToken: string | undefined, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return { status: response.status, headers: response.headers, body: await response.json() };
  }
}

/** The claims of a JWT, without checking it. */
export function claimsOf(jwt: string) {
  return JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
}

async function sha256(text: string) {
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
  return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
```

### Asking which tools a client may use

With a consent screen, the user decides which tools this client gets. Here `list_tickets` is always allowed, and the user picks among the rest.

```ts server.ts active
import { Billing, HelpDesk } from "./apps";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk, Billing],
  auth: {
    mode: "local" as const,
    consent: {
      enabled: true,
      excludedTools: ["list_tickets"],
      customMessage: "Choose what the assistant may do for you.",
    },
  },
};
```

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

@Tool({ name: "list_tickets", description: "List open tickets", inputSchema: {} })
class ListTickets extends ToolContext {
  async execute() {
    return { tickets: ["T-1", "T-2"] };
  }
}

@Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, status: "closed" };
  }
}

@Tool({ name: "refund_invoice", description: "Refund an invoice", inputSchema: { id: z.string() } })
class RefundInvoice extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, refunded: true };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [ListTickets, CloseTicket] })
export class HelpDesk {}

@App({ id: "billing", name: "Billing", tools: [RefundInvoice] })
export class Billing {}
```

```ts consent.test.ts
import { test, expect } from "@frontmcp/testing";
import { OAuthClient, claimsOf } from "./oauth-client";
import { config } from "./server";

/** The CSRF token on the consent screen, which the choice must send back. */
const csrfOf = (page: string) => page.match(/name="csrf" value="([^"]+)"/)![1];

/** Signs in and returns the consent screen. */
async function consentScreen(client: OAuthClient, fields: Record<string, string> = { email: "nour@example.com" }) {
  if (!client.clientId) await client.register();
  const { pendingAuthId } = await client.authorize();
  const screen = await client.submit({ pending_auth_id: pendingAuthId!, ...fields });
  /** Sends the user's choice: one tool ticked, or none. */
  const choose = (tool?: string) => client.submit({ pending_auth_id: pendingAuthId!, csrf: csrfOf(screen.page), consent_submitted: "1", ...(tool ? { tools: tool } : {}) });
  return { ...screen, pendingAuthId: pendingAuthId!, choose };
}

test("after sign-in, the user sees a consent screen instead of a redirect", async () => {
  const { status, location, page, pendingAuthId } = await consentScreen(await OAuthClient.for(config));
  expect(status).toBe(200);
  expect(location).toBeNull();
  expect(page).toContain("Choose what the assistant may do for you.");
  expect(page).toContain("Signed in as:");
  expect(page).toContain('<form method="POST" action="/oauth/callback" id="consent-form">');
  expect(page).toContain(`<input type="hidden" name="pending_auth_id" value="${pendingAuthId}">`);
  expect(page).toMatch(/<input type="hidden" name="csrf" value="[\w-]{43}">/);
  expect(page).not.toContain('name="email"'); // the sign-in stays on the server
  expect(page).toContain('name="tools" value="close_ticket"');
  expect(page).toContain('name="tools" value="refund_invoice"');
  expect(page).not.toContain('value="list_tickets"'); // excluded: always allowed
});

test("the token only reaches the tools the user picked", async () => {
  const client = await OAuthClient.for(config);
  const { code } = await (await consentScreen(client)).choose("close_ticket");
  const { access_token } = (await client.exchange(code!)).body;

  expect(claimsOf(access_token).consent).toEqual({ enabled: true, selectedTools: ["close_ticket", "list_tickets"] });
  expect((await client.callTool(access_token, "list_tickets")).body.result.isError).toBeUndefined();
  expect((await client.callTool(access_token, "close_ticket", { id: "T-1" })).body.result.structuredContent).toEqual({ id: "T-1", status: "closed" });

  const refused = (await client.callTool(access_token, "refund_invoice", { id: "INV-7" })).body.result;
  expect(refused).toMatchObject({ isError: true, _meta: { code: "TOOL_NOT_CONSENTED" } });
  expect(refused.content[0].text).toBe('Tool "billing:refund_invoice" was not consented for this session. Re-authorize and select it to enable access.');

  // tools/list isn't filtered
  const list = await client.send("/", {
    method: "POST",
    headers: { "content-type": "application/json", authorization: `Bearer ${access_token}`, "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
  });
  expect((await list.json()).result.tools.map((tool: { name: string }) => tool.name)).toEqual(["close_ticket", "list_tickets", "refund_invoice"]);
});

test("a choice sent without the screen's CSRF token is refused", async () => {
  const client = await OAuthClient.for(config);
  const { pendingAuthId } = await consentScreen(client);
  const forged = await client.submit({ pending_auth_id: pendingAuthId, consent_submitted: "1", tools: "refund_invoice" });
  expect(forged.status).toBe(400);
  expect(forged.page).toContain("Invalid or missing CSRF token");
});

test("the screen's options", async () => {
  const client = await OAuthClient.for({
    ...config,
    auth: { mode: "local" as const, consent: { enabled: true, groupByApp: false, showDescriptions: false, allowSelectAll: false, requireSelection: false, defaultSelectedTools: ["list_tickets"] } },
  });
  const screen = await consentScreen(client);
  expect(screen.page).toContain(`Choose which tools ${client.clientId} can access. You can change this later.`);
  expect(screen.page).toContain('name="tools" value="list_tickets" class="mt-0.5 w-5 h-5 rounded border-gray-300" checked>');
  expect(screen.page).toContain('name="tools" value="close_ticket" class="mt-0.5 w-5 h-5 rounded border-gray-300">'); // not checked
  for (const gone of ["Select all tools", "Toggle All", "Close a ticket"]) expect(screen.page).not.toContain(gone);

  // requireSelection: false accepts an empty choice: no tool at all
  const { code } = await screen.choose();
  const { access_token } = (await client.exchange(code!)).body;
  expect(claimsOf(access_token).consent).toEqual({ enabled: true, selectedTools: [] });
  expect((await client.callTool(access_token, "list_tickets")).body.result._meta.code).toBe("TOOL_NOT_CONSENTED");
});

test("with authenticate, the sign-in is checked once, and the form doesn't carry it", async () => {
  const keys: string[] = [];
  const client = await OAuthClient.for({
    ...config,
    auth: {
      mode: "local" as const,
      consent: { enabled: true },
      login: { fields: { deskKey: { type: "password" as const, label: "Desk key" } } },
      authenticate: async ({ fields }: { fields: Record<string, string> }) => {
        keys.push(fields.deskKey);
        return fields.deskKey === "dk-7f3a" ? { ok: true as const, sub: "agent-nour" } : { ok: false as const, message: "Unknown desk key." };
      },
    },
  });
  const screen = await consentScreen(client, { deskKey: "dk-7f3a" });
  expect(screen.page).not.toContain("dk-7f3a");
  const { code } = await screen.choose("close_ticket");
  expect(claimsOf((await client.exchange(code!)).body.access_token).sub).toBe("agent-nour");
  expect(keys).toEqual(["dk-7f3a"]);
});

test("an empty choice shows the screen again", async () => {
  const empty = await (await consentScreen(await OAuthClient.for(config))).choose();
  expect(empty.status).toBe(200);
  expect(empty.page).toContain("Please select at least one tool to continue.");
});

test("a tool that wasn't offered is refused", async () => {
  const forged = await (await consentScreen(await OAuthClient.for(config))).choose("delete_everything");
  expect(forged.status).toBe(400);
  expect(forged.page).toContain("Invalid tool selection. Please restart authorization and choose from the available tools.");
});

test("the choice is remembered for the same user and client", async () => {
  const client = await OAuthClient.for(config);
  await (await consentScreen(client)).choose("close_ticket");

  const nour = await consentScreen(client);
  expect(nour.status).toBe(302); // no screen this time
  expect(claimsOf((await client.exchange(nour.code!)).body.access_token).consent.selectedTools).toEqual(["close_ticket", "list_tickets"]);

  const sam = await consentScreen(client, { email: "sam@example.com" });
  expect(sam.status).toBe(200); // a new user sees the screen
});
```

```ts oauth-client.ts hidden
import { FrontMcpInstance } from "@frontmcp/sdk";

type Config = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];
type Handler = (request: Request) => Promise<Response>;

export const SERVER = "https://desk.example.com";
export const REDIRECT_URI = "http://localhost:3000/callback";

/** An MCP client's side of local-mode OAuth, and the user's browser: one request per step. */
export class OAuthClient {
  clientId = "";
  /** PKCE: the client keeps this secret and only sends its SHA-256 to /oauth/authorize. */
  readonly verifier = "a-random-string-of-43-to-128-characters-0123456789";
  /** The browser's cookies for the server: /oauth/authorize sets one, and the form must send it back. */
  readonly cookies = new Map<string, string>();

  private constructor(private readonly server: Handler) {}

  static async for(config: Config) {
    return new OAuthClient((await FrontMcpInstance.createFetchHandler(config)) as Handler);
  }

  async send(path: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; "));
    const response = await this.server(new Request(SERVER + path, { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const name = pair.slice(0, pair.indexOf("="));
      const value = pair.slice(pair.indexOf("=") + 1);
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** 1. Register (dynamic client registration). */
  async register(metadata: Record<string, unknown> = {}) {
    const response = await this.send("/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], grant_types: ["authorization_code", "refresh_token"], ...metadata }),
    });
    const body = await response.json();
    if (response.status === 201) this.clientId = body.client_id;
    return { status: response.status, body };
  }

  /** 2. Open the sign-in page, and find the pending_auth_id its form carries. */
  async authorize(params: Record<string, string> = {}) {
    const query = new URLSearchParams({
      response_type: "code",
      client_id: this.clientId,
      redirect_uri: REDIRECT_URI,
      state: "xyz",
      code_challenge: await sha256(this.verifier),
      code_challenge_method: "S256",
      ...params,
    });
    const response = await this.send(`/oauth/authorize?${query}`);
    const page = await response.text();
    return { status: response.status, page, pendingAuthId: page.match(/name="pending_auth_id" value="([^"]+)"/)?.[1] };
  }

  /** 3. Submit the sign-in form, as the user's browser does: a POST, with the cookie. */
  async submit(fields: Record<string, string>) {
    const response = await this.send("/oauth/callback", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams(fields),
    });
    const location = response.headers.get("location");
    return { status: response.status, page: await response.text(), location, code: location ? new URL(location).searchParams.get("code") : null };
  }

  /** 4. Trade the code and the PKCE verifier for tokens. */
  exchange(code: string) {
    return this.token({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  refresh(refreshToken: string) {
    return this.token({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: this.clientId });
  }

  async token(form: Record<string, string>) {
    const response = await this.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form) });
    return { status: response.status, body: await response.json() };
  }

  /** Steps 1 to 4, for tests that only need a token. */
  async signIn(fields: Record<string, string>, params: Record<string, string> = {}) {
    if (!this.clientId) await this.register();
    const { pendingAuthId } = await this.authorize(params);
    const { code } = await this.submit({ pending_auth_id: pendingAuthId!, ...fields });
    return (await this.exchange(code!)).body;
  }

  /** An MCP tools/call, with or without an access token. */
  async callTool(accessToken: string | undefined, name: string, args: Record<string, unknown> = {}) {
    const response = await this.send("/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    });
    return { status: response.status, headers: response.headers, body: await response.json() };
  }
}

/** The claims of a JWT, without checking it. */
export function claimsOf(jwt: string) {
  return JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
}

async function sha256(text: string) {
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
  return btoa(String.fromCharCode(...digest)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
```

The consent form sends the choice as one `tools` parameter per ticked tool, with the sign-in's `pending_auth_id`, the screen's `csrf` and `consent_submitted=1`, and the browser sends the sign-in cookie with it. A `TOOL_NOT_CONSENTED` result tells the model which tool it was refused; the user gets it by signing in again and ticking it. [Progressive auth](https://frontmcp.dev/reference/auth/progressive) covers asking for more later.

### Using a React component

`auth.ui` needs files on disk and a browser to render them, so it doesn't run in the Playground. This page replaces the sign-in page:

```tsx auth/login.tsx
import React from "react";
import { useAuthFlow } from "@frontmcp/ui/auth";

export default function LoginPage() {
  const { clientName, scopes, error } = useAuthFlow();
  return (
    <main className="login">
      <h1>Sign in to Help Desk</h1>
      <p>{clientName} asks for {scopes.join(", ") || "access to your queue"}.</p>
      {error && <p role="alert">{error}</p>}
      {/* <AuthPageWrapper> around this component already renders the <form> with pending_auth_id and csrf */}
      <label>
        Desk key <input type="password" name="deskKey" required />
      </label>
      <button type="submit">Sign in</button>
    </main>
  );
}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";
import { checkDeskKey } from "./agents";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "local",
    ui: { login: "./auth/login.tsx" },
    authenticate: checkDeskKey,
  },
})
export default class Server {}
```

The sign-in page is then an empty `<div id="frontmcp-auth-root">`, the state in `window.__FRONTMCP_AUTH__`, and a module script with your component, served with the headers listed in [`auth.ui`](#authui-react-pages). React and `@frontmcp/ui/auth` load from `esm.sh`, so the user's browser needs to reach it. This was checked in Node, with a CommonJS build and, on FrontMCP 1.9.2, in an ES module project (`"type": "module"`), which before 1.9.0 fell back to the built-in page: see [Troubleshooting](#the-built-in-page-shows-instead-of-your-authui-component).

---

## Troubleshooting

### `Email is required` after adding `login.fields`

Your fields replaced the email field, but without `authenticate` FrontMCP still asks for one. Add an [`authenticate`](https://frontmcp.dev/reference/auth/local#checking-users-yourself) that checks your fields.

### Every user signs in as the same user

`authenticate` returns `{ ok: true }` without a `sub`, and there's no `login.subject`, so everyone gets the `sub` of `anonymousSubject`. Return the user's id as `sub`.

### `This sign-in was started in another browser, or at another address. Start it again from the app.`

The form reached `/oauth/callback` without the [sign-in cookie](https://frontmcp.dev/reference/auth/local#the-sign-in-cookie) that came with the page. The user finished in another browser, the browser blocks cookies, or your page sends its form to another host than the one that served it. Post to `callbackPath`, on the same host.

### `Invalid or missing CSRF token`

A consent choice, or an `auth.ui` page, submitted without its `csrf` value. The built-in consent screen has it in a hidden field; on an `auth.ui` page it's `window.__FRONTMCP_AUTH__.csrfToken`, which `<AuthPageWrapper>`, `submitFinish()` and `submitExtra()` add, and a form you write yourself must include.

### `Cross-origin request blocked`

The browser sent the sign-in form from a page on another host (its `Origin` or `Referer`). Serve the sign-in page from FrontMCP itself. FrontMCP compares that host with the request's `Host` and `X-Forwarded-Host`, so a proxy that changes `Host` must send `X-Forwarded-Host` with the public one.

### The built-in page shows instead of your `auth.ui` component

FrontMCP couldn't build the file and logged why, as `Failed to build auth.ui page for slot "login": …`:

- `ENOENT: no such file or directory`: the path is wrong. A relative path is resolved from the file with `@FrontMcp` or `@App`, or from the working directory if FrontMCP logged `auth.ui paths will resolve against process.cwd()`. Use an absolute path to be sure.
- `Dynamic require of "path" is not supported`: the server runs FrontMCP 1.8.7 or earlier as an ES module (`import`, with `"type": "module"`), which couldn't build `auth.ui` pages. Upgrade: since 1.9.0 they build in ES module projects too.

FrontMCP doesn't try a broken file again until the server restarts.

### `Invalid tool selection. Please restart authorization and choose from the available tools.`

The consent form sent a tool that wasn't on the screen, such as an excluded tool, or a name that doesn't exist. Only tick tools from the screen.
