Tokens and sessions
A client sends its credential in the Authorization header of every request, and FrontMCP checks it before any of your code runs. In transparent mode the credential is a JWT your identity provider signed, and FrontMCP verifies its signature against the provider's public keys, then its issuer, expiry, audience and scopes. This page covers that check in full, the tokens FrontMCP signs itself, static keys, what your code gets from a token, the sessions older clients keep, what happens when a token expires, and the errors a client sees when it's refused.
auth: {
mode: "transparent",
provider, // who issues tokens: the token's iss
expectedAudience?, // who they're for: the token's aud
requireAudience?, requiredScopes?,
providerConfig?: { jwks?, jwksUri?, additionalIssuers?, verifyIssuer? },
}
// on every request: Authorization: Bearer <JWT>
Reference
How a request is authenticated
For every request to the MCP endpoint, in this order:
staticmode compares the credential with itstokens, and lets the request in or refuses it. Nothing below applies.publicmode lets the request in as an anonymous caller, unless it carries a bearer JWT. That JWT is checked as one FrontMCP issued.transparentmode withallowAnonymouslets a request without a bearer token in as an anonymous caller.- No
Authorizationheader, or no bearer token in it:401. - A token that isn't a JWT, three base64url parts separated by dots:
401,Token is not a valid JWT. - The JWT is verified: in
transparentmode against your provider's keys, as described below; in the other modes against FrontMCP's own secret, with its issuer and audience. - In
transparentmode, its audience, thenrequiredScopes, are checked. - The token must name its caller, in
sub, elseclient_idorazp. A token with none of them:401.
The caller your code sees is then built from the token's claims. None of this depends on a session: MCP 2026-07-28 has none, and the sessions of older clients are checked again on every request.
Verifying JWTs from your identity provider
transparent mode accepts tokens your identity provider signed for this server, and never issues any.
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
auth: {
mode: "transparent",
provider: "https://auth.example.com",
expectedAudience: "https://desk.example.com",
providerConfig: { jwksUri: "https://auth.example.com/.well-known/jwks.json" },
},
})
export default class Server {}Options
| Option | Type | Default | What it does |
|---|---|---|---|
provider | string, a URL | Required | Your provider's issuer. A token's iss must be exactly this, with or without a trailing slash. FrontMCP also looks for keys here, and redirects clients here for metadata. |
expectedAudience | string | string[] | The server's own address, from the request | A token's aud (a string or a list) must contain one of these. Set it: on FrontMCP's Node server the default is built from the request's Host and http://, see Caveats. |
requireAudience | boolean | false | true refuses tokens that have no aud. By default they're accepted. |
requiredScopes | string[] | [] | Scopes every token must have, in its scope or scp claim, or the request gets 403. Doesn't apply to anonymous callers. |
allowAnonymous, anonymousScopes, publicAccess | Letting requests without a token in: see Anonymous access. | ||
providerConfig.jwks | { keys: JWK[] } | none | The provider's public keys, written into your config. FrontMCP then never fetches keys. |
providerConfig.jwksUri | string, a URL | none | Where to fetch the provider's keys. |
providerConfig.additionalIssuers | string[] | none | More iss values to accept, like a gateway that signs tokens under its own issuer with the same keys. Only list issuers you control. |
providerConfig.verifyIssuer | boolean | true | false accepts a token whatever its iss, as long as the provider's keys signed it. Prefer additionalIssuers. |
providerConfig.id, providerConfig.name | string | Derived from provider | Names the provider in logs and in FrontMCP's key cache. |
clientId, clientSecret, scopes, and providerConfig.dcrEnabled, authEndpoint, tokenEndpoint, registrationEndpoint, userInfoEndpoint | Accepted, and not read when checking a token. They're for remote mode, which shares them. |
Where the keys come from
FrontMCP looks for the provider's public keys (a JWKS) in this order, and uses the first that has any:
providerConfig.jwks.providerConfig.jwksUri.<provider>/.well-known/jwks.json. (New in 1.9.2.)- The
jwks_urilisted in<provider>/.well-known/oauth-authorization-server. - The
jwks_urilisted in<provider>/.well-known/openid-configuration. (New in 1.8.4.)
A step that finds no keys, because the URL fails or the document has none, moves on to the next, so a jwksUri that's down falls back to discovery.
Fetched keys are kept for 6 hours per server. When a refetch fails, the keys already fetched are used for up to 24 hours, and after that every token is refused. A token whose kid isn't among the kept keys makes FrontMCP fetch them again before it refuses the token, so a key your provider starts using works on its first token. That refetch happens at most once a minute per provider: tokens that come while one is under way wait for it, and an unknown kid within the minute after it is refused without a request. Keys written in providerConfig.jwks are never fetched. (Changed in 1.9.3: before, an unknown kid was refused until the kept keys were 6 hours old.) Each fetch has 5 seconds, and must use HTTPS (plain HTTP only for localhost, or outside production). FrontMCP's Node build fetches only from hosts that resolve to public addresses, localhost excepted outside production: a private address, or a name that doesn't resolve, is refused without a request, in development too. These are FrontMCP's built-in limits, with no options to change them. The Playground can't wait hours, so the 6 hours, the 24 hours and the minute were checked in Node with the clock moved forward. If no keys can be found, every token is refused with no_provider_verified.
What's checked
| Claim | Check |
|---|---|
| Signature | Made by one of the provider's keys, picked by the token's kid. The algorithm must be one of RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512 or EdDSA: HS256 and none are refused, so a shared secret can't stand in for the provider's key. |
iss | provider, with or without a trailing slash, or one of additionalIssuers. Skipped with verifyIssuer: false. |
exp | Present, and in the future. There's no leeway: a token is refused the second it expires. A token without exp is refused, with its own description, missing required "exp" claim. |
nbf | In the past, again with no leeway. |
iat | Not checked. |
aud | Contains one of expectedAudience. A token with no aud is accepted unless requireAudience is true. |
scope, scp | Together contain every one of requiredScopes. A space-separated string and a list both count. |
The leeway and iat rows were checked in Node, with tokens signed seconds before the request; the rest are in the tests below. A failed signature, issuer, expiry or key lookup all give the same description, no_provider_verified (kid=<the token's kid>), so the client can't tell which. The exception is a token that the provider's key signed and that has no exp: it's told missing required "exp" claim, even when its issuer, audience or nbf is wrong too (checked in Node, with tokens signed for the purpose). A forged token without exp still gets no_provider_verified. Troubleshooting lists how to find out.
Scopes
The caller's scopes are the token's scope claim, and its scp claim, which some providers use instead: each either a space-separated string or a list. They're in this.auth.scopes and this.context.authInfo.scopes, this.auth.hasScope() checks them, and requiredScopes reads the same list. A token with both claims has the scopes of both. Reading scopes from scp shows it.
Changed in 1.9.2: before, scp was never read, and a scope list satisfied requiredScopes but left this.auth.scopes empty.
Tokens FrontMCP issues
In local and remote mode, FrontMCP is the authorization server, and signs its own access tokens with HS256 and the JWT_SECRET environment variable. It checks them with the same secret, and checks their claims:
| Claim | Check |
|---|---|
exp | Present, and in the future. |
nbf | In the past, when the token has one. |
iss | The server's issuer: local.issuer, else FRONTMCP_PUBLIC_URL, else the address the request came to (with FRONTMCP_PUBLIC_HOST set, http://<host>:<http.port>, where http.port defaults to the PORT environment variable, or 3000). The same on FrontMCP's Node server and under createFetchHandler(). See Local auth. |
aud | The server's address as the request shows it: FRONTMCP_PUBLIC_URL, or the URL the request was sent to under createFetchHandler(), or http:// and the Host header on the Node server. Every token FrontMCP issues names the address it was issued at. With expectedAudience, the token must name one of those addresses instead, wherever the request comes in. |
So a token works only on the server that issued it, even when servers share JWT_SECRET, and only at the address it was issued for. A failed check gets 401 with its reason, like unexpected "iss" claim value or Token audience does not match this resource. Tokens issued before FrontMCP 1.8.3 have no aud, so they're refused after an upgrade: clients refresh them or sign in again. Remote and proxied auth shows the checks in a Playground. The public RSA key at /.well-known/jwks.json isn't the one these tokens are signed with.
JWT_SECRETshould be at least 32 bytes. In production, a missing or shorter one stops the server. Outside production, FrontMCP makes up a random secret per process, so tokens stop working when the server restarts, and one instance's tokens don't work on another. See secrets.- Changing
JWT_SECRETinvalidates every token signed with the old one. - How tokens are issued, how long they last and how clients refresh them is in Local auth.
public mode checks bearer JWTs the same way, so it refuses any JWT it didn't sign: see Every request to a public server gets 401.
Static keys
static mode compares the credential with a list of keys you choose.
| Option | Type | Default | What it does |
|---|---|---|---|
tokens | string[] | Required, at least one | The accepted keys. Compared in constant time. |
header | string | "authorization" | The request header that carries the key, like "x-api-key". |
scheme | string | "Bearer" | The word before the key, matched without regard to case, followed by one space. "" for a header that holds the bare key. |
scopes | string[] | ["static"] | The scopes every accepted request gets. |
realm | string | "mcp" | The realm in the WWW-Authenticate challenge. |
publicAccess | Accepted, and changes nothing here: every caller of a static server has a key, so none is anonymous. See publicAccess. |
- Each key is its own caller:
static:and the first 12 hex characters of the key's SHA-256. The id is the same on every instance and after a restart, and says nothing about the key. - The key itself never reaches your code:
this.context.authInfo.tokenis"". - There's no way to tell people who share a key apart, or to revoke one of them. To rotate a key, deploy with the old and the new key in
tokens, move clients to the new one, then remove the old one. - With
scheme: "", a refused request gets noWWW-Authenticateheader, since there's no scheme to name in it. (Before 1.9.2, it got an empty one.)
What your code gets from a token
Once a token passes, its claims become the caller:
| Where | From the token |
|---|---|
this.auth.user.sub | sub. A token without one, like a client-credentials token, takes its client_id claim, then azp. A token with none of the three is refused with 401, so a token that passes always names its caller. (Changed in 1.9.3: before, such a token was let in with user.sub "", and this.auth.isAnonymous was true.) |
this.auth.user.name, email, picture | The claims of the same name. |
this.auth.scopes | scope and scp, split on spaces when they're strings. |
this.auth.roles, permissions | roles and permissions, or the claims claimsMapping points to. |
this.auth.claims, this.context.authInfo.user | Every claim. |
this.context.authInfo.token | The token itself, which this.fetch() can forward to your own services. Don't put it in results or logs. |
this.context.authInfo.clientId | sub again. It's not the OAuth client's id. |
this.context.authInfo.expiresAt | exp, in milliseconds. undefined for a caller without a token. |
this.auth and this.context.authInfo describe the rest.
Sessions
MCP 2026-07-28: no sessions
A client on MCP 2026-07-28 sends its token with every request, and each request is checked on its own. Nothing is kept between requests: this.context.sessionId and this.auth.sessionId are the same new anon: id every time, whoever is calling, and FrontMCP encrypts no session id for them. There's nothing to expire but the token itself. FrontMCP doesn't count that id as a session, so what it keeps per session is kept per signed-in caller for these clients, like this.secureStore with scope: "session". (Changed in 1.8.4: before, the id counted as a session, one that lasted one request. Changed in 1.8.5: anonymous and static-key callers no longer get an encrypted session made for them, so they need no MCP_SESSION_SECRET, and this.auth.sessionId is no longer "".)
Older clients
Clients on protocol versions before 2026-07-28 start with initialize, and FrontMCP's Node server answers with an Mcp-Session-Id that the client sends back on every later request. These rules were checked in Node against FrontMcpInstance.bootstrap(), with protocol 2025-11-25, and the ones about Redis against a Valkey server, which speaks Redis's protocol; the Playground's handler keeps no sessions.
| What happens | |
|---|---|
| The session id | A random id, the machine's id, the token's signature (a JWT's third part) and the time, encrypted with AES-256-GCM, as three base64url parts: <iv>.<tag>.<data>. It holds neither the token nor who the user is. |
| Credentials | Still sent, and still checked, on every request. A request with a valid session id and no token, or an expired one, gets 401. |
Another valid token, transparent mode | 404, {"code":-32000,"message":"invalid session id"}. The session belongs to the token it started with, so a client that gets a new token must initialize again. (Changed in 1.8.6: the message was session not initialized.) |
Another valid key, static mode | 404, invalid session id, the same: the session belongs to the key it started with. (Changed in 1.8.6: the session carried on, and the caller became the new key's.) |
public mode | The anonymous caller keeps one anon: id for the whole session. |
An id that doesn't verify: changed, made up, or minted under another MCP_SESSION_SECRET | 404, invalid session id, whatever the server's storage. After the secret is rotated, a client with an old id gets this once, then sends initialize. (Changed in 1.8.6: session not initialized, and a server with Redis served an id minted under another secret.) |
An id that verifies, for a session this instance doesn't have: another instance minted it, or the server restarted, with the same MCP_SESSION_SECRET | 404, session not initialized. With transport.persistence on Redis, any instance with the same secret serves it. Outside production, without the secret, the key comes from the machine's id, which a restart changes unless you set MACHINE_ID: an id from before the restart gets invalid session id. |
No id, on anything but initialize | -32600 Session not initialized — send `initialize` first, with HTTP status 200. |
DELETE with the id | 204. The id then gets 404 Session not found. |
The encryption key is the SHA-256 of MCP_SESSION_SECRET. During these sessions this.auth.scopes has the token's scopes, as this.context.authInfo.scopes does. (Before 1.8.7 this.auth.scopes was [] for these clients, and only this.context.authInfo.scopes had them.)
Secrets
| Variable | What it protects | Without it |
|---|---|---|
MCP_SESSION_SECRET | The session ids of clients before MCP 2026-07-28. | In production, their initialize gets 500 SESSION_SECRET_REQUIRED, whoever is calling; clients on MCP 2026-07-28 are served. See Auth in production. Elsewhere, a key derived from the machine's id. |
JWT_SECRET | The tokens FrontMCP signs. | In production with local or remote, the server doesn't start (JwtSecretRequiredError), or on an edge isolate every request gets 500 JWT_SECRET_REQUIRED, health checks included. Elsewhere, a random secret per process. |
VAULT_SECRET | Credentials FrontMCP stores for users, and the state of a request that asks the user. | Falls back to JWT_SECRET. |
Give every instance the same values. Environment variables lists them all.
Expiry and refresh
FrontMCP never refreshes a caller's token. When it expires, the next request gets 401 with error="invalid_token", and it's the client's job to get a new one:
transparent: from your identity provider, with its refresh token. The next request with the new token goes through. An older client on a session must also start a new session, because the session belongs to the old token.localandremote: from FrontMCP's/oauth/token, withgrant_type=refresh_token. See Local auth.
A tool that needs to know when the caller's token runs out reads this.context.authInfo.expiresAt, in milliseconds.
Errors a client sees
| Request | Status | WWW-Authenticate after resource_metadata="…" |
|---|---|---|
No token, or an Authorization header that isn't Bearer | 401 | Nothing |
| A token that isn't a JWT | 401 | error="invalid_token", error_description="Token is not a valid JWT" |
Expired, not valid yet, wrong issuer, bad signature, unknown kid, HS256, or keys not found | 401 | error="invalid_token", error_description="no_provider_verified (kid=desk-2)" (without (kid=…) when the token has none) |
No exp, with a valid signature | 401 | error="invalid_token", error_description="missing required \"exp\" claim" |
None of sub, client_id and azp, with a valid signature | 401 | error="invalid_token", error_description="Token has no subject: the sub, client_id and azp claims are all missing" |
| Issued for another audience | 401 | error="invalid_token", error_description="Token audience does not match expected audiences. Got: https://billing.example.com. Expected one of: https://desk.example.com" |
No aud, with requireAudience | 401 | error="invalid_token", error_description="Token is missing audience claim" |
Missing a scope in requiredScopes | 403 | error="insufficient_scope", error_description="The request requires higher privileges", scope="tickets:write" |
The body is {"error":"Unauthorized"} or {"error":"Forbidden"}. In public, local and remote mode, a JWT that fails gets 401 with the description from the check, like "alg" (Algorithm) Header Parameter value not allowed, missing required "exp" claim, unexpected "iss" claim value or Token audience does not match this resource: see Tokens FrontMCP issues. In static mode the challenge has no resource_metadata: Bearer realm="mcp", plus error="invalid_token", error_description="The access token is invalid" for a wrong key.
Caveats
- Set
expectedAudience. Without it, the expected audience is the server's own address as the request shows it: on FrontMCP's Node server,http://and theHostheader; undercreateFetchHandler(), the URL the request was sent to. Behind a proxy that terminates TLS, a token forhttps://desk.example.comis refused because the server expectshttp://desk.example.com, and the audience then depends on a header the client sends.FRONTMCP_PUBLIC_URLfixes the address; see The server's public address. - A token with no
audis accepted by default, so a token your provider issued for another service, without an audience, works here too. SetrequireAudience: trueif your provider always setsaud. - Only JWTs. Opaque access tokens, which some providers issue, are refused with
Token is not a valid JWT. - An unknown
kidfetches the keys again at most once a minute. Tokens with made-upkids can't make FrontMCP hammer your provider, but a token signed with a new key that comes less than a minute after another unknownkidis refused, though the provider already publishes the key. A minute later it's accepted. See Rotating signing keys.
Usage
Accepting tokens from your identity provider
The server accepts tokens from https://auth.example.com issued for https://desk.example.com. To run offline, the provider's public key is written into the config as providerConfig.jwks; usually FrontMCP fetches it. The Playground's client has no token, so the server also lets anonymous callers in; the tests call as nour, with a token signed ahead of time.
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() {
const { user, isAnonymous, scopes, roles, claims } = this.auth;
const { clientId, expiresAt, token } = this.context.authInfo;
return { user, isAnonymous, scopes, roles, tenant: claims.tenant ?? null, clientId, expiresAt: expiresAt ?? null, hasToken: Boolean(token) };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
clientId is the token's sub, not the OAuth client the user signed in with, and expiresAt is in milliseconds, though exp is in seconds. A token issued to a client rather than a user, as with the client-credentials grant, often has no sub: FrontMCP then takes the caller's id from client_id, or else azp, and this.auth.claims.sub holds the same. A valid token with none of them is refused with 401, so check that your provider puts one in every token. (Changed in 1.9.3: before, its caller looked anonymous, with user.sub "".) An anonymous caller of a transparent server has the scope anonymous unless you set anonymousScopes.
Seeing why a token is refused
Each test sends a token that fails one check. The tokens were signed ahead of time; the comments say how each one differs from nour's.
import { test, expect } from "@frontmcp/testing";
import { callAs, why } from "./call-as";
import { config } from "./main";
import * as token from "./keys";
test("not a JWT", async () => {
const { status, challenge } = await callAs("Bearer 9c1f4e7a.b2");
expect(status).toBe(401);
expect(why(challenge)).toBe('error="invalid_token", error_description="Token is not a valid JWT"');
});
test("expired, not valid yet, another issuer, another key: all the same reason", async () => {
for (const t of [token.EXPIRED, token.NOT_YET, token.OTHER_ISSUER, token.FORGED, token.HS256]) {
const { status, challenge } = await callAs(`Bearer ${t}`);
expect(status).toBe(401);
expect(why(challenge)).toBe('error="invalid_token", error_description="no_provider_verified (kid=desk-2)"');
}
});
test("no expiry: a reason of its own", async () => {
const { status, challenge } = await callAs(`Bearer ${token.NO_EXPIRY}`);
expect(status).toBe(401);
expect(why(challenge)).toBe('error="invalid_token", error_description="missing required \\"exp\\" claim"');
});
test("a key the provider doesn't have, or no signature at all", async () => {
expect(why((await callAs(`Bearer ${token.UNKNOWN_KID}`)).challenge)).toBe('error="invalid_token", error_description="no_provider_verified (kid=rogue)"');
expect(why((await callAs(`Bearer ${token.NONE}`)).challenge)).toBe('error="invalid_token", error_description="no_provider_verified"');
});
test("issued for another audience", async () => {
const { status, challenge } = await callAs(`Bearer ${token.OTHER_AUDIENCE}`);
expect(status).toBe(401);
expect(why(challenge)).toBe(
'error="invalid_token", error_description="Token audience does not match expected audiences. Got: https://billing.example.com. Expected one of: https://desk.example.com"',
);
});
test("no audience: accepted, unless requireAudience", async () => {
expect((await callAs(`Bearer ${token.NO_AUDIENCE}`)).status).toBe(200);
const strict = { ...config, auth: { ...config.auth, requireAudience: true } };
expect(why((await callAs(`Bearer ${token.NO_AUDIENCE}`, strict)).challenge)).toBe('error="invalid_token", error_description="Token is missing audience claim"');
});
test("missing a required scope: 403", async () => {
const strict = { ...config, auth: { ...config.auth, requiredScopes: ["tickets:write"] } };
const refused = await callAs(`Bearer ${token.SAM}`, strict);
expect(refused).toMatchObject({ status: 403, body: { error: "Forbidden" } });
expect(why(refused.challenge)).toBe('error="insufficient_scope", error_description="The request requires higher privileges", scope="tickets:write"');
});
test("a bad token isn't let in as anonymous", async () => {
// this server has allowAnonymous: true
expect((await callAs(`Bearer ${token.EXPIRED}`)).status).toBe(401);
expect((await callAs("Basic ZGVzazprZXk=")).caller.isAnonymous).toBe(true);
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Signature, issuer, expiry and key failures all say no_provider_verified, which tells a client its token is bad without telling it why. A missing exp on a correctly signed token is the one failure with a reason of its own. To find out, decode the token (its middle part is base64url JSON) and compare iss, exp, nbf and the header's kid and alg with your config: Troubleshooting has the list.
Accepting more than one audience or issuer
expectedAudience takes a list, for a server reached at more than one address or a provider that sets a different audience per client. additionalIssuers accepts a second iss, signed with the same keys:
import { App, FrontMcp } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";
import { JWKS } from "./keys";
@App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] })
export class HelpDesk {}
export const config = {
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDesk],
auth: {
mode: "transparent" as const,
provider: "https://auth.example.com",
expectedAudience: ["https://desk.example.com", "https://billing.example.com"],
allowAnonymous: true,
providerConfig: { jwks: JWKS, additionalIssuers: ["https://login.example.org"] },
},
};
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
verifyIssuer: false would accept the second issuer too, and any other: every token the provider's keys signed, whoever it names as the issuer. List the issuers you expect instead.
Rotating signing keys
Identity providers change their signing keys from time to time, publishing the new key before they start using it. With keys written into providerConfig.jwks, list both while tokens signed with either are in use:
import { App, FrontMcp } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";
import { CURRENT_KEY, NEXT_KEY } from "./keys";
@App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] })
export class HelpDesk {}
export const config = {
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDesk],
auth: {
mode: "transparent" as const,
provider: "https://auth.example.com",
expectedAudience: "https://desk.example.com",
allowAnonymous: true,
providerConfig: { jwks: { keys: [CURRENT_KEY, NEXT_KEY] } },
},
};
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
With jwksUri, /.well-known/jwks.json or discovery, FrontMCP fetches the keys itself and keeps them for 6 hours. A token that names a kid it doesn't have makes it fetch them again, so a key your provider starts using is accepted on its first token, as a test in Fetching the keys from your provider shows. (Changed in 1.9.3: before, such tokens were refused until the kept keys were 6 hours old.) Keys written into providerConfig.jwks are never fetched, so with them, deploy the new key before your provider uses it.
Fetching the keys from your provider
Here the provider runs on the same machine, at http://localhost:8080/realms/desk. The Playground can't reach a provider, so idp.example.ts stands in for it: it replaces fetch during a test, answers the URLs a provider would, and records what FrontMCP asked for.
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { BEFORE_ROTATION, PROVIDER_JWKS, inTurn, provider } from "./idp.example";
import { NOUR, ROGUE, SAM } from "./keys";
const realm = "http://localhost:8080/realms/desk";
const auth = { mode: "transparent" as const, provider: realm, expectedAudience: "https://desk.example.com" };
test("jwksUri: fetched once, then kept", async () => {
const idp = provider({ "/realms/desk/protocol/openid-connect/certs": PROVIDER_JWKS });
try {
const server = { ...auth, providerConfig: { jwksUri: `${realm}/protocol/openid-connect/certs` } };
const [first, second] = await callAs([`Bearer ${SAM}`, `Bearer ${NOUR}`], server);
expect(first.caller).toEqual({ sub: "sam" });
expect(second.caller).toEqual({ sub: "nour" });
expect(idp.requested).toEqual([`${realm}/protocol/openid-connect/certs`]);
} finally {
idp.stop();
}
});
test("without jwksUri: /.well-known/jwks.json first", async () => {
const idp = provider({ "/realms/desk/.well-known/jwks.json": PROVIDER_JWKS });
try {
const [response] = await callAs([`Bearer ${SAM}`], auth);
expect(response.caller).toEqual({ sub: "sam" });
expect(idp.requested).toEqual([`${realm}/.well-known/jwks.json`]);
} finally {
idp.stop();
}
});
test("then the jwks_uri in the authorization server metadata", async () => {
const idp = provider({
"/realms/desk/.well-known/oauth-authorization-server": { issuer: realm, jwks_uri: `${realm}/protocol/openid-connect/certs` },
"/realms/desk/protocol/openid-connect/certs": PROVIDER_JWKS,
});
try {
const [response] = await callAs([`Bearer ${SAM}`], auth);
expect(response.caller).toEqual({ sub: "sam" });
expect(idp.requested).toEqual([`${realm}/.well-known/jwks.json`, `${realm}/.well-known/oauth-authorization-server`, `${realm}/protocol/openid-connect/certs`]);
} finally {
idp.stop();
}
});
test("then the jwks_uri in the OpenID configuration", async () => {
const idp = provider({
"/realms/desk/.well-known/openid-configuration": { issuer: realm, jwks_uri: `${realm}/protocol/openid-connect/certs` },
"/realms/desk/protocol/openid-connect/certs": PROVIDER_JWKS,
});
try {
const [response] = await callAs([`Bearer ${SAM}`], auth);
expect(response.caller).toEqual({ sub: "sam" });
expect(idp.requested).toEqual([
`${realm}/.well-known/jwks.json`,
`${realm}/.well-known/oauth-authorization-server`,
`${realm}/.well-known/openid-configuration`,
`${realm}/protocol/openid-connect/certs`,
]);
} finally {
idp.stop();
}
});
test("keys found nowhere: every token is refused", async () => {
const idp = provider({});
try {
const [response] = await callAs([`Bearer ${SAM}`], auth);
expect(response.status).toBe(401);
const everywhere = [`${realm}/.well-known/jwks.json`, `${realm}/.well-known/oauth-authorization-server`, `${realm}/.well-known/openid-configuration`];
// and again, since no keys have the token's kid
expect(idp.requested).toEqual([...everywhere, ...everywhere]);
} finally {
idp.stop();
}
});
test("a key the provider starts using is fetched on its first token", async () => {
const idp = provider({ "/realms/desk/.well-known/jwks.json": inTurn(BEFORE_ROTATION, PROVIDER_JWKS) });
try {
const [sam, nour, rogue] = await callAs([`Bearer ${SAM}`, `Bearer ${NOUR}`, `Bearer ${ROGUE}`], auth);
expect(sam.caller).toEqual({ sub: "sam" });
expect(nour.caller).toEqual({ sub: "nour" }); // desk-3 wasn't there for sam's token
expect(rogue.status).toBe(401);
// fetched for sam, then for nour's kid; rogue's comes within the minute, so no request
expect(idp.requested).toEqual([`${realm}/.well-known/jwks.json`, `${realm}/.well-known/jwks.json`]);
} finally {
idp.stop();
}
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Each place that has no keys costs a request, on the first token after a start and every 6 hours after that: a provider found by its OpenID configuration takes four. Setting jwksUri makes it one. A token with a kid the kept keys don't have costs those requests again, at most once a minute. Plain HTTP is allowed here because the provider is on localhost; in production, FrontMCP fetches keys over HTTPS only, and never from a private address.
Static keys in a header of your own
Some clients send an API key in a header like X-API-Key, without Bearer. Set header, and scheme: "" for a bare key:
import { test, expect } from "@frontmcp/testing";
import { send } from "./send";
const auth = { mode: "static", tokens: ["desk-key-1", "desk-key-2"], header: "x-api-key", scheme: "", scopes: ["tickets:read"] };
async function sha256Hex(text: string) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
test("each key is its own caller, named after the key's SHA-256", async () => {
const one = await send(auth, { "x-api-key": "desk-key-1" });
const two = await send(auth, { "x-api-key": "desk-key-2" });
expect(one.caller).toEqual({ sub: `static:${(await sha256Hex("desk-key-1")).slice(0, 12)}`, scopes: ["tickets:read"], token: "" });
expect(two.caller.sub).not.toBe(one.caller.sub);
});
test("the key in Authorization doesn't count", async () => {
expect((await send(auth, { authorization: "Bearer desk-key-1" })).status).toBe(401);
});
test("with scheme \"\", a refusal has no challenge", async () => {
const refused = await send(auth, { "x-api-key": "nope" });
expect(refused).toMatchObject({ status: 401, challenge: null, body: { error: "Unauthorized" } });
});
test("with the default scheme, the challenge names the realm", async () => {
const refused = await send({ mode: "static", tokens: ["desk-key-1"], realm: "help-desk" }, { authorization: "Bearer nope" });
expect(refused.challenge).toBe('Bearer realm="help-desk", error="invalid_token", error_description="The access token is invalid"');
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The key is never passed to your code (token is ""), and the caller's id is derived from it, so logs can say which key was used without containing it. With scheme: "", a refused client gets 401 with no WWW-Authenticate header: a challenge would have to name a scheme.
Reading scopes from scp
Some providers, like Okta and Microsoft Entra ID, put scopes in scp rather than scope, as a string or a list. FrontMCP reads both claims, so this.auth.scopes, hasScope() and requiredScopes work the same either way. To check them as permissions too, as authorities rules do, point claimsMapping.permissions at scp:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a support ticket. Support agents only.", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
if (!this.auth.hasScope("tickets:write")) {
this.fail(new PublicMcpError("Closing a ticket needs tickets:write. Ask the user to sign in as an agent.", "FORBIDDEN"));
}
return { id, status: "closed", scopes: this.auth.scopes, permissions: this.auth.permissions };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
claimsMapping also maps roles and the user id: see Options that change this.auth. The same mapping is what authorities rules read, so a rule and a check in the tool agree.
Sessions for older clients
A client on protocol 2025-11-25 starts a session, and sends the session id and its token on every request. This was run in Node against FrontMcpInstance.bootstrap(), in transparent mode; the Playground's handler keeps no sessions.
POST / Authorization: Bearer <nour's token>
{"method":"initialize", …}
← 200 Mcp-Session-Id: inaZOZfe5t-3uHn3.AGnhSTw0ubUgnU5wV3iVMw.1EPyolk-DLGt…
POST / Mcp-Session-Id: … Authorization: Bearer <nour's token>
{"method":"tools/call","params":{"name":"whoami"}}
← 200 {"sub":"nour","scopes":["tickets:read","tickets:write"], …}
POST / Mcp-Session-Id: … (no Authorization)
← 401 WWW-Authenticate: Bearer resource_metadata="…"
POST / Mcp-Session-Id: … Authorization: Bearer <sam's token>
← 404 {"jsonrpc":"2.0","error":{"code":-32000,"message":"invalid session id"}}
DELETE / Mcp-Session-Id: … Authorization: Bearer <nour's token>
← 204
The session doesn't replace the token: a request without one is refused, and a different token, even a valid one for the same user, can't use the session. A client that refreshes its token gets 404 and starts a new session with initialize, which MCP clients do when a session is gone.
Troubleshooting
no_provider_verified (kid=…)
The token failed one of the checks that share this description. Decode it (the part between the first two dots is base64url JSON; the part before is the header) and check, in order:
expis in the past, ornbfin the future, by the server's clock. (A missingexpgetsmissing required "exp" claiminstead.) There's no leeway, so a clock a few seconds off matters.issisn'tprovider(a trailing slash doesn't matter). A provider may use a different issuer than its URL, such as a tenant-specific one: use the exactissfrom a token asprovider, or add it toadditionalIssuers.- The header's
kidisn't in the keys FrontMCP has. With inlinejwks, add the key. Fetched keys are fetched again for an unknownkid, at most once a minute: if your provider publishes the key, the token works a minute later at most; if it doesn't, the token wasn't signed by your provider. - FrontMCP found no keys. Without
jwksorjwksUri, it only asks<provider>/.well-known/jwks.json,<provider>/.well-known/oauth-authorization-serverand<provider>/.well-known/openid-configuration: see Where the keys come from. The server's debug logs show each failed fetch. - The header's
algisHS256ornone.transparentmode only accepts tokens signed with the provider's public keys.
Token audience does not match expected audiences
The full description continues Got: <the token's aud>. Expected one of: <what the server expects>.
The token was issued for a different audience than the server expects. If Expected one of is the server's own address starting with http://, expectedAudience isn't set, and FrontMCP built it from the request: set expectedAudience to the audience your provider puts in tokens for this server. If it is set, the token was issued for another service, or the client asked for the wrong audience (with Auth0, the audience parameter of the sign-in).
Token is missing audience claim
requireAudience is true, and the token has no aud. Configure your provider to issue tokens for this server's audience, or remove requireAudience.
Token has no subject: the sub, client_id and azp claims are all missing
The token is valid, but names no caller: it has no sub, and no client_id or azp either. FrontMCP refuses it rather than let it in as nobody. Configure your provider to put sub in the token, or, for a token issued to a client rather than a user, client_id. claimsMapping.userId doesn't help: it renames the caller of a token that has one of the three. (Changed in 1.9.3: before, the token was let in with user.sub "", as an anonymous caller.)
Token is not a valid JWT
The token isn't three base64url parts separated by dots. Some providers issue opaque access tokens unless the client asks for a specific audience or API; transparent mode can only check JWTs. A static key sent to a transparent server gets this too.
403 with error="insufficient_scope"
The token is valid but lacks one of requiredScopes, which the challenge lists in scope="…". If the token does have the scopes, check where: FrontMCP reads scope and scp, and no other claim, and compares each scope exactly.
this.auth.scopes is empty though the token has scopes
- The scopes are in a claim other than
scopeandscp. PointclaimsMapping.permissionsat it, and checkhasPermission()instead. - The server runs FrontMCP before 1.8.7, and the client is on a session (a protocol before 2026-07-28):
this.auth.scopeswas empty there, andthis.context.authInfo.scopeshad the scopes. Upgrade, or readthis.context.authInfo.scopes.
"alg" (Algorithm) Header Parameter value not allowed
The server is in public, local or remote mode, and the request carries a JWT that FrontMCP didn't issue, such as one from your identity provider. Those modes only accept their own tokens. To accept your provider's tokens, use transparent mode.
invalid session id after the client gets a new token
The client is on a session, which belongs to the token it started with. Any other token, even a refreshed one for the same user, gets 404 and -32000 invalid session id. So does an id minted under another MCP_SESSION_SECRET. The client has to send initialize again; MCP clients do when the server answers 404. (Before 1.8.6 the message was session not initialized.) After the server restarts with the same MCP_SESSION_SECRET, the old id still verifies, but sessions are kept in memory, so the answer is 404 session not initialized.
SESSION_SECRET_REQUIRED or JWT_SECRET_REQUIRED
The server runs in production without MCP_SESSION_SECRET and a client before MCP 2026-07-28 started a session, or without JWT_SECRET in local or remote mode. See Production secrets.