# Remote and proxied auth

> mode: "remote" puts FrontMCP between MCP clients and your identity provider: users sign in at the provider, and FrontMCP issues the tokens clients send. Every option, what tools see, and what FrontMCP checks.

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

`auth: { mode: "remote" }` makes FrontMCP the OAuth server your MCP clients sign in with, while the sign-in itself happens at your identity provider: Auth0, Okta, Keycloak, Frontegg, or any other OAuth 2.0 provider. FrontMCP sends the user to the provider with its own client credentials, takes back the code, learns who the user is, and then issues a token of its own, which is what clients send from then on. Use it when MCP clients can't register with your provider directly, or when tools need the provider's token to call its API for the user. When your provider can issue tokens for your server itself, [`transparent`](https://frontmcp.dev/reference/auth/modes) is simpler: FrontMCP checks the provider's tokens and issues none.

```ts
@FrontMcp({ auth: { mode: "remote", provider, clientId, clientSecret?, scopes?, providerConfig?, local?, allowedScopes?, … } })
```

---

## Reference

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

Set it on [`@FrontMcp`](https://frontmcp.dev/reference/sdk/frontmcp#auth). Here the provider is a Keycloak realm, whose endpoints don't use the paths FrontMCP assumes, so they're spelled out:

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

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  http: { port: 3000 },
  auth: {
    mode: "remote",
    provider: "https://sso.example.com/realms/acme",
    clientId: "help-desk-mcp",
    clientSecret: process.env.SSO_CLIENT_SECRET,
    scopes: ["openid", "profile", "email"],
    providerConfig: {
      id: "sso",
      authEndpoint: "https://sso.example.com/realms/acme/protocol/openid-connect/auth",
      tokenEndpoint: "https://sso.example.com/realms/acme/protocol/openid-connect/token",
      userInfoEndpoint: "https://sso.example.com/realms/acme/protocol/openid-connect/userinfo",
      jwksUri: "https://sso.example.com/realms/acme/protocol/openid-connect/certs",
    },
    // FrontMCP's own public address: the callback URL and the tokens' issuer
    local: { issuer: "https://desk.example.com" },
    // The scopes FrontMCP's tokens may carry
    allowedScopes: ["openid", "profile", "email", "tickets:*"],
  },
})
export default class Server {}
```

At the provider, register a client `help-desk-mcp` whose redirect URI is `https://desk.example.com/oauth/provider/sso/callback`, and set `JWT_SECRET` in the server's environment: FrontMCP signs its tokens with it. [See more examples below.](#usage)

#### How a sign-in works

1. A client calls the server without a token. It gets `401` with `WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource"`. That document names the server itself as the authorization server, and `/.well-known/oauth-authorization-server` lists FrontMCP's own endpoints: `/oauth/authorize`, `/oauth/token`, `/oauth/userinfo`, and `/oauth/register` outside production.
2. The client identifies itself: by registering at `/oauth/register`, or by using a [metadata document URL](https://frontmcp.dev/reference/auth/cimd) as its `client_id`. Other client ids are refused, unless you set `requireRegisteredClients: false`.
3. The client sends the user to `/oauth/authorize` with its PKCE challenge. FrontMCP redirects straight to the provider's authorization endpoint, with **its own** `client_id`, a `redirect_uri` of `<issuer>/oauth/provider/<id>/callback`, a `state` of `federated:…`, a PKCE challenge of its own, and `scope` set to `scopes`. The redirect also sets a cookie, `frontmcp_signin_<id>` (`__Host-frontmcp_signin_<id>` over `https`), that ties the sign-in to the user's browser. There's no FrontMCP login page and no choice of provider.
4. The user signs in at the provider, which redirects back to the callback with a `code`. The callback needs the sign-in cookie, and an `iss` in it must be `provider`. If the user declines, or the provider answers with any other error, the sign-in ends here with a `400` page, and nobody gets a token.
5. FrontMCP exchanges the code at the provider's token endpoint (a form `POST` with `client_id`, `client_secret` if you set one, and its PKCE verifier). It takes the user's identity from the `id_token` in the response if it verifies: signed with one of the provider's keys, issued by `provider`, for `clientId`, and not expired. Otherwise it asks the provider's userinfo endpoint. It stores the provider's tokens, encrypted, in `tokenStorage`.
6. With [`consent`](#options) on, FrontMCP now shows a page where the user picks the tools the client may use.
7. FrontMCP redirects to the client's `redirect_uri` with a code of its own, the client's `state`, and `iss`, and clears the sign-in cookie.
8. The client exchanges that code at `/oauth/token` and gets FrontMCP's access token (an HS256 JWT, valid for an hour) and a refresh token (30 days, replaced on every use). The token's `scope` is the scopes the client asked for that [`allowedScopes`](#options) allows, and its `aud` is the server's address.
9. The client sends the access token on every request. FrontMCP checks its signature, its expiry, that its `iss` is `local.issuer`, and that its `aud` is the address the request came to. It never asks the provider to check it: FrontMCP goes back to the provider only to [renew the provider's token](#calling-the-providers-api-as-the-user) for a tool that reads it.

[The first Playground below](#putting-frontmcp-in-front-of-your-provider) runs every step through [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), with a stand-in for the provider, including a declined sign-in and a callback without the cookie or with another `iss`. `id_token`s that don't verify were checked with a stand-in provider too.

#### Options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `provider` | `string` | Required | The provider's base URL, like `https://acme.eu.auth0.com`. FrontMCP builds the provider's endpoints from it, unless `providerConfig` names them, and the default provider id. A value that isn't a URL stops the server with a validation error, `Invalid URL` at `auth.provider`; `createFetchHandler()` rejects with it. |
| `clientId` | `string` | | FrontMCP's client id at the provider. Required in practice: without it, the server starts with a warning and every sign-in fails with [`Upstream identity provider is not configured`](#upstream-identity-provider-is-not-configured). |
| `clientSecret` | `string` | | Sent to the provider's token endpoint in the form body (`client_secret_post`). Leave it out if the provider registered FrontMCP as a public client: FrontMCP always uses PKCE. |
| `scopes` | `string[]` | `["openid"]` | The scopes FrontMCP asks the provider for. Add `profile` and `email` to get the user's name and email. The scopes the MCP client asks for aren't passed on. |
| `providerConfig.id` | `string` | The provider's host name, dots turned into `_` | Names the provider in the callback path, `/oauth/provider/<id>/callback`, and in [`this.orchestration.getToken(id)`](#calling-the-providers-api-as-the-user). `https://auth.example.com` gives `auth_example_com`. |
| `providerConfig.authEndpoint` | `string` | `<provider>/authorize` | Where users are sent to sign in. |
| `providerConfig.tokenEndpoint` | `string` | `<provider>/token` | Where FrontMCP exchanges the provider's code. |
| `providerConfig.userInfoEndpoint` | `string` | `<provider>/userinfo` | Asked for the user's identity when the token response has no `id_token` that verifies. |
| `providerConfig.name` | `string` | The id | A display name. No page in remote mode shows it. |
| `providerConfig.jwks`, `providerConfig.jwksUri` | `{ keys: JWK[] }`, `string` | `<provider>/.well-known/jwks.json` | The provider's public keys, to verify its `id_token`. Without either, FrontMCP fetches `<provider>/.well-known/jwks.json`, then the `jwks_uri` in `<provider>/.well-known/oauth-authorization-server`, then the one in `<provider>/.well-known/openid-configuration` (new in 1.8.4). Set `jwksUri` for a provider that lists its keys elsewhere, like Okta or Keycloak, to save those requests: [the table on Tokens](https://frontmcp.dev/reference/auth/tokens#where-the-keys-come-from) has the URLs. An `id_token` that doesn't verify is ignored, and the userinfo endpoint is asked instead. |
| `providerConfig.dcrEnabled`, `providerConfig.registrationEndpoint` | | `false` | Meant to let FrontMCP register itself with the provider. Not implemented in 1.9.3: you need `clientId`. |
| `providerConfig.additionalIssuers`, `providerConfig.verifyIssuer` | `string[]`, `boolean` | none, `true` | More `iss` values the provider's callback may name, and `false` to accept any. |
| `local.issuer` | `string` | `http://localhost:<http.port>` for the callback, where `http.port` defaults to the `PORT` environment variable, or `3000` | FrontMCP's own public URL. A trailing slash is dropped (since 1.9.3: before, it gave callback URLs with `//`). The callback URL you register at the provider is built from it, and it's the `iss` of every token FrontMCP issues, which it checks on every request. Without it, tokens get the `iss` [local mode](https://frontmcp.dev/reference/auth/local#tokens) gives them: `FRONTMCP_PUBLIC_URL`, or else the address the request came to. Set it in every deployment: see [Registering the callback URL](#registering-the-callback-url). |
| `local.signKey`, `local.jwks` | | | Accepted and not used yet: tokens are HS256, signed with `JWT_SECRET`. |
| `requireRegisteredClients` | `boolean` | `true` | Refuses sign-ins from client ids FrontMCP doesn't know: only registered clients and [CIMD](https://frontmcp.dev/reference/auth/cimd) URLs pass. `false` lets any `client_id` in, with any `redirect_uri`, which is only safe on your own machine: see [Only letting known clients in](#only-letting-known-clients-in). |
| `allowedScopes` | `string[]` | `["openid", "profile", "email", "offline_access"]` | The scopes FrontMCP's tokens can carry. Scopes a client asks for that aren't listed are dropped. An entry can be a pattern where `*` matches anything, like `"tickets:*"`. It has nothing to do with `scopes`, which is what FrontMCP asks the provider for. |
| `cimd` | `CimdConfig` | On | How FrontMCP fetches and checks client metadata documents. See [Client ID metadata](https://frontmcp.dev/reference/auth/cimd). |
| `consent` | `ConsentConfig` | Off | `{ enabled: true, … }` shows a tool-selection page after the provider; the token then allows only the tools the user picked, and others fail with `TOOL_NOT_CONSENTED`. The options are the same as in [local mode](https://frontmcp.dev/reference/auth/local). |
| `tokenStorage` | `"memory"`, `{ redis }` or `{ sqlite }` | `"memory"` | Where FrontMCP keeps sign-ins in progress, its codes and refresh tokens, and the provider's tokens. In memory, they're lost on restart and not shared between instances, so a sign-in that starts on one instance and comes back to another fails. |
| `allowDefaultPublic` | `boolean` | `false` | Lets any client get an anonymous FrontMCP token from `/oauth/token` with `grant_type=anonymous`. Requests without a token are still refused. See [Checking that the caller signed in](#checking-that-the-caller-signed-in). |
| `anonymousScopes` | `string[]` | `["anonymous"]` | The scopes of those anonymous tokens. |
| `federatedAuth`, `incrementalAuth` | | | For several providers at once ([Upstream providers](https://frontmcp.dev/reference/auth/upstream-providers#federatedauth)), and for granting apps one at a time ([Progressive auth](https://frontmcp.dev/reference/auth/progressive)). |
| `secureStore`, `ui`, `extras` | | | As in [local mode](https://frontmcp.dev/reference/auth/local) and [Custom login UI](https://frontmcp.dev/reference/auth/login-ui). |
| `expectedAudience` | `string \| string[]` | The address the request came to | The `aud` a token must have. FrontMCP's tokens name the server's address as the client reached it, and by default work only at that address. With `expectedAudience`, a token that names one of the listed addresses is accepted at any address, and others are refused. |
| `refresh` | `{ enabled?, skewSeconds? }` | `{ enabled: true, skewSeconds: 60 }` | Renews the provider's access token with its refresh token when a tool reads it through [`this.orchestration`](#calling-the-providers-api-as-the-user) less than `skewSeconds` before it expires, or after. `enabled: false` never renews it. It doesn't change FrontMCP's own tokens. Changed in 1.9.3: before, it was accepted and never read. |
| `publicAccess` | `{ tools?, prompts?, rateLimit? }` | | The tools and prompts the holder of an anonymous token may use, and how often. See [`publicAccess`](https://frontmcp.dev/reference/auth/modes#publicaccess). |

#### What FrontMCP serves

| Request | Answer |
| --- | --- |
| `GET /.well-known/oauth-protected-resource` | `{ resource, authorization_servers: [the server itself], scopes_supported, bearer_methods_supported }`. `scopes_supported` is the entries of `allowedScopes` without a `*`. |
| `GET /.well-known/oauth-authorization-server` | FrontMCP's own metadata: `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`, `registration_endpoint` (outside production), `scopes_supported`, `code_challenge_methods_supported: ["S256"]`, `client_id_metadata_document_supported`. |
| `GET /.well-known/jwks.json` | An RS256 public key. FrontMCP signs nothing with it, so it can't be used to check FrontMCP's tokens. |
| `POST /oauth/register` | Dynamic client registration. Outside production only, and only for loopback redirect URIs (`localhost`, `127.0.0.1`, `::1`). |
| `GET /oauth/authorize` | A `302` to the provider, which sets the sign-in cookie. |
| `GET /oauth/provider/<id>/callback` | Where the provider sends the user back. Needs the sign-in cookie. Served by `createFetchHandler()` too since 1.8.4. |
| `POST /oauth/token` | `authorization_code` and `refresh_token` grants, and `anonymous` with `allowDefaultPublic`. |
| `GET /oauth/userinfo` | `{ sub, email, name }` for a FrontMCP token. |

The URLs in the two metadata documents use the request's origin, unless you set `FRONTMCP_PUBLIC_URL`: the URL it was sent to under `createFetchHandler()`, and its `Host` header and `http` on FrontMCP's Node server. The authorization server metadata's `issuer`, and the resource metadata's `authorization_servers`, name the token issuer: `local.issuer` when you set it. The authorization server metadata has `authorization_response_iss_parameter_supported: true`, and every redirect to the client, errors included, carries that `iss`. See [environment variables](https://frontmcp.dev/reference/server/config-files).

#### What tools see

A caller who signed in through the provider arrives with FrontMCP's token, and [`this.auth`](https://frontmcp.dev/reference/sdk/auth) is built from it:

| | Value |
| --- | --- |
| `user.sub` | The provider's `sub` for the user, unchanged, like `auth0\|nour`. |
| `user.name`, `user.email` | The provider's `name` and `email`, from the `id_token` or userinfo. |
| `isAnonymous` | `false`. Only an [anonymous token](#checking-that-the-caller-signed-in) gives `true`. |
| `scopes` | The scopes the **MCP client** asked FrontMCP for, less the ones `allowedScopes` doesn't list. Not what the provider granted. |
| `roles`, `permissions` | `[]`. FrontMCP copies only `sub`, `email` and `name` from the provider. |
| `claims` | FrontMCP's token: `sub`, `email`, `name`, `scope`, `federated: { enabled, selectedProviders, skippedProviders }`, `iss`, `iat`, `exp`, `jti`, `aud`, and `consent` when used. None of the provider's other claims. |

For anything else about the user, like their roles or tenant, call the provider's API with its token: [`this.orchestration.getToken(id)`](#calling-the-providers-api-as-the-user).

> **Pitfall: Scopes say what the client asked for, not what the user may do**
A remote-mode token's `scope` is the `scope` parameter the client sent to `/oauth/authorize`, less anything `allowedScopes` doesn't list. The provider isn't asked about them, so any client gets any allowed scope, for any user. `this.auth.hasScope()` and scope-based [authorities](https://frontmcp.dev/reference/auth/authorities) can't tell users apart in remote mode. Keep scopes that grant more than every user should have out of `allowedScopes`, and decide what a user may do from who they are: their `sub` or `email`, looked up in your own records, or asked of the provider's API.

#### Compared with `transparent` and `local`

| | `transparent` | `remote` | `local` |
| --- | --- | --- | --- |
| Where users sign in | At your provider, which the client talks to directly | At your provider, through FrontMCP | On FrontMCP's own page |
| Who issues the token clients send | Your provider | FrontMCP | FrontMCP |
| Clients register with | Your provider | FrontMCP (or [CIMD](https://frontmcp.dev/reference/auth/cimd)) | FrontMCP (or CIMD) |
| What FrontMCP checks per request | Signature against the provider's keys, issuer, expiry, audience, `requiredScopes` | Its own HS256 signature, expiry, issuer and audience | Its own HS256 signature, expiry, issuer and audience |
| `this.auth.scopes` | Granted by the provider | Asked for by the client, within `allowedScopes` | Asked for by the client, within `allowedScopes` |
| Claims tools see | Every claim of the provider's token | `sub`, `email`, `name` | `sub`, `email`, `name` from the login |
| The provider's token, for its API | The caller's own token, `this.context.authInfo.token` | `this.orchestration.getToken(id)`, until it expires | None, unless you add upstream `providers` |
| Needs `JWT_SECRET` | No | Yes, in production | Yes, in production |
| Works on `createFetchHandler()` | Yes | Yes, since 1.8.4 | Yes |

Pick `transparent` when your provider can register MCP clients (by dynamic registration or CIMD) and issues JWT access tokens for your server. Pick `remote` when it can't, when its access tokens aren't JWTs for your server (Google's aren't), or when tools need to call its API as the user. [Local auth](https://frontmcp.dev/reference/auth/local) has no provider at all.

#### Caveats

- **Changed in 1.8.4: it runs on `createFetchHandler()`.** Before, [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) matched the callback path `/oauth/provider/:providerId/callback` literally, so the provider's redirect got `404`, and remote mode worked only on FrontMCP's Node server. Now Cloudflare Workers and anything else served through it can finish a sign-in.
- **FrontMCP doesn't discover the provider's endpoints.** It reads the provider's `/.well-known/openid-configuration` only to find its keys: it appends `/authorize`, `/token` and `/userinfo` to `provider`. Few providers use those three paths; set them in `providerConfig`, as in [Pointing FrontMCP at your provider's endpoints](#pointing-frontmcp-at-your-providers-endpoints).
- **The provider's `id_token` is used only when it verifies**: signed with one of the provider's keys, with `iss` `provider`, an `aud` that includes `clientId`, and an `exp`. When it doesn't, FrontMCP asks the userinfo endpoint instead, so a provider whose keys FrontMCP can't find still works, with one more request per sign-in, as long as its userinfo endpoint does. FrontMCP's Node build fetches keys only from hosts that resolve to public addresses, `localhost` excepted outside production. Set `providerConfig.jwksUri` to point it at the keys.
- **A sign-in finishes only in the browser that started it.** The callback needs the sign-in cookie that `/oauth/authorize` set, and a browser sends a cookie only to the host that set it. So the client must open `/oauth/authorize` at the host of `local.issuer`: a sign-in started at `127.0.0.1` while `local.issuer` is `http://localhost:3000` gets [a `400` page](#this-sign-in-was-started-in-another-browser-or-at-another-address-start-it-again-from-the-app). Sign-ins in progress during an upgrade to 1.8.3 have to start again.
- **The provider's token lasts as long as the provider lets it.** When the provider gave a refresh token, FrontMCP renews the access token with it, and the provider's token follows FrontMCP's own through client refreshes. When it gave none, `this.orchestration.getToken(id)` throws once `expires_in` has passed, though FrontMCP's own token still works, and the user gets it back only by signing in again: ask for `offline_access` in `scopes` if your provider wants that for a refresh token. (Changed in 1.9.3: before, the provider's token was never renewed, and a client refresh lost it.)
- **Only one provider.** Two apps with their own remote `auth` under [`splitByApp`](https://frontmcp.dev/reference/server/apps#endpoints-standalone-and-splitbyapp) don't get one provider each: `/oauth/authorize` is served once, at the root, and sends everyone to the first app's provider. On the shared endpoint, an app's own `auth` is checked per call only with `incrementalAuth`: see [Auth for one app](https://frontmcp.dev/reference/auth/modes#auth-for-one-app), and [Apps with their own auth](https://frontmcp.dev/reference/auth/upstream-providers#apps-with-their-own-auth) for what the provider picker does with them.
- **Production needs `JWT_SECRET`.** Without it, a production server doesn't start: `createFetchHandler()` and the Node server reject with `JwtSecretRequiredError`. In development, FrontMCP signs with a random secret per process, so tokens stop working when the server restarts.

---

## Usage

> **Note**
The Playgrounds on this page stand the provider at `https://auth.example.com`. Where a test needs the provider to answer, `provider.example.ts` stands in for it: it replaces `fetch` during the test and answers FrontMCP's requests to the provider's token and userinfo endpoints, and its API. Each Playground's own server is public, so its Call tab works; the tests start the remote-mode server from `server.ts` with [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), and send their requests to `https://desk.example.com`, the origin FrontMCP's metadata and the `aud` of its tokens then name.

### Putting FrontMCP in front of your provider

`server.ts` is the configuration you'd pass to `@FrontMcp`. The tests follow a client that has never seen the server: it's refused, finds out where to sign in, registers, and is sent to the provider. Then the provider, played by `provider.example.ts`, sends the user back, and the client gets FrontMCP's token. `sign-in.ts` is the client and the user's browser, with its cookies:

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

// In your project: @FrontMcp(config) export default class Server {}
export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "remote" as const,
    provider: "https://auth.example.com",
    clientId: "help-desk-mcp",
    clientSecret: "stand-in-secret", // process.env.AUTH_CLIENT_SECRET in a real server
    scopes: ["openid", "profile", "email"],
    local: { issuer: "https://desk.example.com" },
  },
};
```

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

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

```ts sign-in.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { standInProvider } from "./provider.example";
import { Client, REDIRECT_URI } from "./sign-in";
import { config } from "./server";

type Handler = (request: Request) => Promise<Response>;
const get = async (path: string) => (await FrontMcpInstance.createFetchHandler(config))(new Request(`https://desk.example.com${path}`));

/** Registers, as an MCP client does, and returns the sign-in request it then sends. */
async function registerAndSignIn(handler: Handler) {
  const registered = await handler(
    new Request("https://desk.example.com/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: ["http://127.0.0.1:33418/callback"], token_endpoint_auth_method: "none" }),
    }),
  );
  const { client_id } = await registered.json();
  // The PKCE challenge is RFC 7636's example.
  return new URLSearchParams({
    response_type: "code",
    client_id,
    redirect_uri: "http://127.0.0.1:33418/callback",
    code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
    code_challenge_method: "S256",
    scope: "tickets:read",
    state: "client-state",
  });
}

test("a request without a token is refused, and told where to look", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const res = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  expect(res.status).toBe(401);
  expect(res.headers.get("www-authenticate")).toBe('Bearer resource_metadata="https://desk.example.com/.well-known/oauth-protected-resource"');
});

test("the server is its own authorization server", async () => {
  const resource = await (await get("/.well-known/oauth-protected-resource")).json();
  expect(resource.authorization_servers).toEqual([resource.resource]);

  const metadata = await (await get("/.well-known/oauth-authorization-server")).json();
  expect(new URL(metadata.authorization_endpoint).pathname).toBe("/oauth/authorize");
  expect(new URL(metadata.token_endpoint).pathname).toBe("/oauth/token");
  expect(new URL(metadata.registration_endpoint).pathname).toBe("/oauth/register");
  expect(metadata.code_challenge_methods_supported).toEqual(["S256"]);
  expect(metadata.client_id_metadata_document_supported).toBe(true);
  expect(metadata).toMatchObject({ issuer: "https://desk.example.com", authorization_response_iss_parameter_supported: true });
});

test("signing in goes straight to the provider, as FrontMCP's own client", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const signIn = await registerAndSignIn(handler);
  const response = await handler(new Request(`https://desk.example.com/oauth/authorize?${signIn}`));
  expect(response.status).toBe(302);
  const to = new URL(response.headers.get("location")!);
  expect(to.origin + to.pathname).toBe("https://auth.example.com/authorize");
  expect(Object.fromEntries(to.searchParams)).toEqual({
    response_type: "code",
    client_id: "help-desk-mcp",
    redirect_uri: "https://desk.example.com/oauth/provider/auth_example_com/callback",
    state: expect.stringMatching(/^federated:/),
    code_challenge: expect.any(String),
    code_challenge_method: "S256",
    scope: "openid profile email", // `scopes`, not the client's `tickets:read`
  });
  // FrontMCP's PKCE with the provider is its own, not the client's
  expect(to.searchParams.get("code_challenge")).not.toBe(signIn.get("code_challenge"));
  // The cookie that ties the sign-in to this browser
  expect(response.headers.getSetCookie()).toEqual([expect.stringMatching(/^__Host-frontmcp_signin_\w+=[\w-]+; Path=\/; Max-Age=1800; HttpOnly; Secure; SameSite=Lax$/)]);
});

test("the provider's callback finishes the sign-in", async () => {
  const provider = standInProvider();
  try {
    const client = await Client.for(config);
    const back = await client.returnFromProvider(await client.startSignIn(), { code: "code-from-the-provider" });
    // FrontMCP exchanged the provider's code as its own client, then asked who the user is
    expect(provider.requests.map((r) => `${r.method} ${r.path}`)).toEqual(["POST /token", "GET /userinfo"]);
    expect(provider.requests[0].form).toMatchObject({ code: "code-from-the-provider", client_id: "help-desk-mcp", client_secret: "stand-in-secret", code_verifier: expect.any(String) });
    // …then sent the user back to the client with a code of its own, and cleared the cookie
    expect(back.location).toMatch(/^http:\/\/127\.0\.0\.1:33418\/callback\?code=\w+&state=client-state&iss=https%3A%2F%2Fdesk\.example\.com$/);
    expect(client.cookies.size).toBe(0);

    const tokens = await client.token({ grant_type: "authorization_code", code: back.code!, redirect_uri: REDIRECT_URI, client_id: client.clientId, code_verifier: client.verifier });
    expect(tokens).toMatchObject({ token_type: "Bearer", expires_in: 3600, refresh_token: expect.any(String), scope: "openid" });
    expect((await client.callTool(tokens.access_token, "whoami")).structuredContent).toEqual({ sub: "auth0|nour", email: "nour@example.com", isAnonymous: false, scopes: ["openid"] });
  } finally {
    provider.stop();
  }
});

test("a sign-in declined at the provider ends there, without a code", async () => {
  const provider = standInProvider();
  try {
    const client = await Client.for(config);
    const declined = await client.returnFromProvider(await client.startSignIn(), { error: "access_denied" });
    expect(declined).toMatchObject({ status: 400, location: null });
    expect(declined.page).toContain("Sign-in was declined at the identity provider.");
    expect(provider.requests).toEqual([]);
  } finally {
    provider.stop();
  }
});

test("the callback needs the sign-in cookie, and the provider's own iss", async () => {
  const client = await Client.for(config);
  const otherIssuer = await client.returnFromProvider(await client.startSignIn(), { code: "code-from-the-provider", iss: "https://login.example.net" });
  expect(otherIssuer.status).toBe(400);
  expect(otherIssuer.page).toContain("Authorization response came from an unexpected issuer.");

  const atProvider = await client.startSignIn();
  client.cookies.clear(); // as if the user came back in another browser
  const elsewhere = await client.returnFromProvider(atProvider, { code: "code-from-the-provider" });
  expect(elsewhere.status).toBe(400);
  expect(elsewhere.page).toContain("This sign-in was started in another browser, or at another address. Start it again from the app.");
});
```

```ts provider.example.ts
// Stands in for the provider at auth.example.com, which the Playground can't reach:
// it answers FrontMCP's requests to its token and userinfo endpoints, and its API.
export function standInProvider({ expiresIn = 3600 } = {}) {
  const realFetch = globalThis.fetch;
  const requests: { method: string; path: string; form?: Record<string, string> }[] = [];
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
    const url = new URL(input instanceof Request ? input.url : String(input));
    if (url.origin !== "https://auth.example.com") return realFetch(input, init);
    const method = init?.method ?? "GET";
    requests.push({ method, path: url.pathname, ...(method === "POST" ? { form: Object.fromEntries(new URLSearchParams(String(init?.body))) } : {}) });
    const signedIn = new Headers(init?.headers).get("authorization") === "Bearer provider-token-for-nour";
    if (url.pathname === "/token") return Response.json({ access_token: "provider-token-for-nour", token_type: "Bearer", expires_in: expiresIn });
    if (url.pathname === "/userinfo" && signedIn) return Response.json({ sub: "auth0|nour", email: "nour@example.com", name: "Nour Haddad" });
    if (url.pathname === "/api/groups" && signedIn) return Response.json(["support", "billing"]);
    return new Response("Not found", { status: 404 });
  }) as typeof fetch;
  return { requests, stop: () => void (globalThis.fetch = realFetch) };
}
```

```ts sign-in.ts
import { FrontMcpInstance } from "@frontmcp/sdk";

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

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

/** An MCP client, and the user's browser with its cookies, signing in through FrontMCP. */
export class Client {
  clientId = "";
  readonly verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // RFC 7636's example
  readonly cookies = new Map<string, string>();
  private constructor(private readonly server: Handler) {}

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

  async send(url: 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(new URL(url, SERVER), { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const [name, value] = [pair.slice(0, pair.indexOf("=")), pair.slice(pair.indexOf("=") + 1)];
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** Registers, opens /oauth/authorize, and returns where FrontMCP sends the user: the provider's sign-in page. */
  async startSignIn(scope = "openid") {
    const registered = await this.send("/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: "none" }),
    });
    this.clientId = (await registered.json()).client_id;
    const query = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: REDIRECT_URI, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", scope, state: "client-state" });
    return new URL((await this.send(`/oauth/authorize?${query}`)).headers.get("location")!);
  }

  /** The provider sends the user back to FrontMCP's callback with these parameters. */
  async returnFromProvider(atProvider: URL, params: Record<string, string>) {
    const callback = new URL(atProvider.searchParams.get("redirect_uri")!);
    callback.search = new URLSearchParams({ state: atProvider.searchParams.get("state")!, ...params }).toString();
    const response = await this.send(callback.href);
    const location = response.headers.get("location");
    return { status: response.status, page: await response.text(), location, code: location ? new URL(location).searchParams.get("code") : null };
  }

  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 response.json();
  }

  /** Every step, for tests that only need FrontMCP's tokens. */
  async signIn() {
    const { code } = await this.returnFromProvider(await this.startSignIn(), { code: "code-from-the-provider" });
    return this.token({ grant_type: "authorization_code", code: code!, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  async callTool(accessToken: string, 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, 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 (await response.json()).result;
  }
}
```

The provider never sees the MCP client: it sees `help-desk-mcp`, FrontMCP's client, coming back to FrontMCP's callback. That's why clients register with FrontMCP, not with the provider. The cookie is `__Host-` because the request was `https`; over plain `http`, as on your own machine, it's `frontmcp_signin_<id>` on `Path=/oauth`. The stand-in's token response has no `id_token`, so FrontMCP asks its userinfo endpoint who the user is, as it does for any `id_token` it can't verify.

### Pointing FrontMCP at your provider's endpoints

FrontMCP doesn't read your provider's discovery document. Copy `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint` and `jwks_uri` from the provider's `/.well-known/openid-configuration` into `providerConfig`, and give the provider a short `id`, which becomes part of the callback URL:

```ts providers.ts active
const issuer = { issuer: "https://desk.example.com" };

// A Keycloak realm
export const keycloak = {
  mode: "remote" as const,
  provider: "https://sso.example.com/realms/acme",
  clientId: "help-desk-mcp",
  providerConfig: {
    id: "sso",
    authEndpoint: "https://sso.example.com/realms/acme/protocol/openid-connect/auth",
    tokenEndpoint: "https://sso.example.com/realms/acme/protocol/openid-connect/token",
    userInfoEndpoint: "https://sso.example.com/realms/acme/protocol/openid-connect/userinfo",
    jwksUri: "https://sso.example.com/realms/acme/protocol/openid-connect/certs",
  },
  local: issuer,
};

// An Okta authorization server
export const okta = {
  mode: "remote" as const,
  provider: "https://acme.okta.com/oauth2/default",
  clientId: "help-desk-mcp",
  providerConfig: {
    id: "okta",
    authEndpoint: "https://acme.okta.com/oauth2/default/v1/authorize",
    tokenEndpoint: "https://acme.okta.com/oauth2/default/v1/token",
    userInfoEndpoint: "https://acme.okta.com/oauth2/default/v1/userinfo",
    jwksUri: "https://acme.okta.com/oauth2/default/v1/keys",
  },
  local: issuer,
};

// An Auth0 tenant: /authorize, /userinfo and /.well-known/jwks.json match, the token endpoint doesn't
export const auth0 = {
  mode: "remote" as const,
  provider: "https://acme.eu.auth0.com",
  clientId: "help-desk-mcp",
  providerConfig: { id: "auth0", tokenEndpoint: "https://acme.eu.auth0.com/oauth/token" },
  local: issuer,
};
```

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

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

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

```ts providers.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
import { auth0, keycloak, okta } from "./providers";

/** Registers a client and starts a sign-in on a server with this auth, and returns where FrontMCP sends the user. */
async function redirectFor(auth: object) {
  const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth });
  const redirect_uri = "http://127.0.0.1:33418/callback";
  const registered = await handler(new Request("https://desk.example.com/oauth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ redirect_uris: [redirect_uri], token_endpoint_auth_method: "none" }) }));
  const { client_id } = await registered.json();
  const query = new URLSearchParams({ response_type: "code", client_id, redirect_uri, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const res = await handler(new Request(`https://desk.example.com/oauth/authorize?${query}`));
  const to = new URL(res.headers.get("location")!);
  return { endpoint: to.origin + to.pathname, callback: to.searchParams.get("redirect_uri") };
}

test("Keycloak", async () => {
  expect(await redirectFor(keycloak)).toEqual({
    endpoint: "https://sso.example.com/realms/acme/protocol/openid-connect/auth",
    callback: "https://desk.example.com/oauth/provider/sso/callback",
  });
});

test("Okta", async () => {
  expect(await redirectFor(okta)).toEqual({
    endpoint: "https://acme.okta.com/oauth2/default/v1/authorize",
    callback: "https://desk.example.com/oauth/provider/okta/callback",
  });
});

test("Auth0", async () => {
  expect(await redirectFor(auth0)).toEqual({
    endpoint: "https://acme.eu.auth0.com/authorize",
    callback: "https://desk.example.com/oauth/provider/auth0/callback",
  });
});

test("without providerConfig, the paths are guessed and the id comes from the host", async () => {
  const { providerConfig, ...guessed } = keycloak;
  expect(await redirectFor(guessed)).toEqual({
    endpoint: "https://sso.example.com/realms/acme/authorize",
    callback: "https://desk.example.com/oauth/provider/sso_example_com/callback",
  });
});
```

`tokenEndpoint`, `userInfoEndpoint` and `jwksUri` are used after the callback, so these tests can't show them; a wrong token endpoint fails the sign-in with [`Failed to exchange code with provider`](#failed-to-exchange-code-with-provider). Whatever the provider, it has to:

- accept PKCE (`S256`) and the client secret in the form body (`client_secret_post`), or no secret for a public client;
- return an `id_token` with a `sub` that FrontMCP can verify with the provider's keys, or have a userinfo endpoint whose answer has a `sub`. Plain OAuth providers without OpenID Connect, like GitHub, whose user API has `id` and no `sub`, can't be used: the sign-in fails with [`Could not determine your identity from the provider`](#could-not-determine-your-identity-from-the-provider).

### Registering the callback URL

The provider redirects users to `<issuer>/oauth/provider/<id>/callback`, and it only accepts redirect URIs you registered with it. `<issuer>` is `local.issuer`; without it, FrontMCP makes one up from `http://localhost` and `http.port`, which is wrong everywhere but your own machine:

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

export const auth = {
  mode: "remote" as const,
  provider: "https://auth.example.com",
  clientId: "help-desk-mcp",
};

export const server = (extra: object = {}) => ({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], auth, ...extra });
```

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

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

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

```ts callback.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { auth, server } from "./server";

/** Registers a client, starts a sign-in, and returns the callback URL FrontMCP gives the provider. */
async function callbackUrl(config: any) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const redirect_uri = "http://127.0.0.1:33418/callback";
  const registered = await handler(new Request("https://desk.example.com/oauth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ redirect_uris: [redirect_uri], token_endpoint_auth_method: "none" }) }));
  const { client_id } = await registered.json();
  const query = new URLSearchParams({ response_type: "code", client_id, redirect_uri, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const res = await handler(new Request(`https://desk.example.com/oauth/authorize?${query}`));
  return new URL(res.headers.get("location")!).searchParams.get("redirect_uri");
}

test("local.issuer is FrontMCP's public address", async () => {
  expect(await callbackUrl(server({ auth: { ...auth, local: { issuer: "https://desk.example.com" } } }))).toBe(
    "https://desk.example.com/oauth/provider/auth_example_com/callback",
  );
});

test("without it: http://localhost and http.port, by default 3000", async () => {
  expect(await callbackUrl(server())).toBe("http://localhost:3000/oauth/provider/auth_example_com/callback");
  expect(await callbackUrl(server({ http: { port: 4100 } }))).toBe("http://localhost:4100/oauth/provider/auth_example_com/callback");
});

test("a trailing slash on local.issuer is dropped", async () => {
  expect(await callbackUrl(server({ auth: { ...auth, local: { issuer: "https://desk.example.com/" } } }))).toBe(
    "https://desk.example.com/oauth/provider/auth_example_com/callback",
  );
});
```

Without `http.port`, the made-up callback uses the port the Node server listens on: `PORT`, or `3000`. (Changed in 1.9.2: before, it said `3001`, so even a sign-in on your own machine failed until you set `http.port` or `local.issuer`.) `FRONTMCP_PUBLIC_HOST` changes only the host name. Set `local.issuer` to the URL clients use, and register exactly `<local.issuer>/oauth/provider/<id>/callback` at the provider. Also set [`FRONTMCP_PUBLIC_URL`](https://frontmcp.dev/reference/server/config-files) to the same URL, so the metadata's endpoints name `https` and your host. FrontMCP puts `local.issuer` in its tokens, in the `iss` of its redirects to the client and in the metadata's `issuer`, so they always match (changed in 1.8.5: the metadata's `issuer` used to come from the request). And the metadata's `authorization_endpoint` is where clients start a sign-in, which sets the sign-in cookie: the browser sends it back to the callback only if both are on the same host.

### Choosing between `transparent` and `remote`

The difference shows in which tokens a server accepts. The token here was issued by the provider, signed with its key, for user `nour`. A `transparent` server checks it against the provider's keys and lets `nour` in. A `remote` server only accepts tokens it issued itself, for itself, so it refuses the same token:

```ts servers.ts active
import { HelpDeskApp } from "./help-desk.app";
import { providerKeys } from "./tokens";

const info = { name: "help-desk", version: "1.0.0" };

// The client signs in at the provider and sends the provider's token
export const transparent = {
  info,
  apps: [HelpDeskApp],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    providerConfig: { jwks: providerKeys },
  },
};

// The client signs in through FrontMCP and sends FrontMCP's token
export const remote = {
  info,
  apps: [HelpDeskApp],
  auth: { mode: "remote" as const, provider: "https://auth.example.com", clientId: "help-desk-mcp" },
};
```

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

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

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

```ts tokens.ts
// The provider's public key, and an access token it issued for nour, signed ahead of time.
// nour's token: iss https://auth.example.com, aud https://desk.example.com, scope "tickets:read tickets:write"
export const providerKeys = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] };
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
```

```ts call-as.ts
import { FrontMcpInstance } from "@frontmcp/sdk";

type ServerConfig = Parameters<typeof FrontMcpInstance.createFetchHandler>[0];

/** Gets an anonymous token from a server with allowDefaultPublic, as any client can. */
export async function anonymousToken(config: ServerConfig) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const res = await handler(
    new Request("https://desk.example.com/oauth/token", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ grant_type: "anonymous", client_id: "anyone" }),
    }),
  );
  return (await res.json()).access_token as string;
}

/** Sends one tools/call over HTTP to a server with this config, as a client with this token would. */
export async function callAs(config: ServerConfig, token: string, name: string) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  return handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
}
```

```ts tokens.test.ts
import { test, expect } from "@frontmcp/testing";
import { anonymousToken, callAs } from "./call-as";
import { remote, transparent } from "./servers";
import { NOUR } from "./tokens";

test("transparent accepts the provider's token", async () => {
  const res = await callAs(transparent, NOUR, "whoami");
  expect(res.status).toBe(200);
  expect((await res.json()).result.structuredContent).toEqual({ sub: "nour", scopes: ["tickets:read", "tickets:write"] });
});

test("remote refuses it: only FrontMCP's own tokens get in", async () => {
  const res = await callAs(remote, NOUR, "whoami");
  expect(res.status).toBe(401);
  expect(res.headers.get("www-authenticate")).toContain('error="invalid_token", error_description="\\"alg\\" (Algorithm) Header Parameter value not allowed"');
});

test("…and only the ones it issued for itself", async () => {
  const desk = { ...remote, auth: { ...remote.auth, allowDefaultPublic: true, local: { issuer: "https://desk.example.com" } } };
  const token = await anonymousToken(desk);
  expect((await callAs(desk, token, "whoami")).status).toBe(200);

  // Another server with the same JWT_SECRET: the token's iss isn't its local.issuer
  const billing = { ...desk, auth: { ...desk.auth, local: { issuer: "https://billing.example.com" } } };
  expect((await callAs(billing, token, "whoami")).headers.get("www-authenticate")).toContain('error_description="unexpected \\"iss\\" claim value"');

  // The token's aud is the address it was issued at, https://desk.example.com: a server that expects another refuses it
  const reports = { ...desk, auth: { ...desk.auth, expectedAudience: "https://reports.example.com" } };
  expect((await callAs(reports, token, "whoami")).headers.get("www-authenticate")).toContain('error_description="Token audience does not match this resource"');
});
```

With `remote`, a client that already holds a token from your provider still has to sign in through FrontMCP. In exchange, the provider doesn't need to know about MCP clients at all, and tools can use the provider's own token.

The last test shows what binds a FrontMCP token to the server that issued it. Its `iss` must be `local.issuer`, so another server that shares `JWT_SECRET` refuses it. Its `aud` is the server's address as the client reached it, and it's accepted only at that address, or, with `expectedAudience`, only when it names a listed one. Tokens issued before FrontMCP 1.8.3 have no `aud`, so they're refused after an upgrade, and clients refresh them or sign in again. A server behind a proxy should set [`FRONTMCP_PUBLIC_URL`](https://frontmcp.dev/reference/server/config-files) so that its address doesn't depend on the `Host` header.

### Only letting known clients in

`/oauth/authorize` only accepts clients FrontMCP knows: ones that registered at `/oauth/register`, and [metadata document URLs](https://frontmcp.dev/reference/auth/cimd). A registered client is kept to its own redirect URIs. A client FrontMCP has never heard of has none to check against, so with `requireRegisteredClients: false` FrontMCP sends the code wherever the request says, and someone who gets a signed-in user to open their link receives a code for that user:

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "remote" as const,
    provider: "https://auth.example.com",
    clientId: "help-desk-mcp",
    local: { issuer: "https://desk.example.com" },
  },
};
```

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

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

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

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

const CALLBACK = "http://127.0.0.1:33418/callback";

async function authorize(handler: (r: Request) => Promise<Response>, clientId: string, redirectUri = CALLBACK) {
  const query = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirectUri, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const res = await handler(new Request(`https://desk.example.com/oauth/authorize?${query}`));
  return { status: res.status, location: res.headers.get("location"), page: await res.text() };
}

async function register(handler: (r: Request) => Promise<Response>, redirectUri: string) {
  const res = await handler(
    new Request("https://desk.example.com/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [redirectUri], token_endpoint_auth_method: "none" }),
    }),
  );
  return { status: res.status, body: await res.json() };
}

test("an unknown client is refused, with an error page instead of a redirect", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const res = await authorize(handler, "whoever", "https://attacker.example.net/cb");
  expect(res.status).toBe(400);
  expect(res.location).toBeNull();
  expect(res.page).toContain("Unknown client_id: register the client (DCR / pre-registered) or use a CIMD client-id URL");
});

test("a registered client signs in, but only to its own redirect URI", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const { status, body } = await register(handler, CALLBACK);
  expect(status).toBe(201);
  expect(body).toMatchObject({ client_id: expect.any(String), redirect_uris: [CALLBACK], token_endpoint_auth_method: "none" });

  expect((await authorize(handler, body.client_id)).location).toMatch(/^https:\/\/auth\.example\.com\/authorize\?/);
  const elsewhere = await authorize(handler, body.client_id, "https://attacker.example.net/cb");
  expect(elsewhere.status).toBe(400);
  expect(elsewhere.page).toContain("redirect_uri is not registered for this client");
});

test("with requireRegisteredClients: false, anyone's redirect URI is followed", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ ...config, auth: { ...config.auth, requireRegisteredClients: false } });
  const res = await authorize(handler, "whoever", "https://attacker.example.net/cb");
  expect(res.status).toBe(302); // on to the provider, and the code will go to attacker.example.net
  expect(res.location).toMatch(/^https:\/\/auth\.example\.com\/authorize\?/);
});

test("registration takes loopback redirect URIs only", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  expect(await register(handler, "https://notes.example.com/callback")).toEqual({
    status: 400,
    body: {
      error: "invalid_redirect_uri",
      error_description: "Registration allows only loopback redirect_uris (localhost, 127.0.0.0/8, ::1) without an allowlist; got https://notes.example.com/callback",
    },
  });
});
```

Registration at `/oauth/register` is only for clients on the user's own machine: it takes loopback redirect URIs only, and it's off in production (`NODE_ENV=production`: `404` `Dynamic Client Registration is disabled.`, and the metadata stops advertising it). Remote mode has no option to change either, so in production, a remote-mode server with the defaults only lets in clients that identify themselves with a [metadata document URL](https://frontmcp.dev/reference/auth/cimd).

> **Pitfall: Turning requireRegisteredClients off lets anyone collect sign-ins**
With `false`, FrontMCP redirects the code to whatever `redirect_uri` an unknown client names, and the provider can't stop it: it only sees FrontMCP's own client and callback. Keep it for development on your own machine. It was the default before FrontMCP 1.8.3, so a server that sets it only to keep old clients working should move them to registration or CIMD instead.

### Calling the provider's API as the user

FrontMCP keeps the provider's access token from the sign-in, and its refresh token if it sent one. A tool reads the access token with `this.orchestration.getToken(id)`, where `id` is the provider id, and calls the provider's API as the user. Here the provider's id is `sso`, and `provider.example.ts` answers its API at `/api/groups` for the tokens it handed out. The tests also show how the token is renewed, and how it follows FrontMCP's own:

```ts my-groups.tool.ts active
import { PublicMcpError, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "my_groups", description: "List the groups the user belongs to at the identity provider", inputSchema: {} })
export class MyGroups extends ToolContext {
  async execute() {
    // `sso` is providerConfig.id; without an id, tryGetToken() finds nothing
    const token = await this.orchestration.tryGetToken("sso");
    if (!token) {
      this.fail(new PublicMcpError("Your sign-in with the identity provider has expired. Ask the user to sign in again.", "SIGN_IN_AGAIN"));
    }
    const res = await this.fetch("https://auth.example.com/api/groups", {
      headers: { authorization: `Bearer ${token}` },
    });
    return { groups: await res.json() };
  }
}
```

```ts server.ts
import { App } from "@frontmcp/sdk";
import { MyGroups } from "./my-groups.tool";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "remote" as const,
    provider: "https://auth.example.com",
    clientId: "help-desk-mcp",
    providerConfig: { id: "sso" },
    local: { issuer: "https://desk.example.com" },
  },
};
```

```ts groups.test.ts
import { test, expect } from "@frontmcp/testing";
import { standInProvider } from "./provider.example";
import { Client } from "./sign-in";
import { config } from "./server";

test("the tool calls the provider's API with the user's provider token", async () => {
  const provider = standInProvider();
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn();
    expect((await client.callTool(access_token, "my_groups")).structuredContent).toEqual({ groups: ["support", "billing"] });
    expect(provider.requests.at(-1)).toEqual({ method: "GET", path: "/api/groups", token: "provider-token-1" });
  } finally {
    provider.stop();
  }
});

test("when the client refreshes FrontMCP's token, the provider's token moves to the new one", async () => {
  const provider = standInProvider();
  try {
    const client = await Client.for(config);
    const tokens = await client.signIn();
    const refreshed = await client.token({ grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: client.clientId });
    expect((await client.callTool(refreshed.access_token, "my_groups")).structuredContent).toEqual({ groups: ["support", "billing"] });
    expect(await client.callTool(tokens.access_token, "my_groups")).toMatchObject({ isError: true, _meta: { code: "SIGN_IN_AGAIN" } });
  } finally {
    provider.stop();
  }
});

test("a provider token close to its expiry is renewed with its refresh token", async () => {
  // expires in 30 seconds, less than the default skewSeconds of 60
  const provider = standInProvider({ expiresIn: 30, refreshToken: true });
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn();
    expect((await client.callTool(access_token, "my_groups")).structuredContent).toEqual({ groups: ["support", "billing"] });
    expect(provider.requests.slice(-2)).toEqual([
      { method: "POST", path: "/token", form: { grant_type: "refresh_token", refresh_token: "provider-refresh-1", client_id: "help-desk-mcp" } },
      { method: "GET", path: "/api/groups", token: "provider-token-2" },
    ]);
  } finally {
    provider.stop();
  }
});

test("with refresh.enabled: false, it isn't renewed", async () => {
  const provider = standInProvider({ expiresIn: 30, refreshToken: true });
  try {
    const client = await Client.for({ ...config, auth: { ...config.auth, refresh: { enabled: false } } });
    const { access_token } = await client.signIn();
    await client.callTool(access_token, "my_groups");
    expect(provider.requests.at(-1)).toEqual({ method: "GET", path: "/api/groups", token: "provider-token-1" });
  } finally {
    provider.stop();
  }
});

test("a renewal the provider refuses means signing in again", async () => {
  const provider = standInProvider({ expiresIn: 30, refreshToken: true, refuseRenewal: true });
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn();
    expect(await client.callTool(access_token, "my_groups")).toMatchObject({ isError: true, _meta: { code: "SIGN_IN_AGAIN" } });
  } finally {
    provider.stop();
  }
});

test("without a refresh token, the provider's token ends at its expires_in", async () => {
  const provider = standInProvider({ expiresIn: 1 });
  try {
    const client = await Client.for(config);
    const { access_token } = await client.signIn();
    expect((await client.callTool(access_token, "my_groups")).structuredContent).toEqual({ groups: ["support", "billing"] });
    await new Promise((resolve) => setTimeout(resolve, 1100));
    expect(await client.callTool(access_token, "my_groups")).toMatchObject({ isError: true, _meta: { code: "SIGN_IN_AGAIN" } });
  } finally {
    provider.stop();
  }
});
```

```ts provider.example.ts hidden
// Stands in for the provider at auth.example.com, which the Playground can't reach:
// it answers FrontMCP's requests to its token and userinfo endpoints, and its API.
export function standInProvider({ expiresIn = 3600, refreshToken = false, refuseRenewal = false } = {}) {
  const realFetch = globalThis.fetch;
  const requests: { method: string; path: string; form?: Record<string, string>; token?: string }[] = [];
  const issued = new Set<string>();
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
    const url = new URL(input instanceof Request ? input.url : String(input));
    if (url.origin !== "https://auth.example.com") return realFetch(input, init);
    const method = init?.method ?? "GET";
    const form = method === "POST" ? Object.fromEntries(new URLSearchParams(String(init?.body))) : undefined;
    const token = new Headers(init?.headers).get("authorization")?.replace(/^Bearer /, "");
    requests.push({ method, path: url.pathname, ...(form ? { form } : {}), ...(url.pathname === "/api/groups" ? { token } : {}) });
    const signedIn = token !== undefined && issued.has(token);
    if (url.pathname === "/token") {
      if (form?.grant_type === "refresh_token" && refuseRenewal) return Response.json({ error: "invalid_grant" }, { status: 400 });
      const n = issued.size + 1;
      issued.add(`provider-token-${n}`);
      return Response.json({ access_token: `provider-token-${n}`, token_type: "Bearer", expires_in: expiresIn, ...(refreshToken ? { refresh_token: `provider-refresh-${n}` } : {}) });
    }
    if (url.pathname === "/userinfo" && signedIn) return Response.json({ sub: "auth0|nour", email: "nour@example.com", name: "Nour Haddad" });
    if (url.pathname === "/api/groups" && signedIn) return Response.json(["support", "billing"]);
    return new Response("Not found", { status: 404 });
  }) as typeof fetch;
  return { requests, stop: () => void (globalThis.fetch = realFetch) };
}
```

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

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

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

/** An MCP client, and the user's browser with its cookies, signing in through FrontMCP. */
export class Client {
  clientId = "";
  readonly verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // RFC 7636's example
  readonly cookies = new Map<string, string>();
  private constructor(private readonly server: Handler) {}

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

  async send(url: 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(new URL(url, SERVER), { redirect: "manual", ...init, headers }));
    for (const cookie of response.headers.getSetCookie()) {
      const [pair] = cookie.split(";");
      const [name, value] = [pair.slice(0, pair.indexOf("=")), pair.slice(pair.indexOf("=") + 1)];
      if (value) this.cookies.set(name, value);
      else this.cookies.delete(name);
    }
    return response;
  }

  /** Registers, opens /oauth/authorize, and returns where FrontMCP sends the user: the provider's sign-in page. */
  async startSignIn(scope = "openid") {
    const registered = await this.send("/oauth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ client_name: "Desk CLI", redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: "none" }),
    });
    this.clientId = (await registered.json()).client_id;
    const query = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: REDIRECT_URI, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", scope, state: "client-state" });
    return new URL((await this.send(`/oauth/authorize?${query}`)).headers.get("location")!);
  }

  /** The provider sends the user back to FrontMCP's callback with these parameters. */
  async returnFromProvider(atProvider: URL, params: Record<string, string>) {
    const callback = new URL(atProvider.searchParams.get("redirect_uri")!);
    callback.search = new URLSearchParams({ state: atProvider.searchParams.get("state")!, ...params }).toString();
    const response = await this.send(callback.href);
    const location = response.headers.get("location");
    return { status: response.status, page: await response.text(), location, code: location ? new URL(location).searchParams.get("code") : null };
  }

  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 response.json();
  }

  /** Every step, for tests that only need FrontMCP's tokens. */
  async signIn() {
    const { code } = await this.returnFromProvider(await this.startSignIn(), { code: "code-from-the-provider" });
    return this.token({ grant_type: "authorization_code", code: code!, redirect_uri: REDIRECT_URI, client_id: this.clientId, code_verifier: this.verifier });
  }

  async callTool(accessToken: string, 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, 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 (await response.json()).result;
  }
}
```

What to expect:

- `getToken(id)` returns the token, and throws `TokenNotAvailableError` (`OrchestratedAuthorization: No tokens available for provider "sso"`) when there's none; `tryGetToken(id)` returns `null` instead. Without an id, `tryGetToken()` returns `null` and `getToken()` throws `NoProviderIdError`: `this.orchestration.primaryProviderId` is `undefined` in remote mode.
- When the provider gave a refresh token, FrontMCP renews the provider's token with it when a tool reads the token less than [`refresh.skewSeconds`](#options) before it expires, 60 by default, or after: a form `POST` to the token endpoint with `grant_type=refresh_token`, the refresh token, `client_id`, and `client_secret` if you set one. A provider that sends no new refresh token keeps the old one in use, and calls that need a renewal at the same time share one request. FrontMCP keeps a provider token that has a refresh token for 30 days after it was stored or last renewed, as long as its own refresh tokens last.
- When the client [refreshes](#how-a-sign-in-works) FrontMCP's token, the provider's token moves to the new one: the old access token no longer finds it.
- The token stops being available when the provider refuses the renewal, when `refresh.enabled` is `false` and it expires, and, without a refresh token, when its `expires_in` passes. Handle `null`, and tell the user to sign in again.
- Changed in 1.9.3: before, FrontMCP never used the provider's refresh token, and a client refresh lost the provider's token.
- The token never reaches the model or the client unless your tool returns it. Don't.

### Checking that the caller signed in

Every request to a remote-mode server carries a token FrontMCP issued, and a sign-in the user declines at the provider ends with an error page, not a token. The only tokens for callers who didn't sign in are the anonymous ones `allowDefaultPublic` hands to any client. Their holder is anonymous everywhere: `this.auth.isAnonymous` is `true`, `sub` is `anon:` and a new id, the scopes are `anonymousScopes`, and [authorities](https://frontmcp.dev/reference/auth/authorities#refusing-anonymous-callers) rules see a caller without a `user.sub`. This server has `allowDefaultPublic`, so the test can get an anonymous token the way any client could. `close_ticket` refuses it:

```ts close-ticket.tool.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "close_ticket", description: "Close a support ticket. Signed-in users only.", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (this.auth.isAnonymous) {
      this.fail(new PublicMcpError("Only signed-in users can close tickets. Ask the user to sign in.", "SIGN_IN_REQUIRED"));
    }
    return { id, status: "closed", closedBy: this.auth.user.email ?? this.auth.user.sub };
  }
}
```

```ts whoami.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

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

```ts server.ts
import { App } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";
import { WhoAmI } from "./whoami.tool";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: {
    mode: "remote" as const,
    provider: "https://auth.example.com",
    clientId: "help-desk-mcp",
    allowDefaultPublic: true,
  },
};
```

```ts anonymous.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Tool, ToolContext } from "@frontmcp/sdk";
import { config } from "./server";

type Handler = (r: Request) => Promise<Response>;

async function call(handler: Handler, token: string, name: string, args: object = {}) {
  const res = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name, authorization: `Bearer ${token}` },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  return (await res.json()).result;
}

async function anonymousToken(handler: Handler) {
  const res = await handler(
    new Request("https://desk.example.com/oauth/token", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ grant_type: "anonymous", client_id: "anyone" }),
    }),
  );
  return { status: res.status, body: await res.json() };
}

test("any client can get an anonymous token, and its holder is anonymous", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const { status, body } = await anonymousToken(handler);
  expect(status).toBe(200);
  expect(body).toMatchObject({ access_token: expect.any(String), token_type: "Bearer", expires_in: 86400 });
  expect((await call(handler, body.access_token, "whoami")).structuredContent).toEqual({
    sub: expect.stringMatching(/^anon:/),
    isAnonymous: true,
    scopes: ["anonymous"],
  });
});

test("close_ticket refuses it", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const { body } = await anonymousToken(handler);
  const result = await call(handler, body.access_token, "close_ticket", { id: "T-1" });
  expect(result).toMatchObject({ isError: true, _meta: { code: "SIGN_IN_REQUIRED" } });
});

test("so does an authorities rule that needs a signed-in caller", async () => {
  @Tool({ name: "add_note", description: "Add a note to a ticket. Signed-in users only.", inputSchema: {}, authorities: "authenticated" })
  class AddNote extends ToolContext {
    async execute() {
      return { added: true };
    }
  }
  @App({ id: "notes", name: "Notes", tools: [AddNote] })
  class Notes {}
  const authenticated = { attributes: { conditions: [{ path: "user.sub", op: "exists" as const, value: true }] } };
  const handler = await FrontMcpInstance.createFetchHandler({ ...config, apps: [Notes], authorities: { profiles: { authenticated } } });
  const { body } = await anonymousToken(handler);
  const result = await call(handler, body.access_token, "add_note");
  expect(result).toMatchObject({ isError: true, _meta: { code: "AUTHORITY_DENIED" } });
  expect(result.content[0].text).toContain("profile:authenticated: attributes.conditions: 'user.sub' failed 'exists' check");
});

test("anonymousScopes sets its scopes", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ ...config, auth: { ...config.auth, anonymousScopes: ["tickets:read"] } });
  const { body } = await anonymousToken(handler);
  expect((await call(handler, body.access_token, "whoami")).structuredContent.scopes).toEqual(["tickets:read"]);
});

test("without allowDefaultPublic there's no anonymous token", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ ...config, auth: { ...config.auth, allowDefaultPublic: false } });
  expect(await anonymousToken(handler)).toEqual({
    status: 400,
    body: { error: "unsupported_grant_type", error_description: "Anonymous access is not enabled on this server" },
  });
});
```

An [`authenticated` profile](https://frontmcp.dev/reference/auth/authorities#refusing-anonymous-callers) refuses them too, before the tool runs, as the third test shows. A sign-in declined at the provider gets a `400` page, [`Sign-in was declined at the identity provider.`](#sign-in-was-declined-at-the-identity-provider), and the client never gets a code, as [the first Playground](#putting-frontmcp-in-front-of-your-provider) shows. Leave `allowDefaultPublic` off unless tools are meant for anonymous callers.

---

## Troubleshooting

### `Upstream identity provider is not configured`

`/oauth/authorize` answers `500` with this page when the server has no `clientId`, once the client itself has passed the checks. FrontMCP can't register itself with the provider (`providerConfig.dcrEnabled` is accepted but does nothing), so register a client at the provider and set `clientId`, plus `clientSecret` for a confidential client. The server logs `Remote mode: no clientId configured for provider "…"` when it starts.

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  // 🚩 no clientId
  auth: { mode: "remote" as const, provider: "https://auth.example.com", providerConfig: { dcrEnabled: true } },
};
```

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

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

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

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

test("sign-in fails before it reaches the provider", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const redirect_uri = "http://127.0.0.1:33418/callback";
  const registered = await handler(new Request("https://desk.example.com/oauth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ redirect_uris: [redirect_uri], token_endpoint_auth_method: "none" }) }));
  const { client_id } = await registered.json();
  const query = new URLSearchParams({ response_type: "code", client_id, redirect_uri, code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge_method: "S256" });
  const res = await handler(new Request(`https://desk.example.com/oauth/authorize?${query}`));
  expect(res.status).toBe(500);
  expect(await res.text()).toContain("Upstream identity provider is not configured");
});
```

### The provider says the redirect URI is invalid

The provider rejects `redirect_uri` before the user can sign in, with an error like `redirect_uri_mismatch` or `Invalid parameter: redirect_uri`. FrontMCP sent `<issuer>/oauth/provider/<id>/callback`, and the provider has something else registered. Read the `redirect_uri` from the redirect `/oauth/authorize` returns, and register exactly that. Usually `local.issuer` is missing (`http://localhost:3000/…`), has a trailing slash (`//oauth`), or `providerConfig.id` changed. See [Registering the callback URL](#registering-the-callback-url).

### The callback answers `404` `{"error":"Not Found","entryPaths":["/"]}`

The server runs FrontMCP 1.8.3 or earlier on [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) (Cloudflare Workers, Deno, Bun, a framework route), which didn't serve `/oauth/provider/<id>/callback`. Upgrade to 1.8.4 or later, which serve it. On 1.8.4, the provider sent the user to a path FrontMCP didn't give it, like one whose provider id has broken percent-encoding: register the `redirect_uri` from the redirect `/oauth/authorize` returns.

### `Failed to exchange code with provider`

The page reads `provider_error`, then `Failed to exchange code with provider:` and the provider's `error_description`. The provider refused FrontMCP's code exchange. Check, in order:

1. **The token endpoint.** Without `providerConfig.tokenEndpoint`, FrontMCP posts to `<provider>/token`.
2. **The client secret.** FrontMCP sends `client_id` and `client_secret` in the form body. A provider set to accept the secret only in an `Authorization: Basic` header refuses it; switch the client to `client_secret_post`.
3. **The redirect URI.** The exchange repeats the callback URL, which must match the one the sign-in used.

### `Could not determine your identity from the provider`

The page reads `access_denied`. The token response had no `id_token` that FrontMCP could verify, and the userinfo endpoint didn't answer, or answered without a `sub`. Check `providerConfig.userInfoEndpoint`, and give FrontMCP the provider's keys with `providerConfig.jwksUri` if they aren't at `<provider>/.well-known/jwks.json`. Add `openid` to `scopes`, and make sure the provider is an OpenID Connect provider: a plain OAuth API like GitHub's has no `sub`.

### `Sign-in was declined at the identity provider.`

The page reads `access_denied`. The provider sent the user back with `error=access_denied`, usually because they declined. Any other error from the provider gets the same `400`, reading `provider_error` and `Authentication provider error:` with the provider's `error_description`, or its `error`. Either way the sign-in is over and the client gets no code: the user starts again from the client.

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

The page reads `invalid_request`. The request that came back to the callback didn't carry the sign-in cookie that `/oauth/authorize` set. Either the user finished the sign-in in another browser, or with cookies blocked, or the client opened `/oauth/authorize` at another host than `local.issuer`'s, like `127.0.0.1` for `localhost`, so the browser kept the cookie for that host. Set `local.issuer` and [`FRONTMCP_PUBLIC_URL`](https://frontmcp.dev/reference/server/config-files) to the same URL, so the metadata sends clients to the host the provider returns to. See [Registering the callback URL](#registering-the-callback-url).

### `Authorization response came from an unexpected issuer.`

The page reads `invalid_request`. The provider's redirect to the callback had an `iss` that isn't `provider`. If your provider names itself differently, add that issuer to `providerConfig.additionalIssuers`.

### `Authentication session expired. Please try again.`

The callback's `state` doesn't match a sign-in FrontMCP started. The sign-in began on another instance, or before a restart, with the default `tokenStorage: "memory"`; or the user used the provider's redirect twice. Use `{ redis }` or `{ sqlite }` for `tokenStorage` when there's more than one instance, and start the sign-in again.

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

The client's `client_id` is neither registered nor an `https` metadata URL, and `requireRegisteredClients` is on, as it is by default since FrontMCP 1.8.3. See [Only letting known clients in](#only-letting-known-clients-in) and [Client ID metadata](https://frontmcp.dev/reference/auth/cimd).

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

The token was issued by FrontMCP, but not by this server for this address: another server that shares `JWT_SECRET` issued it, `local.issuer` changed, or the client reached the server at another address than the one it signed in at, like through a proxy that changes the `Host` header. Tokens issued before FrontMCP 1.8.3 have no `aud` and get the second message too. The client should sign in again. Pin the server's address with [`FRONTMCP_PUBLIC_URL`](https://frontmcp.dev/reference/server/config-files), or list every address clients use in `expectedAudience`. See [Choosing between `transparent` and `remote`](#choosing-between-transparent-and-remote).

### `this.auth.scopes` is missing a scope the client asked for

`allowedScopes` doesn't list it. The default allows `openid`, `profile`, `email` and `offline_access` only, so a scope of your own, like `tickets:read`, is dropped until you add it, or a pattern like `tickets:*`.

### Tokens from my provider get `401` with `"alg" (Algorithm) Header Parameter value not allowed`

A remote-mode server accepts only the tokens it issued: HS256, signed with `JWT_SECRET`. A client that sends your provider's token has to sign in through FrontMCP instead, or the server should use [`transparent`](#choosing-between-transparent-and-remote).

### `No tokens available for provider "…"`

`this.orchestration.getToken(id)` threw `TokenNotAvailableError`. Check the id: it's `providerConfig.id`, or the host name with dots turned into `_`. Without an id, `getToken()` throws `NoProviderIdError` instead. If the id is right, the provider's token expired without a refresh token to renew it, or the token the client sent is one it has since refreshed, and the user has to sign in again. The same error says `Provider "<id>" did not refresh its token; the user has to sign in again` when the provider refused the renewal, and `Token expired for provider "<id>" and refresh not available` when `refresh.enabled` is `false`. See [Calling the provider's API as the user](#calling-the-providers-api-as-the-user).

### `this.auth.roles` is empty, or a claim from the provider is missing

FrontMCP's token carries only the provider's `sub`, `email` and `name`. Roles, groups, tenants and other claims stay at the provider: read them from its API with the provider's token, or keep them in your own records under the user's `sub`.

### `JwtSecretRequiredError`, or `500` `JWT_SECRET_REQUIRED`, in production

With `NODE_ENV=production`, remote mode refuses to start without `JWT_SECRET`: `createFetchHandler()` and the Node server reject with `JwtSecretRequiredError` (`JWT_SECRET is required in production for auth.mode "remote"…`), and on an edge isolate every request answers `500` `{"error":"server_misconfigured","code":"JWT_SECRET_REQUIRED",…}`. Set it to at least 32 bytes, like the output of `openssl rand -hex 32`, and give every instance the same one. See [Auth in production](https://frontmcp.dev/reference/auth/production).
