Local auth

With auth: { mode: "local" }, FrontMCP is its own OAuth 2.1 authorization server. MCP clients find it through its discovery documents, register with it, send the user to a sign-in page it serves, and trade the code they get back for an access token it signs. From then on, every MCP request needs that token. There's no identity provider behind it, so out of the box the sign-in page takes an email address and checks nothing: anyone who can open it can sign in as anyone. Use local mode for development, for a server only you use, or with your own user check in authenticate.

@FrontMcp({ auth: { mode: "local", ...options } })

Reference

auth: { mode: "local" }

Set it in @FrontMcp({ auth }). The options below are all optional.

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "local",
    allowedScopes: ["tickets:read", "tickets:write"],
    login: { title: "Sign in to Help Desk", fields: { deskKey: { type: "password", label: "Desk key", required: true } } },
    authenticate: async ({ fields }) => {
      const agent = await findAgentByDeskKey(fields.deskKey);
      return agent ? { ok: true, sub: agent.id, claims: { team: agent.team } } : { ok: false, message: "That desk key isn't valid." };
    },
  },
})
export default class Server {}

See more examples below.

Options

OptionTypeDefaultWhat it does
authenticate(input, ctx) => Promise<AuthenticateResult>noneYour user check, run when the sign-in form is submitted and before any code is issued. It decides who the user is and can add claims to their token. See Checking users yourself.
loginLoginConfignoneThe sign-in page's title, subtitle, logo and fields, or your own HTML. See Custom login UI.
ui, extrasslot → file map, name → handlernoneYour own React components for the sign-in and consent pages. Node only. See Custom login UI.
requireEmailbooleantrueWithout authenticate, the sign-in form must carry an email, or the answer is 400 Email is required. With false, a sign-in without one is let through as anonymousSubject.
anonymousSubjectstring"local-operator"Who a sign-in without an email is, when requireEmail is false. It's hashed into the sub, so every such sign-in is the same user.
requireRegisteredClientsbooleantrueOnly start a sign-in for a client_id that registered, was listed in dcr.clients, or is a CIMD URL. false gives any client_id and any redirect URI a sign-in page, and the code goes wherever the redirect URI points: for development only. Before 1.8.3 the default was false. See Letting only known clients in.
allowedScopesstring[]["openid", "profile", "email", "offline_access"]The scopes a token can carry. Scopes a client asks for that aren't listed are dropped, and the token response's scope says what was granted. Each entry is a scope name, or a pattern where * matches anything: "tickets:*". The entries without * are the scopes_supported of both discovery documents. Before 1.8.3 every requested scope was granted. See Scopes.
dcrLocalDcrConfigregistration on outside productionControls client registration at POST /oauth/register and which clients and redirect URIs /oauth/authorize accepts. See dcr.
consentConsentConfigoffA screen after sign-in where the user picks the tools this client may call. Other tools answer TOOL_NOT_CONSENTED. See the consent screen.
incrementalAuthIncrementalAuthConfigoffGrants apps one at a time: a token only reaches the apps the user authorized, and a call to another app asks for more. See Progressive auth.
tokenStorage"memory", { redis } or { sqlite }"memory"Where pending sign-ins, codes, refresh tokens, remembered consent and the credential vault live. See Storage.
secureStore"memory", or { redis }, { sqlite } or { backend } with scope and ttlMsmemory, per userWhere this.secureStore keeps its secrets.
local{ issuer?, signKey?, jwks? }noneissuer sets the iss claim of the tokens and the iss parameter of the redirect, without a trailing slash: one is dropped. See Tokens. signKey and jwks are accepted and not used yet: tokens are always signed with JWT_SECRET.
allowDefaultPublicbooleanfalseTurns on grant_type=anonymous at /oauth/token, which hands out a one-day token without signing in. It doesn't let requests without a token in: they still get 401. See Anonymous tokens.
anonymousScopesstring[]["anonymous"]The scopes of an anonymous token.
publicAccess{ tools?, prompts?, rateLimit? }noneThe tools and prompts the holder of an anonymous token may use, and how often. See publicAccess.
cimdCimdConfigonLets a client use the URL of its metadata document as its client_id. See Client ID metadata (CIMD).
providers, federatedAuthUpstreamProviderOptions[], FederatedAuthConfignoneUpstream OAuth providers the user links while signing in; the sign-in page becomes a provider picker, and tools read each provider's token with this.orchestration.getToken(id). See Upstream providers.
expectedAudiencestring or string[]this server's URLThe audiences whose tokens this server accepts. When set, a token's aud must be one of them, in place of the URL the request came to. List every resource URL your clients use. See Tokens.
refresh{ enabled?, skewSeconds? }{ enabled: true, skewSeconds: 60 }Renews the tokens of upstream providers with their refresh tokens, when a tool reads one through this.orchestration less than skewSeconds before it expires, or after; enabled: false never renews them. It works as in remote mode, which shows it. It doesn't change the lifetimes of FrontMCP's own tokens. Changed in 1.9.3: before, it was accepted and never read.

Endpoints

Local mode adds these routes to the server, next to the MCP endpoint:

Method and pathWhat it does
GET /.well-known/oauth-protected-resourceThe protected-resource metadata (RFC 9728) that 401 answers point to: { resource, authorization_servers, scopes_supported, bearer_methods_supported }.
GET /.well-known/oauth-authorization-serverThe authorization-server metadata (RFC 8414): where every endpoint below is, and what they support.
POST /oauth/registerDynamic client registration (RFC 7591). 201 with a new client_id. Off in production unless dcr.enabled says otherwise. Never cached: see below the table.
GET /oauth/authorizeChecks the request and answers with the sign-in page (HTML), and a cookie that ties the sign-in to the user's browser. Problems are a 400 error page.
POST and GET /oauth/callbackWhere the sign-in page's form submits, with POST. Checks the cookie, runs authenticate, shows the consent screen if there is one, then redirects to the client's redirect_uri with code, state and iss. GET still works, for sign-in pages of your own.
POST /oauth/tokenTrades a code for tokens (authorization_code), refreshes them (refresh_token), or issues an anonymous token (anonymous). Form-encoded. Never cached: see below the table.
GET /oauth/userinfo{ sub, email, name } for the bearer token.
GET and POST /oauth/connectConnects a credential in the middle of a session. See Progressive auth.
POST /oauth/ui/extraSide requests from a custom React page.
GET /oauth/provider/<id>/callbackWhere upstream providers send the user back.
GET /.well-known/jwks.jsonAn RSA public key, generated the first time it's asked for. It isn't the key tokens are signed with: they're HS256 with JWT_SECRET. It needs Node's crypto, and outside production it's written to .frontmcp/keys/ in the working directory.

There's no revocation, introspection or OpenID discovery: /oauth/revoke, /oauth/introspect and /.well-known/openid-configuration are 404.

Every response of /oauth/token and /oauth/register, errors included, carries Cache-Control: no-store and Pragma: no-cache, so no proxy or browser keeps a token or a client secret (RFC 6749 §5.1). (Changed in 1.9.3: before, they had neither.)

The URLs in the discovery documents come from each request: the address it was sent to under createFetchHandler(), and its Host header and protocol on FrontMCP's Node server (X-Forwarded-Host and X-Forwarded-Proto only when FRONTMCP_TRUST_PROXY is set), or FRONTMCP_PUBLIC_URL when it's set. issuer, and the protected-resource metadata's authorization_servers, are the token issuer, so they name local.issuer when you set it. scopes_supported lists the entries of allowedScopes that have no *. For a server at http://localhost:3000 with the default allowedScopes, the authorization-server metadata is:

{
  "issuer": "http://localhost:3000",
  "authorization_endpoint": "http://localhost:3000/oauth/authorize",
  "token_endpoint": "http://localhost:3000/oauth/token",
  "userinfo_endpoint": "http://localhost:3000/oauth/userinfo",
  "jwks_uri": "http://localhost:3000/.well-known/jwks.json",
  "registration_endpoint": "http://localhost:3000/oauth/register",
  "token_endpoint_auth_methods_supported": [],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "scopes_supported": ["openid", "profile", "email", "offline_access"],
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true,
  "client_id_metadata_document_supported": true
}

The flow a client follows

This is the OAuth 2.1 authorization-code flow with PKCE, which is what MCP clients implement. The client does every step; the user only sees the sign-in page.

  1. The client sends an MCP request without a token. FrontMCP answers 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource".
  2. It reads that document, then the authorization-server metadata of the server it names, which is the same server.
  3. It registers at /oauth/register with its redirect URIs, unless it's pre-registered or uses a CIMD URL as its client_id.
  4. It makes a random code_verifier, and opens /oauth/authorize in the user's browser with code_challenge (the verifier's SHA-256, base64url) and its client_id, redirect_uri and state. The browser gets the sign-in page and keeps the sign-in cookie that comes with it.
  5. The user fills in the sign-in page. Its form posts to /oauth/callback, with the cookie, which redirects the browser to redirect_uri?code=…&state=…&iss=….
  6. The client posts the code and the code_verifier to /oauth/token and gets an access token and a refresh token.
  7. It sends Authorization: Bearer <access token> on every MCP request, and uses the refresh token for a new pair before the hour is up.

Signing in, step by step sends each of these requests.

/oauth/authorize parameters

ParameterRequiredWhat it does
response_typeyesMust be code.
client_idyesA registered client, one listed in dcr.clients, or a CIMD URL. Any string only with requireRegisteredClients: false.
redirect_uriyeshttp or https. For a registered client, it must be one of its registered URIs.
code_challengeyes43 to 128 characters of base64url.
code_challenge_methodnoOnly S256, the default. plain is refused.
scopenoSpace-separated. Only the scopes in allowedScopes are granted: see Scopes.
statenoHanded back on the redirect.
resourcenoMust be this server's URL, which becomes the token's aud anyway. Anything else is an error, checked after the client: redirected to redirect_uri with error=invalid_request when that URI is one the client registered, a 400 page otherwise.
appsnoWith incrementalAuth, the apps to grant.

Scopes

A token gets the scopes the client asked for that allowedScopes lists, in the order asked, and the others are dropped without an error. The sign-in page lists the granted ones, and the token response's scope says what was granted. With the default allowedScopes, only openid, profile, email and offline_access can be granted, so a scope of your own, like tickets:read, is dropped until you list it.

Tokens

The access token is a JWT signed with HS256 and the JWT_SECRET environment variable. It holds:

ClaimValue
subThe user's id. See Users.
scopeThe granted scopes, "" if there are none.
email, nameWhat the sign-in form sent, when it did.
isslocal.issuer; else FRONTMCP_PUBLIC_URL; else, with FRONTMCP_PUBLIC_HOST set, http://<FRONTMCP_PUBLIC_HOST>:<http.port>, where http.port defaults to the PORT environment variable, or 3000; else the address the request came to, like aud. The same on FrontMCP's Node server and under createFetchHandler(), and the discovery documents and the redirect's iss name the same issuer.
iat, expIssued now, expires in one hour.
jtiA random id.
audThe resource the token is for: the resource of the authorize request, or else this server's URL, built from the request like the discovery documents' URLs. Refreshed tokens keep it.
Your claimsWhat authenticate returned in claims, and consent and authorized_apps when those features are on.
  • An access token lasts 3600 seconds and a refresh token 30 days. Neither can be configured.
  • Every refresh returns a new refresh token, and the old one stops working.
  • Using a code twice fails, and also cancels the refresh token the first exchange issued.
  • FrontMCP checks a token's signature and expiry, that its iss is the one this server would issue for the request, and that its aud is the URL the request came to, or one of expectedAudience when you set it. With expectedAudience, a token issued at any of the listed addresses is accepted at the others too. So a token works only on the server that issued it, even when other servers share its JWT_SECRET, and only at the address it was issued at: they answer 401 with unexpected "iss" claim value or Token audience does not match this resource.
  • this.auth reads the token: user.sub, user.email and user.name from the claims of the same name, scopes from scope, and every claim in claims.

Users

  • Without authenticate, sub is a hash of the email address, shaped like a UUID. The same address, in any case, is always the same user, so a user keeps their data across sign-ins.
  • With authenticate, sub is the sub it returns, or a hash of one login field when login.subject is { fromField, strategy: "per-account" }, or a hash of anonymousSubject.
  • Local mode keeps no list of users: a user is whoever signs in.

authenticate(input, ctx)

What it is
input.fieldsEvery field the sign-in form sent, by name: email and name on the built-in page, your login.fields otherwise. OAuth parameters like pending_auth_id, client_id and state are left out.
input.resumeOnly when a user connects a credential later: { sub, key, context? }.
ctxget(token) for your providers, fetch(), logger, and the client's clientId and clientName.
{ ok: true, sub?, claims?, credentials? }Signs the user in. sub is their id. claims go into the access token, except the ones FrontMCP sets: sub, iss, aud, exp, iat, nbf, jti, scope, email, name, picture, roles, consent, federated and authorized_apps are dropped. credentials go into the user's credential vault.
{ ok: false, message, retryField? }Shows the sign-in page again with message, and the values the user typed, except in password fields. No code is issued.
A throw, or anything elseThe same page with Authentication failed. Please try again.

roles is one of the dropped claims. To give local users roles, return them under another name, like deskRoles, and point authorities.claimsMapping.roles at it.

Keys and signing

Environment variableWhat it does
JWT_SECRETSigns and checks every token, and the progressive auth tickets and credential links. At least 32 bytes: openssl rand -hex 32.
VAULT_SECRETMixed into the keys of the credential vault and this.secureStore. Defaults to JWT_SECRET.
FRONTMCP_PUBLIC_URLThe server's public address: the URLs in the discovery documents, the aud of its tokens, and their iss unless local.issuer is set.
FRONTMCP_PUBLIC_HOSTWithout local.issuer and FRONTMCP_PUBLIC_URL, makes the iss http://<host>:<http.port> in place of the request's address, where http.port defaults to PORT, or 3000. It changes nothing else, so the discovery documents' other URLs still come from the request. Prefer FRONTMCP_PUBLIC_URL.

Without JWT_SECRET, FrontMCP logs a warning and signs with a random secret made when the process starts, so every token dies with the process, and a second instance can't read the first one's tokens. In production (NODE_ENV=production) the server doesn't start instead: createFetchHandler() and FrontMCP's Node server reject when they're called, with JwtSecretRequiredError (JWT_SECRET is required in production for auth.mode "local"…), or JwtSecretWeakError for a secret under 32 bytes. On an edge isolate, which builds the server on its first request, every request gets 500 JWT_SECRET_REQUIRED or JWT_SECRET_INVALID instead: see Production secrets. (Changed in 1.8.4: createFetchHandler() used to return a handler that answered every request with that 500.) That, and registration being off in production, were checked in Node: the Playground doesn't run in production mode.

Storage

tokenStorage holds everything local mode must remember between requests: pending sign-ins, codes, refresh tokens, remembered consent, and the credential vault.

ValueWhat it's for
"memory"One process. Everything is lost on restart, and a second instance doesn't know the first one's sign-ins: a user who opens /oauth/authorize on one instance and submits the form to another gets Authorization request has expired.
{ redis: { host, port?, password?, db?, tls?, keyPrefix?, defaultTtlMs? } }Several instances, or restarts.
{ sqlite: { path, encryption?: { secret }, walMode?, ttlCleanupIntervalMs?, busyTimeoutMs? } }One machine, across restarts. Needs the @frontmcp/storage-sqlite package.

If the store can't be reached, the server fails closed: it doesn't start, and createFetchHandler() rejects with StorageConnectionError: Failed to connect to Redis: … instead of falling back to memory. The Playground has no Redis or SQLite, and neither backend was run for this page: only that failure. From FrontMCP's code: busyTimeoutMs (default 5000) is how long SQLite waits for a lock another connection holds, and reaches the store since 1.9.0 (1.8.7 accepted it and dropped it here); a database written with another encryption.secret fails with a SqliteDecryptionError that names the secret (new in 1.8.7).

dcr

FieldDefaultWhat it does
enabledon outside productionfalse answers POST /oauth/register with 404 and drops registration_endpoint from the metadata. Clients then sign in only if they're in dcr.clients or use a CIMD URL (or with any client_id, if requireRegisteredClients is false).
clientsnoneClients you register in code: { clientId, redirectUris, clientSecret?, clientName?, tokenEndpointAuthMethod?, grantTypes?, responseTypes?, scope? }. A client with a clientSecret must send it to /oauth/token.
allowedRedirectUrisnoneRedirect URIs allowed at registration, exactly or with *: "http://localhost:*/callback". Without it, registration only accepts loopback addresses (localhost, 127.0.0.0/8, ::1). It doesn't register anyone: with requireRegisteredClients: false, it's also the list an unregistered client's redirect URI must be on.
allowedClientIdsnoneOnly these client_ids may sign in. A registered client that isn't listed is redirected to its redirect_uri with error=unauthorized_client and error_description=client_id "…" is not in the configured allowlist; a client that isn't registered gets the 400 page for unknown clients. CIMD URLs are exempt. Without dcr.clients, it also refuses every registration; with them, registration still answers 201, with a client that can't sign in.
initialAccessTokennoneRegistration needs Authorization: Bearer <token>, or it's 401.
maxDynamicClients1000How many registered clients to keep. Past it, registration answers 503 temporarily_unavailable (Client registration capacity reached; please try again later.); 0 refuses every registration.

A registration asks for token_endpoint_auth_method none (the default), client_secret_post or client_secret_basic, and gets a client_secret for the last two. private_key_jwt is 400 invalid_client_metadata. A registration FrontMCP can't read, like one without redirect_uris, with an empty list or with a method it doesn't know, gets 500 {"error":"server_misconfigured","code":"CONFIG_INVALID",…}, whose message blames the server's configuration: check the client's request, not your config.

this.secureStore

Local mode gives tools this.secureStore, an encrypted key-value store for secrets a user gives your server, like an API key. By default it's in memory and kept per user (sub), so it survives a new sign-in and one user can't read another's.

MethodReturns
get<T>(key)The value, or undefined.
set(key, value, { ttlMs? })Stores any JSON value.
delete(key)true if there was one.
list()The keys.

secureStore: { scope } keys it by "user" (the default), "session" or "global", and { redis }, { sqlite } or { backend } (an object with get, set, delete and list) change where it's kept. "session" is meant for the sessions of clients before MCP 2026-07-28; a request on MCP 2026-07-28 has no session, so there it's kept per user, like "user". Changed in 1.8.4: before, "session" used whatever session id the request named, so a caller who sent another's id read and replaced their secrets.

Anonymous tokens

With allowDefaultPublic: true, a client can post grant_type=anonymous&client_id=<anything> to /oauth/token and get a token without signing in. It's valid for a day, and the refresh token that comes with it doesn't work. Its sub is anon: and a random id, its scope is anonymousScopes, and it has anonymous: true and an aud like any other token. A caller with it is anonymous: this.auth.isAnonymous is true, and this.auth.scopes is anonymousScopes. (Before 1.8.3 the token had a plain random sub and no scopes, and its holder looked signed in.)

A sign-in finishes only in the browser that started it. With the sign-in page, /oauth/authorize sets a cookie for this sign-in: __Host-frontmcp_signin_<id> over HTTPS (Path=/, Secure), or frontmcp_signin_<id> on Path=/oauth over HTTP, both HttpOnly, SameSite=Lax and good for 30 minutes. /oauth/callback refuses a form that doesn't bring it back, with a 400 page: This sign-in was started in another browser, or at another address. Start it again from the app. The redirect that delivers the code clears it. Each sign-in has its own cookie, so a user can have two open at once.

So someone who starts a sign-in can't send another person to the page and receive a token for them. A browser keeps it without you doing anything, but a script that walks the sign-in, like a test, must keep the cookie and send it back, as the client below does. There's no option to turn it off. (New in 1.8.3: sign-ins in progress during the upgrade have to start again.)

Caveats

  • The sign-in and consent pages load their fonts and styles from Google Fonts and cdn.jsdelivr.net, so the user's browser has to reach both; their Content-Security-Policy allows those hosts. See Custom login UI.
  • Links FrontMCP builds for later, like the credential-connect link and the redirect URI it gives upstream providers, start with http://localhost:<http.port> unless you set local.issuer, on every runtime. http.port defaults to the PORT environment variable, or 3000, the port FrontMCP's Node server listens on. (Changed in 1.9.2: before, they said 3001 whenever http.port wasn't set.)
  • An app's own auth only applies to apps with their own endpoint (standalone or splitByApp). On the shared endpoint, a call is checked against the apps the user authorized only with incrementalAuth. See Auth for one app.

Usage

The Playground's own client can't open a browser or sign in, so in each example below the Playground runs the app without auth, and the tests start the same app with local auth through FrontMcpInstance.createFetchHandler() and send it the requests an MCP client would, and the user's browser, with its cookie. The tests send their requests to https://desk.example.com, and under createFetchHandler() the server builds its URLs, and its tokens' iss and aud, from the address a request was sent to, so that's the address they name.

Discovering the server

A client without a token gets a 401 that points to the protected-resource metadata, and that names the authorization server. Both documents are public.

Open
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() {
    const { user, scopes } = this.auth;
    return { sub: user.sub, email: user.email ?? null, scopes };
  }
}

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

/.well-known/jwks.json isn't in the tests: FrontMCP makes its key with Node's crypto, which the Playground doesn't have. In Node it answers { "keys": [{ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "…", "n": "…", "e": "AQAB" }] }.

Signing in, step by step

oauth-client.ts is a small MCP client: one method per request of the flow. The tests walk through it and then call a tool with the token.

Open
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,
    private readonly origin: string,
  ) {}

  /** A client of a new server with this config, reached at this address. */
  static async for(config: Config, origin = SERVER) {
    return new OAuthClient((await FrontMcpInstance.createFetchHandler(config)) as Handler, origin);
  }

  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(this.origin + 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(/=+$/, "");
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A real client opens /oauth/authorize in the user's browser and catches the redirect to redirect_uri (a local port, for a desktop client). The browser keeps the cookie and follows the sign-in page's form to /oauth/callback on its own; the test does the same by keeping the cookie in client.cookies and reading pending_auth_id from the page.

By default iss and aud are both the address the request came to, so a token issued at one address is refused at any other, with unexpected "iss" claim value first. With local.issuer, iss is the same everywhere and aud still ties the token to its address. FrontMCP's Node server does the same (checked in Node).

Refreshing a token

Before the access token's hour is up, the client posts its refresh token. It gets a new access token and a new refresh token, and the old refresh token stops working.

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

test("a refresh returns a new pair and retires the old refresh token", async () => {
  const client = await OAuthClient.for(config);
  const first = await client.signIn({ email: "nour@example.com" }, { scope: "tickets:read" });

  const refreshed = await client.refresh(first.refresh_token);
  expect(refreshed.status).toBe(200);
  expect(refreshed.body).toMatchObject({ token_type: "Bearer", expires_in: 3600, scope: "tickets:read" });
  expect(refreshed.body.refresh_token).not.toBe(first.refresh_token);
  expect(claimsOf(refreshed.body.access_token)).toMatchObject({ sub: claimsOf(first.access_token).sub, aud: claimsOf(first.access_token).aud });

  const again = await client.refresh(first.refresh_token);
  expect(again).toEqual({ status: 400, body: { error: "invalid_grant", error_description: "Refresh token is invalid or expired" } });
});

test("a code works once, and using it again cancels its refresh token", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { pendingAuthId } = await client.authorize();
  const { code } = await client.submit({ pending_auth_id: pendingAuthId!, email: "nour@example.com" });

  const tokens = await client.exchange(code!);
  expect(tokens.status).toBe(200);
  expect(await client.exchange(code!)).toEqual({ status: 400, body: { error: "invalid_grant", error_description: "Authorization code has already been used" } });
  expect((await client.refresh(tokens.body.refresh_token)).status).toBe(400);
});

test("the code only goes to the client that asked, with its verifier", async () => {
  const client = await OAuthClient.for(config);
  await client.register({ redirect_uris: ["http://localhost:3000/callback", "http://localhost:4000/callback"] });
  const { pendingAuthId } = await client.authorize();
  const { code } = await client.submit({ pending_auth_id: pendingAuthId!, email: "nour@example.com" });
  const exchange = (changes: Record<string, string>) =>
    client.token({ grant_type: "authorization_code", code: code!, redirect_uri: "http://localhost:3000/callback", client_id: client.clientId, code_verifier: client.verifier, ...changes });

  expect(await exchange({ code_verifier: "not-the-verifier-this-client-started-with-0123" })).toEqual({ status: 400, body: { error: "invalid_grant", error_description: "PKCE verification failed" } });
  expect(await exchange({ redirect_uri: "http://localhost:4000/callback" })).toEqual({ status: 400, body: { error: "invalid_grant", error_description: "Redirect URI does not match" } });
  expect(await exchange({ client_id: "someone-else" })).toEqual({ status: 400, body: { error: "invalid_grant", error_description: "Client ID does not match" } });
  expect((await exchange({})).status).toBe(200); // none of those used the code up
});

test("a client registered without refresh_token can still refresh", async () => {
  const client = await OAuthClient.for(config);
  await client.register({ grant_types: ["authorization_code"] });
  const { refresh_token } = await client.signIn({ email: "nour@example.com" });
  expect((await client.refresh(refresh_token)).status).toBe(200);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A failed exchange doesn't use the code up: the client that started the sign-in can still exchange it. grant_types from registration isn't enforced: a client registered with only authorization_code can refresh too. A code lasts 60 seconds and an open sign-in page 10 minutes; those are fixed in the build, and the Playground doesn't wait that long.

Checking users yourself

authenticate runs when the sign-in form is submitted. Here the sign-in page asks for a desk key instead of an email, and authenticate looks the key up. A wrong key shows the page again with a message; a right one signs the agent in with their own id and team.

Open
import { HelpDesk } from "./help-desk.app";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "local" as const,
    login: {
      title: "Sign in to Help Desk",
      fields: { deskKey: { type: "password" as const, label: "Desk key", required: true } },
    },
    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.", retryField: "deskKey" };
      return { ok: true as const, sub: agent.id, claims: { team: agent.team, roles: ["admin"] } };
    },
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

authenticate can reach your own services through ctx.get() and ctx.fetch(). When login.fields replaces the email field, add authenticate: without it, the form still has to carry an email, and every sign-in fails with Email is required.

Letting only known clients in

/oauth/authorize only starts a sign-in for a client it knows: one that registered, one listed in dcr.clients, or a CIMD URL. So it knows the client's redirect URIs, and the code only goes to one of them. requireRegisteredClients: false turns that off. Then any client_id gets the sign-in page, with any redirect URI, and the code goes there: someone can send a user a link to your real sign-in page that ends with their own redirect URI, and get a token for that user.

Open
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "local" as const },
};

/** No registration: the only clients are the ones listed here. */
export const listed = {
  ...config,
  auth: {
    mode: "local" as const,
    dcr: {
      enabled: false,
      clients: [{ clientId: "desk-cli", redirectUris: ["http://localhost:3000/callback"], clientName: "Desk CLI" }],
    },
  },
};

// 🚩 For development only: any client_id, any redirect URI
export const anyClient = { ...config, auth: { mode: "local" as const, requireRegisteredClients: false } };

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Controlling registration

dcr decides who may register and with which redirect URIs, and dcr.clients registers clients in code. A client registered with a secret must send it with every token request.

Open
import { HelpDesk } from "./help-desk.app";

const base = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] };

/** Registration needs a token you hand out, and only for callback URLs on this machine. */
export const invited = {
  ...base,
  auth: {
    mode: "local" as const,
    dcr: { initialAccessToken: "reg-4c1f", allowedRedirectUris: ["http://localhost:*/callback"], maxDynamicClients: 1 },
  },
};

/** No registration: the clients are listed here, one of them with a secret. */
export const listed = {
  ...base,
  auth: {
    mode: "local" as const,
    dcr: {
      allowedClientIds: ["desk-cli", "desk-web"],
      clients: [
        { clientId: "desk-cli", redirectUris: ["http://localhost:3000/callback"] },
        { clientId: "desk-web", clientSecret: "s3cr3t-for-the-web-app", tokenEndpointAuthMethod: "client_secret_post" as const, redirectUris: ["http://localhost:3000/callback"] },
      ],
    },
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Keeping a secret per user

A user gives your server an API key once, and it's there the next time they sign in. this.secureStore keeps it encrypted, under the user's sub.

Open
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "save_crm_key", description: "Remember the user's CRM API key", inputSchema: { key: z.string() } })
export class SaveCrmKey extends ToolContext {
  async execute({ key }: { key: string }) {
    await this.secureStore.set("crm-key", key);
    return { saved: true };
  }
}

@Tool({ name: "crm_key_status", description: "Say whether the user has saved a CRM API key", inputSchema: {} })
export class CrmKeyStatus extends ToolContext {
  async execute() {
    const key = await this.secureStore.get<string>("crm-key");
    return { saved: key !== undefined, endsWith: key?.slice(-4) ?? null };
  }
}

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The memory backing loses everything on restart, like the rest of local mode's state. Use secureStore: { redis } or { sqlite } to keep it.

Giving clients a token without a user

allowDefaultPublic: true lets a client get a token without a user, from /oauth/token. Requests with no token at all are still refused.

Open
import { HelpDesk } from "./help-desk.app";

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "local" as const, allowDefaultPublic: true, anonymousScopes: ["tickets:read"] },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Running it in production

In production, local mode needs a fixed JWT_SECRET, shared storage if there's more than one instance, and an issuer that matches the public URL. None of this runs in the Playground:

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

// Environment: JWT_SECRET (openssl rand -hex 32), FRONTMCP_PUBLIC_URL=https://desk.example.com, NODE_ENV=production
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: {
    mode: "local",
    local: { issuer: "https://desk.example.com" },
    allowedScopes: ["tickets:read", "tickets:write"],
    authenticate: checkDeskKey,
    tokenStorage: {
      redis: { host: process.env.REDIS_HOST!, port: 6379, password: process.env.REDIS_PASSWORD, tls: true, keyPrefix: "desk:auth:" },
    },
  },
})
export default class Server {}

Auth in production has the full checklist; Configuration files covers keeping secrets out of code.


Troubleshooting

Email is required

The sign-in form reached /oauth/callback without an email, and there's no authenticate. This also happens when login.fields replaced the email field: add an authenticate that checks your fields, or set requireEmail: false.

Authorization request has expired. Please try again.

The pending_auth_id the form sent isn't one the server knows. It was already used (a sign-in that succeeded removes it), the server restarted, or another instance served /oauth/authorize and memory storage doesn't share it. Use tokenStorage: { redis } with more than one instance, and start the sign-in again.

redirect_uri is not registered for this client

The client registered other redirect URIs. A registered client can only use the ones it registered, port included, so a desktop client that picks a new port each time must register again with it.

Registration allows only loopback redirect_uris (localhost, 127.0.0.0/8, ::1) without an allowlist

Registration only accepts local redirect URIs until you list others in dcr.allowedRedirectUris, or register the client in code with dcr.clients. Web clients like hosted chat apps usually identify themselves with CIMD instead.

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

The client isn't registered, isn't in dcr.clients, and its client_id isn't a CIMD URL. Since 1.8.3 that's refused by default (requireRegisteredClients), and a redirect URI on dcr.allowedRedirectUris doesn't make a client known. With memory storage, dynamic registrations are lost on restart, so the client has to register again. In production registration is off: list the client in dcr.clients, or have it use CIMD.

error=unauthorized_client, client_id "…" is not in the configured allowlist

The client is registered, but dcr.allowedClientIds doesn't list it, so /oauth/authorize sends it back to its redirect_uri with this error, and state and iss, instead of a sign-in page. Add its id to the list, or register it in dcr.clients under an id that's listed. Changed in 1.8.4: this used to be a 400 page.

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

The sign-in form reached /oauth/callback without the cookie /oauth/authorize set. The user opened the link in one browser and finished in another, the browser blocks cookies, or the sign-in started at one host name and finished at another, like 127.0.0.1 and localhost: cookies belong to a host. A script that walks the sign-in must keep the cookie and send it back. Sign-ins started before an upgrade to 1.8.3 have to start again.

401 with unexpected "iss" claim value or Token audience does not match this resource

The token was issued by another server that shares JWT_SECRET (another iss), or at another address (another aud, and without local.issuer another iss too). Behind a proxy, the address this server checks against comes from the request's Host unless you set FRONTMCP_PUBLIC_URL, or list the URLs clients use in expectedAudience. A token issued before 1.8.3 has no aud and is refused too, with the second message. After an upgrade that changes the server's iss (to 1.8.4 on createFetchHandler() or with FRONTMCP_PUBLIC_URL, to 1.8.5 on the Node server reached at another address than localhost:<port>, or with FRONTMCP_PUBLIC_HOST or expectedAudience), older tokens are refused once with the first message. In each case the client refreshes the token or signs in again.

invalid_grant: PKCE verification failed, Redirect URI does not match, Client ID does not match, Authorization code has already been used

The token request must use the same client_id and redirect_uri as the authorize request, and the code_verifier whose SHA-256 was the code_challenge. A code works once, for 60 seconds. Authorization code is invalid or expired means it's older than that, or the server restarted with memory storage.

invalid_client: Client authentication failed

The client is registered with a secret (client_secret_post or client_secret_basic, or a clientSecret in dcr.clients) and didn't send it, or sent another one.

code_challenge: Invalid input: expected string, received undefined

The client didn't use PKCE. Local mode requires it, with code_challenge_method=S256; plain gives code_challenge_method must be "S256" (OAuth 2.1).

Every token stops working when the server restarts

JWT_SECRET isn't set, so each process signs with its own random secret. Set it to a fixed value of at least 32 bytes. Refresh tokens also need persistent tokenStorage to survive a restart.

JwtSecretRequiredError, or 500 with JWT_SECRET_REQUIRED or JWT_SECRET_INVALID

NODE_ENV is production and JWT_SECRET is missing or shorter than 32 bytes, so the server doesn't start (JwtSecretWeakError for a short one), or, on an edge isolate, every request, discovery included, fails. Set it: openssl rand -hex 32.

401 with error="invalid_token", error_description="Token is not a valid JWT"

The request's bearer token isn't a JWT at all. A JWT signed with another secret gets signature verification failed instead. Local mode only accepts tokens it issued.