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:
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
- A client calls the server without a token. It gets
401withWWW-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-serverlists FrontMCP's own endpoints:/oauth/authorize,/oauth/token,/oauth/userinfo, and/oauth/registeroutside production. - The client identifies itself: by registering at
/oauth/register, or by using a metadata document URL as itsclient_id. Other client ids are refused, unless you setrequireRegisteredClients: false. - The client sends the user to
/oauth/authorizewith its PKCE challenge. FrontMCP redirects straight to the provider's authorization endpoint, with its ownclient_id, aredirect_uriof<issuer>/oauth/provider/<id>/callback, astateoffederated:…, a PKCE challenge of its own, andscopeset toscopes. The redirect also sets a cookie,frontmcp_signin_<id>(__Host-frontmcp_signin_<id>overhttps), that ties the sign-in to the user's browser. There's no FrontMCP login page and no choice of provider. - The user signs in at the provider, which redirects back to the callback with a
code. The callback needs the sign-in cookie, and anissin it must beprovider. If the user declines, or the provider answers with any other error, the sign-in ends here with a400page, and nobody gets a token. - FrontMCP exchanges the code at the provider's token endpoint (a form
POSTwithclient_id,client_secretif you set one, and its PKCE verifier). It takes the user's identity from theid_tokenin the response if it verifies: signed with one of the provider's keys, issued byprovider, forclientId, and not expired. Otherwise it asks the provider's userinfo endpoint. It stores the provider's tokens, encrypted, intokenStorage. - With
consenton, FrontMCP now shows a page where the user picks the tools the client may use. - FrontMCP redirects to the client's
redirect_uriwith a code of its own, the client'sstate, andiss, and clears the sign-in cookie. - The client exchanges that code at
/oauth/tokenand gets FrontMCP's access token (an HS256 JWT, valid for an hour) and a refresh token (30 days, replaced on every use). The token'sscopeis the scopes the client asked for thatallowedScopesallows, and itsaudis the server's address. - The client sends the access token on every request. FrontMCP checks its signature, its expiry, that its
issislocal.issuer, and that itsaudis 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
| 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. | |
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). 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 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 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.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 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. |
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. |
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. |
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. |
anonymousScopes | string[] | ["anonymous"] | The scopes of those anonymous tokens. |
federatedAuth, incrementalAuth | For several providers at once (Upstream providers), and for granting apps one at a time (Progressive auth). | ||
secureStore, ui, extras | As in local mode and Custom 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 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
| 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.
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.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 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).
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) | 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 has no provider at all.
Caveats
- Changed in 1.8.4: it runs on
createFetchHandler(). Before,createFetchHandler()matched the callback path/oauth/provider/:providerId/callbackliterally, so the provider's redirect got404, 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-configurationonly to find its keys: it appends/authorize,/tokenand/userinfotoprovider. Few providers use those three paths; set them inproviderConfig, as in Pointing FrontMCP at your provider's endpoints. - The provider's
id_tokenis used only when it verifies: signed with one of the provider's keys, withissprovider, anaudthat includesclientId, and anexp. 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,localhostexcepted outside production. SetproviderConfig.jwksUrito 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/authorizeset, and a browser sends a cookie only to the host that set it. So the client must open/oauth/authorizeat the host oflocal.issuer: a sign-in started at127.0.0.1whilelocal.issuerishttp://localhost:3000gets a400page. 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 onceexpires_inhas passed, though FrontMCP's own token still works, and the user gets it back only by signing in again: ask foroffline_accessinscopesif 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
authundersplitByAppdon't get one provider each:/oauth/authorizeis served once, at the root, and sends everyone to the first app's provider. On the shared endpoint, an app's ownauthis checked per call only withincrementalAuth: 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 withJwtSecretRequiredError. 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:
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:
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_tokenwith asubthat FrontMCP can verify with the provider's keys, or have a userinfo endpoint whose answer has asub. Plain OAuth providers without OpenID Connect, like GitHub, whose user API hasidand nosub, can't be used: the sign-in fails withCould 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:
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:
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:
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:
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 throwsTokenNotAvailableError(OrchestratedAuthorization: No tokens available for provider "sso") when there's none;tryGetToken(id)returnsnullinstead. Without an id,tryGetToken()returnsnullandgetToken()throwsNoProviderIdError:this.orchestration.primaryProviderIdisundefinedin 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.skewSecondsbefore it expires, 60 by default, or after: a formPOSTto the token endpoint withgrant_type=refresh_token, the refresh token,client_id, andclient_secretif 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.enabledisfalseand it expires, and, without a refresh token, when itsexpires_inpasses. Handlenull, 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:
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.
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:
- The token endpoint. Without
providerConfig.tokenEndpoint, FrontMCP posts to<provider>/token. - The client secret. FrontMCP sends
client_idandclient_secretin the form body. A provider set to accept the secret only in anAuthorization: Basicheader refuses it; switch the client toclient_secret_post. - 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.