Custom login UI
In local mode, 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.
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.render, ui.login |
| Consent | POST /oauth/callback | After sign-in, when consent.enabled is on. | consent, ui.consent |
| Provider picker | GET /oauth/authorize | Instead of the sign-in page, when local mode has upstream providers (Upstream providers). | ui.federated |
| Authorize one more app | GET /oauth/authorize | When a signed-in user follows a progressive auth link. | ui.incremental |
| Connect a credential | GET /oauth/connect | When a tool asks for a credential. 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 that came with the page. FrontMCP looks the sign-in up by that id, checks the cookie, runs authenticate, 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). |
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 lists) under "Requested permissions" when there are any, and the client's id at the bottom.
login.render(ctx)
Return a complete HTML document. FrontMCP sends it as the sign-in page, with the headers 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 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.
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, 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) andaddedItems, plustoolson the consent page,providerson the provider picker,erroron the error page, andextrasfor anything else, like the logo. - An import map that loads
react,react-domand@frontmcp/ui/authfromesm.sh, and a script that mounts your component withmountAuthPage()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'sredirect_uri, so the form's redirect can reach the client, andX-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: same-originandCache-Control: no-store. A form your component renders itself should usemethod="POST". Aredirect_uriwith an IPv6 address can't be named in the policy, so loopback clients should use127.0.0.1orlocalhost. - A CSRF token for this sign-in. The form must send it back as
csrf, or the answer is400 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; <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.
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/authorizechecks the client, and itsredirect_uriagainst its registration, before it shows anything, and a problem found there is an error page, not a redirect. An error found later, like a wrongresource, goes back to the redirect URI the client registered. Only withrequireRegisteredClients: falsecan 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, and
/oauth/callbackrefuses 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_idis random, kept on the server, and deleted when the code is issued: an unknown or used id is400 Authorization request has expired. Please try again. - Cross-site submits are refused. A submit to
/oauth/callbackwhoseOriginorReferernames another host is400 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 setOrigin.) - 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
authenticateputs back what the user typed, except inpasswordfields, and so doeslogin.render'svalues. - The consent screen doesn't carry the sign-in. Its form has the
pending_auth_idand 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-originandCache-Control: no-store. So no other site can show them in a frame. Alogin.renderpage gets them all but the Content-Security-Policy. - Custom pages get a CSRF token, and strict headers, as described in
auth.ui.
The built-in pages load Tailwind from cdn.jsdelivr.net and fonts from Google, so the user's browser has to reach both.
Usage
As on Local auth, 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, 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.
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 } };
},
},
};Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
import { HelpDesk } from "./help-desk.app";
const escape = (text: string) =>
text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>" },
},
};Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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.",
},
},
};Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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:
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>
);
}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. 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.
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 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 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@FrontMcpor@App, or from the working directory if FrontMCP loggedauth.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 buildauth.uipages. 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.