# Local auth

> FrontMCP as its own OAuth 2.1 authorization server, with auth mode local: every option, the endpoints it serves, the flow a client follows, and what its tokens hold.

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

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`](#checking-users-yourself).

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

---

## Reference

### `auth: { mode: "local" }`

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

```ts 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.](#usage)

> **Pitfall: The built-in sign-in page trusts whatever email it's given**
Without `authenticate`, the sign-in page asks for an email address and a name and signs the user in as that address. It sends no email and checks no password, and the user's id (`sub`) is derived from the address alone. So anyone who can reach your server can sign in as any user, including one your tools treat as an admin. Before real users depend on local mode, add [`authenticate`](#checking-users-yourself), or use [`transparent` or `remote` mode](https://frontmcp.dev/reference/auth/modes) with an identity provider.

#### Options

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `authenticate` | `(input, ctx) => Promise<AuthenticateResult>` | none | Your 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](#checking-users-yourself). |
| `login` | `LoginConfig` | none | The sign-in page's title, subtitle, logo and fields, or your own HTML. See [Custom login UI](https://frontmcp.dev/reference/auth/login-ui). |
| `ui`, `extras` | slot → file map, name → handler | none | Your own React components for the sign-in and consent pages. Node only. See [Custom login UI](https://frontmcp.dev/reference/auth/login-ui#authui-react-pages). |
| `requireEmail` | `boolean` | `true` | Without `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`. |
| `anonymousSubject` | `string` | `"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. |
| `requireRegisteredClients` | `boolean` | `true` | Only start a sign-in for a `client_id` that registered, was listed in `dcr.clients`, or is a [CIMD](https://frontmcp.dev/reference/auth/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](#letting-only-known-clients-in). |
| `allowedScopes` | `string[]` | `["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](#scopes). |
| `dcr` | `LocalDcrConfig` | registration on outside production | Controls client registration at `POST /oauth/register` and which clients and redirect URIs `/oauth/authorize` accepts. See [`dcr`](#dcr). |
| `consent` | `ConsentConfig` | off | A screen after sign-in where the user picks the tools this client may call. Other tools answer `TOOL_NOT_CONSENTED`. See [the consent screen](https://frontmcp.dev/reference/auth/login-ui#the-consent-screen). |
| `incrementalAuth` | `IncrementalAuthConfig` | off | Grants 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](https://frontmcp.dev/reference/auth/progressive). |
| `tokenStorage` | `"memory"`, `{ redis }` or `{ sqlite }` | `"memory"` | Where pending sign-ins, codes, refresh tokens, remembered consent and the credential vault live. See [Storage](#storage). |
| `secureStore` | `"memory"`, or `{ redis }`, `{ sqlite }` or `{ backend }` with `scope` and `ttlMs` | memory, per user | Where [`this.secureStore`](#thissecurestore) keeps its secrets. |
| `local` | `{ issuer?, signKey?, jwks? }` | none | `issuer` sets the `iss` claim of the tokens and the `iss` parameter of the redirect, without a trailing slash: one is dropped. See [Tokens](#tokens). `signKey` and `jwks` are accepted and not used yet: tokens are always signed with `JWT_SECRET`. |
| `allowDefaultPublic` | `boolean` | `false` | Turns 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](#anonymous-tokens). |
| `anonymousScopes` | `string[]` | `["anonymous"]` | The scopes of an [anonymous token](#anonymous-tokens). |
| `publicAccess` | `{ tools?, prompts?, rateLimit? }` | none | The tools and prompts the holder of an anonymous token may use, and how often. See [`publicAccess`](https://frontmcp.dev/reference/auth/modes#publicaccess). |
| `cimd` | `CimdConfig` | on | Lets a client use the URL of its metadata document as its `client_id`. See [Client ID metadata (CIMD)](https://frontmcp.dev/reference/auth/cimd). |
| `providers`, `federatedAuth` | `UpstreamProviderOptions[]`, `FederatedAuthConfig` | none | Upstream 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](https://frontmcp.dev/reference/auth/upstream-providers). |
| `expectedAudience` | `string` or `string[]` | this server's URL | The 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](#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](https://frontmcp.dev/reference/auth/remote#calling-the-providers-api-as-the-user), 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 path | What it does |
| --- | --- |
| `GET /.well-known/oauth-protected-resource` | The protected-resource metadata (RFC 9728) that `401` answers point to: `{ resource, authorization_servers, scopes_supported, bearer_methods_supported }`. |
| `GET /.well-known/oauth-authorization-server` | The authorization-server metadata (RFC 8414): where every endpoint below is, and what they support. |
| `POST /oauth/register` | Dynamic client registration (RFC 7591). `201` with a new `client_id`. Off in production unless [`dcr.enabled`](#dcr) says otherwise. Never cached: see below the table. |
| `GET /oauth/authorize` | Checks the request and answers with the sign-in page (HTML), and a [cookie](#the-sign-in-cookie) that ties the sign-in to the user's browser. Problems are a `400` error page. |
| `POST` and `GET /oauth/callback` | Where 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/token` | Trades a code for tokens (`authorization_code`), refreshes them (`refresh_token`), or issues an [anonymous token](#anonymous-tokens) (`anonymous`). Form-encoded. Never cached: see below the table. |
| `GET /oauth/userinfo` | `{ sub, email, name }` for the bearer token. |
| `GET` and `POST /oauth/connect` | Connects a credential in the middle of a session. See [Progressive auth](https://frontmcp.dev/reference/auth/progressive#connecting-a-credential-later). |
| `POST /oauth/ui/extra` | Side requests from a [custom React page](https://frontmcp.dev/reference/auth/login-ui#extras). |
| `GET /oauth/provider/<id>/callback` | Where upstream `providers` send the user back. |
| `GET /.well-known/jwks.json` | An 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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), 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](#tokens), so they name `local.issuer` when you set it. `scopes_supported` lists the entries of [`allowedScopes`](#scopes) that have no `*`. For a server at `http://localhost:3000` with the default `allowedScopes`, the authorization-server metadata is:

```json
{
  "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](#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](#signing-in-step-by-step) sends each of these requests.

#### `/oauth/authorize` parameters

| Parameter | Required | What it does |
| --- | --- | --- |
| `response_type` | yes | Must be `code`. |
| `client_id` | yes | A registered client, one listed in `dcr.clients`, or a [CIMD](https://frontmcp.dev/reference/auth/cimd) URL. Any string only with [`requireRegisteredClients: false`](#letting-only-known-clients-in). |
| `redirect_uri` | yes | `http` or `https`. For a registered client, it must be one of its registered URIs. |
| `code_challenge` | yes | 43 to 128 characters of base64url. |
| `code_challenge_method` | no | Only `S256`, the default. `plain` is refused. |
| `scope` | no | Space-separated. Only the scopes in `allowedScopes` are granted: see [Scopes](#scopes). |
| `state` | no | Handed back on the redirect. |
| `resource` | no | Must 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. |
| `apps` | no | With [`incrementalAuth`](https://frontmcp.dev/reference/auth/progressive), 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.

> **Pitfall: Scopes say what the client asked for, not what the user may do**
Every user who signs in gets every allowed scope their client asks for: a client that asks for `tickets:write` gets it, whoever signs in. Keep scopes that grant more than every user should have out of `allowedScopes`, and base access decisions on who the user is (`sub`, or claims your `authenticate` adds), and on [`consent`](https://frontmcp.dev/reference/auth/login-ui#the-consent-screen) or [`incrementalAuth`](https://frontmcp.dev/reference/auth/progressive), which FrontMCP enforces.

#### Tokens

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

| Claim | Value |
| --- | --- |
| `sub` | The user's id. See [Users](#users). |
| `scope` | The [granted scopes](#scopes), `""` if there are none. |
| `email`, `name` | What the sign-in form sent, when it did. |
| `iss` | `local.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`, `exp` | Issued now, expires in one hour. |
| `jti` | A random id. |
| `aud` | The 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 claims | What `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`](https://frontmcp.dev/reference/sdk/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`.

> **Note**
Changed in 1.8.3: tokens are bound to the server that issued them. Access tokens issued before 1.8.3 have no `aud`, so after the upgrade they're refused with `401`, and clients refresh them or sign in again. Behind a proxy, set `FRONTMCP_PUBLIC_URL` so the URL a token is checked against doesn't depend on the `Host` header, or list your public URLs in `expectedAudience`.

Changed in 1.8.4: without `local.issuer`, `FRONTMCP_PUBLIC_URL` sets `iss` too, and under `createFetchHandler()` the default `iss` is the request's address instead of `http://localhost:3001`.

Changed in 1.8.5: FrontMCP's Node server derives `iss` the same way, so without `local.issuer` or `FRONTMCP_PUBLIC_URL` it's the address the request came to, not `http://localhost:<http.port, or 3001>`; `FRONTMCP_PUBLIC_URL` wins over `FRONTMCP_PUBLIC_HOST`; and `expectedAudience` no longer changes `iss`. A server whose `iss` changed refuses the tokens it issued before the upgrade, once, with `unexpected "iss" claim value`. Set `FRONTMCP_PUBLIC_URL` or `local.issuer` in production.

Changed in 1.9.3: a trailing slash on `local.issuer` is dropped, so `https://desk.example.com/` gives the `iss` `https://desk.example.com`, and the URLs built from it, like an upstream provider's callback, no longer have `//`. A server whose `local.issuer` ends in `/` refuses the tokens it issued before the upgrade, once, in the same way.

#### 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.fields` | Every 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.resume` | Only when a user [connects a credential later](https://frontmcp.dev/reference/auth/progressive#connecting-a-credential-later): `{ sub, key, context? }`. |
| `ctx` | `get(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](https://frontmcp.dev/reference/auth/progressive#connecting-a-credential-later). |
| `{ 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 else | The 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`](https://frontmcp.dev/reference/sdk/auth#options-that-change-thisauth) at it.

#### Keys and signing

| Environment variable | What it does |
| --- | --- |
| `JWT_SECRET` | Signs and checks every token, and the [progressive auth](https://frontmcp.dev/reference/auth/progressive) tickets and credential links. At least 32 bytes: `openssl rand -hex 32`. |
| `VAULT_SECRET` | Mixed into the keys of the credential vault and `this.secureStore`. Defaults to `JWT_SECRET`. |
| `FRONTMCP_PUBLIC_URL` | The 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_HOST` | Without `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](https://frontmcp.dev/reference/sdk/create-fetch-handler#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.

| Value | What 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`

| Field | Default | What it does |
| --- | --- | --- |
| `enabled` | on outside production | `false` 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`). |
| `clients` | none | Clients you register in code: `{ clientId, redirectUris, clientSecret?, clientName?, tokenEndpointAuthMethod?, grantTypes?, responseTypes?, scope? }`. A client with a `clientSecret` must send it to `/oauth/token`. |
| `allowedRedirectUris` | none | Redirect 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. |
| `allowedClientIds` | none | Only these `client_id`s 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](#unknown-client_id-register-the-client-dcr--pre-registered-or-use-a-cimd-client-id-url). 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. |
| `initialAccessToken` | none | Registration needs `Authorization: Bearer <token>`, or it's `401`. |
| `maxDynamicClients` | `1000` | How 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.

| Method | Returns |
| --- | --- |
| `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.)

#### The sign-in cookie

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](#signing-in-step-by-step) 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](https://frontmcp.dev/reference/auth/login-ui#security).
- 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`](https://frontmcp.dev/reference/auth/progressive). See [Auth for one app](https://frontmcp.dev/reference/auth/modes#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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) 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.

```ts help-desk.app.ts active
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 {}
```

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

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

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

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

const pathOf = (url: string) => new URL(url).pathname;

test("a request without a token is refused, with a pointer to the metadata", async () => {
  const client = await OAuthClient.for(config);
  const { status, headers, body } = await client.callTool(undefined, "whoami");
  expect(status).toBe(401);
  expect(body).toEqual({ error: "Unauthorized" });
  expect(headers.get("www-authenticate")).toBe(`Bearer resource_metadata="${SERVER}/.well-known/oauth-protected-resource"`);
});

test("the protected-resource metadata names the server itself", async () => {
  const client = await OAuthClient.for(config);
  const prm = await (await client.send("/.well-known/oauth-protected-resource")).json();
  expect(prm).toEqual({
    resource: SERVER,
    authorization_servers: [SERVER],
    scopes_supported: ["openid", "profile", "email", "offline_access"],
    bearer_methods_supported: ["header"],
  });
});

test("the authorization-server metadata lists the endpoints", async () => {
  const client = await OAuthClient.for(config);
  const response = await client.send("/.well-known/oauth-authorization-server");
  expect(response.headers.get("cache-control")).toBe("no-store");
  const md = await response.json();
  expect([md.authorization_endpoint, md.token_endpoint, md.registration_endpoint, md.userinfo_endpoint, md.jwks_uri].map(pathOf)).toEqual([
    "/oauth/authorize",
    "/oauth/token",
    "/oauth/register",
    "/oauth/userinfo",
    "/.well-known/jwks.json",
  ]);
  expect(md).toMatchObject({
    issuer: SERVER,
    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,
  });
});

test("scopes_supported lists allowedScopes, without the patterns", async () => {
  const client = await OAuthClient.for({ ...config, auth: { mode: "local" as const, allowedScopes: ["openid", "tickets:read", "tickets:*"] } });
  for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server"]) {
    expect((await (await client.send(path)).json()).scopes_supported).toEqual(["openid", "tickets:read"]);
  }
});

test("token and registration responses are never cached, errors included", async () => {
  const client = await OAuthClient.for(config);
  const registered = await client.send("/oauth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ redirect_uris: ["http://localhost:3000/callback"] }) });
  const refused = await client.send("/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "grant_type=refresh_token&refresh_token=nope&client_id=desk-cli" });
  for (const [response, status] of [[registered, 201], [refused, 400]] as const) {
    expect([response.status, response.headers.get("cache-control"), response.headers.get("pragma")]).toEqual([status, "no-store", "no-cache"]);
  }
});

test("there's no OpenID discovery, revocation or introspection", async () => {
  const client = await OAuthClient.for(config);
  for (const path of ["/.well-known/openid-configuration", "/oauth/revoke", "/oauth/introspect"]) {
    expect((await client.send(path, { method: path.startsWith("/oauth") ? "POST" : "GET" })).status).toBe(404);
  }
});
```

`/.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-flow-a-client-follows). The tests walk through it and then call a tool with the token.

```ts oauth-client.ts active
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(/=+$/, "");
}
```

```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() {
    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 {}
```

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

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

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

test("register, sign in, exchange the code, call a tool", async () => {
  const client = await OAuthClient.for(config);

  const registered = await client.register();
  expect(registered.status).toBe(201);
  expect(registered.body).toMatchObject({ client_id: expect.any(String), token_endpoint_auth_method: "none", redirect_uris: [REDIRECT_URI] });

  const authorize = await client.authorize({ scope: "tickets:read" });
  expect(authorize.status).toBe(200);
  expect(authorize.page).toContain('<form method="POST" action="/oauth/callback">');
  expect(authorize.page).toContain('name="email"');
  expect([...client.cookies.keys()]).toEqual([expect.stringMatching(/^__Host-frontmcp_signin_[0-9a-f]{16}$/)]);

  const submitted = await client.submit({ pending_auth_id: authorize.pendingAuthId!, email: "nour@example.com", name: "Nour Haddad" });
  expect(submitted.status).toBe(302);
  expect(submitted.location).toMatch(/^http:\/\/localhost:3000\/callback\?code=\w+&state=xyz&iss=https%3A%2F%2Fdesk\.example\.com$/);
  expect(client.cookies.size).toBe(0); // the redirect clears the cookie

  const tokens = await client.exchange(submitted.code!);
  expect(tokens.body).toEqual({ access_token: expect.any(String), token_type: "Bearer", expires_in: 3600, refresh_token: expect.any(String), scope: "tickets:read" });

  const claims = claimsOf(tokens.body.access_token);
  expect(claims).toMatchObject({ scope: "tickets:read", email: "nour@example.com", name: "Nour Haddad", iss: SERVER, aud: SERVER });
  expect(claims.exp - claims.iat).toBe(3600);

  const call = await client.callTool(tokens.body.access_token, "whoami");
  expect(call.body.result.structuredContent).toEqual({ sub: claims.sub, email: "nour@example.com", scopes: ["tickets:read"] });
});

test("the form only works in the browser that opened the page", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { pendingAuthId } = await client.authorize();
  client.cookies.clear(); // as if someone else's browser sent the form
  const refused = await client.submit({ pending_auth_id: pendingAuthId!, email: "nour@example.com" });
  expect(refused.status).toBe(400);
  expect(refused.code).toBeNull();
  expect(refused.page).toContain("This sign-in was started in another browser, or at another address. Start it again from the app.");
});

test("the same email is the same user, whatever its case", async () => {
  const client = await OAuthClient.for(config);
  const first = claimsOf((await client.signIn({ email: "nour@example.com" })).access_token);
  const again = claimsOf((await client.signIn({ email: "NOUR@example.com" })).access_token);
  const other = claimsOf((await client.signIn({ email: "sam@example.com" })).access_token);
  expect(again.sub).toBe(first.sub);
  expect(other.sub).not.toBe(first.sub);
  expect(first.sub).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});

test("the tokens are HS256 JWTs", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ email: "nour@example.com" });
  expect(JSON.parse(atob(access_token.split(".")[0]))).toEqual({ alg: "HS256", typ: "JWT" });
});

test("only the scopes in allowedScopes are granted", async () => {
  const client = await OAuthClient.for(config);
  const tokens = await client.signIn({ email: "sam@example.com" }, { scope: "admin tickets:write" });
  expect(tokens.scope).toBe("tickets:write");
  const call = await client.callTool(tokens.access_token, "whoami");
  expect(call.body.result.structuredContent.scopes).toEqual(["tickets:write"]);

  // The default allowedScopes: openid, profile, email and offline_access
  const defaults = await OAuthClient.for({ ...config, auth: { mode: "local" as const } });
  expect((await defaults.signIn({ email: "sam@example.com" }, { scope: "tickets:read profile" })).scope).toBe("profile");
});

test("local.issuer sets iss, and the discovery documents name it", async () => {
  const client = await OAuthClient.for({ ...config, auth: { mode: "local" as const, local: { issuer: "https://id.example.com" } } });
  const { access_token } = await client.signIn({ email: "nour@example.com" });
  expect(claimsOf(access_token).iss).toBe("https://id.example.com");
  expect((await (await client.send("/.well-known/oauth-authorization-server")).json()).issuer).toBe("https://id.example.com");
  expect((await (await client.send("/.well-known/oauth-protected-resource")).json()).authorization_servers).toEqual(["https://id.example.com"]);

  // a trailing slash is dropped
  const slashed = await OAuthClient.for({ ...config, auth: { mode: "local" as const, local: { issuer: "https://id.example.com/" } } });
  expect(claimsOf((await slashed.signIn({ email: "nour@example.com" })).access_token).iss).toBe("https://id.example.com");
});

test("requireEmail: false lets everyone in as one user", async () => {
  const client = await OAuthClient.for({ ...config, auth: { mode: "local" as const, requireEmail: false } });
  const first = claimsOf((await client.signIn({})).access_token);
  const second = claimsOf((await client.signIn({ name: "Someone else" })).access_token);
  expect(second.sub).toBe(first.sub);
  expect(first).not.toHaveProperty("email");
});

test("a token works only at the address it was issued at", async () => {
  const desk = await OAuthClient.for(config);
  const { access_token } = await desk.signIn({ email: "nour@example.com" });
  expect(claimsOf(access_token)).toMatchObject({ iss: SERVER, aud: SERVER });
  expect((await desk.callTool(access_token, "whoami")).status).toBe(200);

  // Servers in the same process share the random secret, as servers with one JWT_SECRET would.
  const reports = await OAuthClient.for(config, "https://reports.example.com");
  const otherAddress = await reports.callTool(access_token, "whoami");
  expect(otherAddress.status).toBe(401);
  expect(otherAddress.headers.get("www-authenticate")).toContain('error_description="unexpected \\"iss\\" claim value"');

  // With local.issuer, iss is the same at every address, and aud still tells them apart
  const pinned = { ...config, auth: { ...config.auth, local: { issuer: SERVER } } };
  const pinnedToken = (await (await OAuthClient.for(pinned)).signIn({ email: "nour@example.com" })).access_token;
  const otherAudience = await (await OAuthClient.for(pinned, "https://reports.example.com")).callTool(pinnedToken, "whoami");
  expect(otherAudience.status).toBe(401);
  expect(otherAudience.headers.get("www-authenticate")).toContain('error_description="Token audience does not match this resource"');

  // expectedAudience lists the addresses a token may be used at, whichever of them issued it
  const both = { ...config, auth: { ...config.auth, expectedAudience: [SERVER, "https://reports.example.com"] } };
  expect((await (await OAuthClient.for(both, "https://reports.example.com")).callTool(access_token, "whoami")).status).toBe(200);

  // Another issuer refuses it wherever it's sent
  const billing = await OAuthClient.for({ ...config, auth: { ...config.auth, local: { issuer: "https://billing.example.com" } } });
  expect((await billing.callTool(access_token, "whoami")).headers.get("www-authenticate")).toContain('error_description="unexpected \\"iss\\" claim value"');
});

test("/oauth/userinfo describes the token's user", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ email: "nour@example.com", name: "Nour Haddad" });
  const response = await client.send("/oauth/userinfo", { headers: { authorization: `Bearer ${access_token}` } });
  expect(await response.json()).toEqual({ sub: claimsOf(access_token).sub, email: "nour@example.com", name: "Nour Haddad" });
});
```

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.

```ts refresh.test.ts active
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);
});
```

```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() {
    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 {}
```

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

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

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

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.

```ts server.ts active
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"] } };
    },
  },
};
```

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

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

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

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

test("the sign-in page asks for a desk key", 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('name="deskKey"');
  expect(page).not.toContain('name="email"');
});

test("a wrong key shows the page again, and issues no code", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { pendingAuthId } = await client.authorize();
  const wrong = await client.submit({ pending_auth_id: pendingAuthId!, deskKey: "dk-0000" });
  expect(wrong.status).toBe(200);
  expect(wrong.location).toBeNull();
  expect(wrong.page).toContain("That desk key isn&#39;t valid.");

  const right = await client.submit({ pending_auth_id: pendingAuthId!, deskKey: "dk-7f3a" });
  expect(right.status).toBe(302);
});

test("authenticate gets the fields and the client; a throw is a polite refusal", async () => {
  const calls: unknown[] = [];
  const client = await OAuthClient.for({
    ...config,
    auth: {
      ...config.auth,
      authenticate: async (input: { fields: Record<string, string> }, ctx: { clientId?: string; clientName?: string; get: unknown; fetch: unknown }) => {
        calls.push({ fields: input.fields, clientId: ctx.clientId, clientName: ctx.clientName, get: typeof ctx.get, fetch: typeof ctx.fetch });
        throw new Error("the agents database is down");
      },
    },
  });
  await client.register();
  const { pendingAuthId } = await client.authorize({ scope: "tickets:read" });
  const { page } = await client.submit({ pending_auth_id: pendingAuthId!, deskKey: "dk-7f3a" });
  expect(page).toContain("Authentication failed. Please try again.");
  expect(calls).toEqual([{ fields: { deskKey: "dk-7f3a" }, clientId: client.clientId, clientName: client.clientId, get: "function", fetch: "function" }]);
});

test("a right key signs the agent in, with their claims", async () => {
  const client = await OAuthClient.for(config);
  const { access_token } = await client.signIn({ deskKey: "dk-7f3a" });
  // `roles` is one of the claims FrontMCP sets itself, so it's dropped
  expect(claimsOf(access_token)).toMatchObject({ sub: "agent-nour", team: "billing" });
  expect(claimsOf(access_token)).not.toHaveProperty("roles");

  const call = await client.callTool(access_token, "my_queue");
  expect(call.body.result.structuredContent).toEqual({ agent: "agent-nour", team: "billing", roles: [] });
});
```

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

`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](https://frontmcp.dev/reference/auth/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.

```ts server.ts active
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 } };
```

```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() {
    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 {}
```

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

const elsewhere = { redirect_uri: "https://attacker.example.net/cb" };

test("a client the server doesn't know is refused", async () => {
  const client = await OAuthClient.for(config);
  client.clientId = "made-up-client";
  const { status, page } = await client.authorize(elsewhere);
  expect(status).toBe(400);
  expect(page).toContain("Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL");
});

test("with requireRegisteredClients: false, it gets a code sent anywhere", async () => {
  const client = await OAuthClient.for(anyClient);
  client.clientId = "made-up-client";
  const { status, pendingAuthId } = await client.authorize(elsewhere);
  expect(status).toBe(200);
  const { location } = await client.submit({ pending_auth_id: pendingAuthId!, email: "nour@example.com" });
  expect(location).toMatch(/^https:\/\/attacker\.example\.net\/cb\?code=/);
});

test("a registered client can only use its own redirect URIs", async () => {
  const client = await OAuthClient.for(config);
  await client.register();
  const { status, page } = await client.authorize(elsewhere);
  expect(status).toBe(400);
  expect(page).toContain("redirect_uri is not registered for this client");
  expect((await client.authorize({ redirect_uri: "http://localhost:4000/callback" })).status).toBe(400); // another port too
});

test("a client listed in dcr.clients signs in without registering", async () => {
  const client = await OAuthClient.for(listed);
  client.clientId = "desk-cli";
  const { status } = await client.authorize();
  expect(status).toBe(200);
});

test("with dcr.enabled: false, registration is gone", async () => {
  const client = await OAuthClient.for(listed);
  expect(await client.register()).toEqual({ status: 404, body: { error: "access_denied", error_description: "Dynamic Client Registration is disabled." } });
  const md = await (await client.send("/.well-known/oauth-authorization-server")).json();
  expect(md).not.toHaveProperty("registration_endpoint");
});

test("an error goes back to the client only at a redirect URI it registered", async () => {
  const wrongResource = async (server: typeof config, clientId: string, redirectUri: string) => {
    const client = await OAuthClient.for(server);
    const query = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirectUri, code_challenge: "x".repeat(43), resource: "https://other.example.com" });
    const response = await client.send(`/oauth/authorize?${query}`);
    return { status: response.status, location: response.headers.get("location"), page: await response.text() };
  };
  const known = await wrongResource(listed, "desk-cli", "http://localhost:3000/callback");
  expect(known.status).toBe(302);
  expect(known.location).toBe(
    "http://localhost:3000/callback?error=invalid_request&error_description=Invalid+resource+parameter%3A+does+not+match+server+resource+URI&iss=https%3A%2F%2Fdesk.example.com",
  );

  const unknown = await wrongResource(listed, "made-up-client", "https://attacker.example.net/cb");
  expect(unknown.status).toBe(400); // the client is checked first
  expect(unknown.page).toContain("Unknown client_id");

  const unchecked = await wrongResource(anyClient, "made-up-client", "https://attacker.example.net/cb");
  expect(unchecked).toMatchObject({ status: 400, location: null });
  expect(unchecked.page).toContain("Invalid resource parameter: does not match server resource URI");
});

test("registration only takes loopback redirect URIs without an allowlist", async () => {
  const client = await OAuthClient.for(config);
  const { status, body } = await client.register({ redirect_uris: ["https://app.example.org/cb"] });
  expect(status).toBe(400);
  expect(body).toEqual({
    error: "invalid_redirect_uri",
    error_description: "Registration allows only loopback redirect_uris (localhost, 127.0.0.0/8, ::1) without an allowlist; got https://app.example.org/cb",
  });
});
```

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

> **Pitfall: Keep requireRegisteredClients on**
`requireRegisteredClients` has been on by default since 1.8.3; before, any client got the sign-in page. With it off, anyone can start a sign-in on your page that ends at a redirect URI of their choosing, and `dcr.enabled: false` doesn't change that. Turn it off only on your own machine. To let clients in, keep registration on (it only accepts loopback redirect URIs unless you add `dcr.allowedRedirectUris`), list them in `dcr.clients`, or let them identify themselves with [CIMD](https://frontmcp.dev/reference/auth/cimd). In production registration is off, so only the clients in `dcr.clients` and CIMD clients can sign in, unless you set `dcr.enabled`.

### 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.

```ts server.ts active
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"] },
      ],
    },
  },
};
```

```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() {
    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 {}
```

```ts registration.test.ts
import { test, expect } from "@frontmcp/testing";
import { OAuthClient, REDIRECT_URI } from "./oauth-client";
import { invited, listed } from "./server";

const withToken = (token: string) => ({
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
  body: JSON.stringify({ redirect_uris: ["http://localhost:5173/callback"] }),
});

test("registration needs the initial access token", async () => {
  const client = await OAuthClient.for(invited);
  expect(await client.register()).toEqual({ status: 401, body: { error: "invalid_token", error_description: "A valid initial access token is required to register a client." } });
  expect((await client.send("/oauth/register", withToken("reg-4c1f"))).status).toBe(201);
});

test("a client can ask for a secret", async () => {
  const client = await OAuthClient.for(invited);
  const register = (method: string) =>
    client.send("/oauth/register", { ...withToken("reg-4c1f"), body: JSON.stringify({ redirect_uris: ["http://localhost:5173/callback"], token_endpoint_auth_method: method }) });
  const refused = await register("private_key_jwt");
  expect(refused.status).toBe(400);
  expect((await refused.json()).error).toBe("invalid_client_metadata");
  const withSecret = await register("client_secret_basic");
  expect(await withSecret.json()).toMatchObject({ token_endpoint_auth_method: "client_secret_basic", client_secret: expect.any(String) });
});

test("🚩 a registration it can't read is a 500 that blames the server's configuration", async () => {
  const client = await OAuthClient.for(invited);
  const response = await client.send("/oauth/register", { ...withToken("reg-4c1f"), body: JSON.stringify({ redirect_uris: [] }) });
  expect(response.status).toBe(500);
  expect(await response.json()).toMatchObject({ error: "server_misconfigured", code: "CONFIG_INVALID" });
});

test("maxDynamicClients caps how many clients can register", async () => {
  const client = await OAuthClient.for(invited);
  await client.send("/oauth/register", withToken("reg-4c1f"));
  const second = await client.send("/oauth/register", withToken("reg-4c1f"));
  expect(second.status).toBe(503);
  expect(await second.json()).toEqual({ error: "temporarily_unavailable", error_description: "Client registration capacity reached; please try again later." });
});

test("allowedRedirectUris is checked at registration, and registers no one", async () => {
  const client = await OAuthClient.for(invited);
  const response = await client.send("/oauth/register", { ...withToken("reg-4c1f"), body: JSON.stringify({ redirect_uris: ["https://app.example.org/callback"] }) });
  expect(await response.json()).toEqual({ error: "invalid_redirect_uri", error_description: 'redirect_uri "https://app.example.org/callback" is not in the configured allowlist.' });

  client.clientId = "made-up-client";
  expect((await client.authorize()).page).toContain("Unknown client_id"); // its redirect URI is on the list, but it isn't registered

  // With requireRegisteredClients: false, it's the list an unregistered client's redirect URI must be on
  const open = await OAuthClient.for({ ...invited, auth: { ...invited.auth, requireRegisteredClients: false } });
  open.clientId = "made-up-client";
  expect((await open.authorize()).status).toBe(200);
  const { status, page } = await open.authorize({ redirect_uri: "https://attacker.example.net/cb" });
  expect(status).toBe(400);
  expect(page).toContain("is not in the configured allowlist");
});

test("allowedClientIds turns every other client away", async () => {
  const client = await OAuthClient.for(listed);
  const registered = await client.register(); // accepted, but not on the list
  expect(registered.status).toBe(201);
  const refused = await client.send(`/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: client.clientId, redirect_uri: REDIRECT_URI, state: "xyz", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" })}`);
  expect(refused.status).toBe(302); // back to the client, with the error
  const to = new URL(refused.headers.get("location")!);
  expect(to.origin + to.pathname).toBe(REDIRECT_URI);
  expect(Object.fromEntries(to.searchParams)).toEqual({
    error: "unauthorized_client",
    error_description: `client_id "${registered.body.client_id}" is not in the configured allowlist`,
    state: "xyz",
    iss: "https://desk.example.com", // which server answered (RFC 9207), on errors too
  });

  client.clientId = "desk-cli";
  expect((await client.authorize()).status).toBe(200);
});

test("without dcr.clients, allowedClientIds refuses registration", async () => {
  const client = await OAuthClient.for({ ...listed, auth: { mode: "local" as const, dcr: { allowedClientIds: ["desk-cli"] } } });
  expect(await client.register()).toEqual({ status: 400, body: { error: "access_denied", error_description: "Client-id allowlist is enforced; register clients declaratively via auth.dcr.clients instead of DCR." } });
});

test("a client with a secret must send it", async () => {
  const client = await OAuthClient.for(listed);
  client.clientId = "desk-web";
  const { pendingAuthId } = await client.authorize();
  const { code } = await client.submit({ pending_auth_id: pendingAuthId!, email: "nour@example.com" });
  expect(await client.exchange(code!)).toEqual({ status: 400, body: { error: "invalid_client", error_description: "Client authentication failed" } });
  const withSecret = await client.token({ grant_type: "authorization_code", code: code!, redirect_uri: "http://localhost:3000/callback", client_id: "desk-web", client_secret: "s3cr3t-for-the-web-app", code_verifier: client.verifier });
  expect(withSecret.status).toBe(200);
});
```

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

### 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`.

```ts help-desk.app.ts active
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 {}
```

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

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

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

test("each user has their own secrets, across sign-ins", async () => {
  const client = await OAuthClient.for(config);
  const nour = await client.signIn({ email: "nour@example.com" });
  const sam = await client.signIn({ email: "sam@example.com" });

  await client.callTool(nour.access_token, "save_crm_key", { key: "crm-key-1234" });
  const status = async (token: string) => (await client.callTool(token, "crm_key_status")).body.result.structuredContent;

  expect(await status(nour.access_token)).toEqual({ saved: true, endsWith: "1234" });
  expect(await status(sam.access_token)).toEqual({ saved: false, endsWith: null });

  const nourAgain = await client.signIn({ email: "nour@example.com" });
  expect(await status(nourAgain.access_token)).toEqual({ saved: true, endsWith: "1234" });
});

test("on MCP 2026-07-28 there's no session, so scope \"session\" keeps secrets per user too", async () => {
  const client = await OAuthClient.for({ ...config, auth: { mode: "local" as const, secureStore: { scope: "session" as const } } });
  const nour = await client.signIn({ email: "nour@example.com" });
  const sam = await client.signIn({ email: "sam@example.com" });
  await client.callTool(nour.access_token, "save_crm_key", { key: "crm-key-1234" });
  const status = async (token: string) => (await client.callTool(token, "crm_key_status")).body.result.structuredContent;
  expect(await status(nour.access_token)).toEqual({ saved: true, endsWith: "1234" }); // a later request
  expect(await status(sam.access_token)).toEqual({ saved: false, endsWith: null });
});
```

```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 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.

```ts server.ts active
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"] },
};
```

```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() {
    const { user, isAnonymous, scopes, claims } = this.auth;
    return { sub: user.sub, isAnonymous, scopes, anonymousClaim: claims.anonymous ?? null };
  }
}

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

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

test("grant_type=anonymous hands out a one-day token", async () => {
  const client = await OAuthClient.for(config);
  const { status, body } = await client.token({ grant_type: "anonymous", client_id: "any-client" });
  expect(status).toBe(200);
  expect(body).toMatchObject({ token_type: "Bearer", expires_in: 86400 });
  const claims = claimsOf(body.access_token);
  expect(claims).toMatchObject({ sub: expect.stringMatching(/^anon:/), anonymous: true, scope: "tickets:read", aud: expect.any(String) });
  expect(claims).not.toHaveProperty("role");

  const call = await client.callTool(body.access_token, "whoami");
  expect(call.body.result.structuredContent).toEqual({ sub: claims.sub, isAnonymous: true, scopes: ["tickets:read"], anonymousClaim: true });
});

test("its refresh token doesn't work", async () => {
  const client = await OAuthClient.for(config);
  const { body } = await client.token({ grant_type: "anonymous", client_id: "any-client" });
  client.clientId = "any-client";
  expect(await client.refresh(body.refresh_token)).toEqual({ status: 400, body: { error: "invalid_grant", error_description: "Refresh token is invalid or expired" } });
});

test("a request without any token is still refused", async () => {
  const client = await OAuthClient.for(config);
  expect((await client.callTool(undefined, "whoami")).status).toBe(401);
});

test("without allowDefaultPublic, there are no anonymous tokens", async () => {
  const client = await OAuthClient.for({ ...config, auth: { mode: "local" as const } });
  expect(await client.token({ grant_type: "anonymous", client_id: "any-client" })).toEqual({
    status: 400,
    body: { error: "unsupported_grant_type", error_description: "Anonymous access is not enabled on this server" },
  });
});
```

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

### 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:

```ts 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](https://frontmcp.dev/reference/auth/production) has the full checklist; [Configuration files](https://frontmcp.dev/reference/server/config-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](https://frontmcp.dev/reference/auth/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](https://frontmcp.dev/reference/auth/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](#the-sign-in-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.
