Remote and proxied auth

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 is simpler: FrontMCP checks the provider's tokens and issues none.

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

Reference

auth: { mode: "remote" }

Set it on @FrontMcp. Here the provider is a Keycloak realm, whose endpoints don't use the paths FrontMCP assumes, so they're spelled out:

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.

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 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 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 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 for a tool that reads it.

The first Playground below runs every step through createFetchHandler(), with a stand-in for the provider, including a declined sign-in and a callback without the cookie or with another iss. id_tokens that don't verify were checked with a stand-in provider too.

Options

OptionTypeDefaultDescription
providerstringRequiredThe 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.
clientIdstringFrontMCP'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.
clientSecretstringSent 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.
scopesstring[]["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.idstringThe provider's host name, dots turned into _Names the provider in the callback path, /oauth/provider/<id>/callback, and in this.orchestration.getToken(id). https://auth.example.com gives auth_example_com.
providerConfig.authEndpointstring<provider>/authorizeWhere users are sent to sign in.
providerConfig.tokenEndpointstring<provider>/tokenWhere FrontMCP exchanges the provider's code.
providerConfig.userInfoEndpointstring<provider>/userinfoAsked for the user's identity when the token response has no id_token that verifies.
providerConfig.namestringThe idA display name. No page in remote mode shows it.
providerConfig.jwks, providerConfig.jwksUri{ keys: JWK[] }, string<provider>/.well-known/jwks.jsonThe 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 has the URLs. An id_token that doesn't verify is ignored, and the userinfo endpoint is asked instead.
providerConfig.dcrEnabled, providerConfig.registrationEndpointfalseMeant to let FrontMCP register itself with the provider. Not implemented in 1.9.3: you need clientId.
providerConfig.additionalIssuers, providerConfig.verifyIssuerstring[], booleannone, trueMore iss values the provider's callback may name, and false to accept any.
local.issuerstringhttp://localhost:<http.port> for the callback, where http.port defaults to the PORT environment variable, or 3000FrontMCP'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 gives them: FRONTMCP_PUBLIC_URL, or else the address the request came to. Set it in every deployment: see Registering the callback URL.
local.signKey, local.jwksAccepted and not used yet: tokens are HS256, signed with JWT_SECRET.
requireRegisteredClientsbooleantrueRefuses sign-ins from client ids FrontMCP doesn't know: only registered clients and 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.
allowedScopesstring[]["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.
cimdCimdConfigOnHow FrontMCP fetches and checks client metadata documents. See Client ID metadata.
consentConsentConfigOff{ 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.
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.
allowDefaultPublicbooleanfalseLets 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.
anonymousScopesstring[]["anonymous"]The scopes of those anonymous tokens.
federatedAuth, incrementalAuthFor several providers at once (Upstream providers), and for granting apps one at a time (Progressive auth).
secureStore, ui, extrasAs in local mode and Custom login UI.
expectedAudiencestring | string[]The address the request came toThe 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 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.

What FrontMCP serves

RequestAnswer
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-serverFrontMCP'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.jsonAn RS256 public key. FrontMCP signs nothing with it, so it can't be used to check FrontMCP's tokens.
POST /oauth/registerDynamic client registration. Outside production only, and only for loopback redirect URIs (localhost, 127.0.0.1, ::1).
GET /oauth/authorizeA 302 to the provider, which sets the sign-in cookie.
GET /oauth/provider/<id>/callbackWhere the provider sends the user back. Needs the sign-in cookie. Served by createFetchHandler() too since 1.8.4.
POST /oauth/tokenauthorization_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.

What tools see

A caller who signed in through the provider arrives with FrontMCP's token, and this.auth is built from it:

Value
user.subThe provider's sub for the user, unchanged, like auth0|nour.
user.name, user.emailThe provider's name and email, from the id_token or userinfo.
isAnonymousfalse. Only an anonymous token gives true.
scopesThe 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.
claimsFrontMCP'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).

Compared with transparent and local

transparentremotelocal
Where users sign inAt your provider, which the client talks to directlyAt your provider, through FrontMCPOn FrontMCP's own page
Who issues the token clients sendYour providerFrontMCPFrontMCP
Clients register withYour providerFrontMCP (or CIMD)FrontMCP (or CIMD)
What FrontMCP checks per requestSignature against the provider's keys, issuer, expiry, audience, requiredScopesIts own HS256 signature, expiry, issuer and audienceIts own HS256 signature, expiry, issuer and audience
this.auth.scopesGranted by the providerAsked for by the client, within allowedScopesAsked for by the client, within allowedScopes
Claims tools seeEvery claim of the provider's tokensub, email, namesub, email, name from the login
The provider's token, for its APIThe caller's own token, this.context.authInfo.tokenthis.orchestration.getToken(id), until it expiresNone, unless you add upstream providers
Needs JWT_SECRETNoYes, in productionYes, in production
Works on createFetchHandler()YesYes, since 1.8.4Yes

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 has no provider at all.

Caveats

  • Changed in 1.8.4: it runs on createFetchHandler(). Before, createFetchHandler() 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.
  • 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. 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 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, and 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

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:

Open
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" },
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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,
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

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:

Open
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 });

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

Open
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" },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

Open
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" },
  },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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:

Open
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() };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

Open
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 };
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

An authenticated profile 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., and the client never gets a code, as the first Playground 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.

Open
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 } },
};

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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

The server runs FrontMCP 1.8.3 or earlier on createFetchHandler() (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 to the same URL, so the metadata sends clients to the host the provider returns to. See 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 and Client ID metadata.

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, or list every address clients use in expectedAudience. See 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.

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.

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.