# Tokens and sessions

> How FrontMCP checks the credential on every request (JWTs from your identity provider, tokens it issued itself, static keys), what your code gets from a token, the sessions older clients keep, what happens when a token expires, and every error a client can get.

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

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.

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

1. **`static` mode** compares the credential with its `tokens`, and lets the request in or refuses it. Nothing below applies.
2. **`public` mode** lets the request in as an anonymous caller, unless it carries a bearer JWT. That JWT is checked as one [FrontMCP issued](#tokens-frontmcp-issues).
3. **`transparent` mode with `allowAnonymous`** lets a request without a bearer token in as an anonymous caller.
4. **No `Authorization` header**, or no bearer token in it: `401`.
5. **A token that isn't a JWT**, three base64url parts separated by dots: `401`, `Token is not a valid JWT`.
6. **The JWT is verified**: in `transparent` mode against your provider's keys, as described below; in the other modes against FrontMCP's own secret, with [its issuer and audience](#tokens-frontmcp-issues).
7. **In `transparent` mode, its audience, then `requiredScopes`, are checked.**
8. **The token must name its caller**, in `sub`, else `client_id` or `azp`. 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](#sessions).

### Verifying JWTs from your identity provider

`transparent` mode accepts tokens your identity provider signed for this server, and never issues any.

```ts main.ts
@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 {}
```

[See more examples below.](#usage)

#### 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](https://frontmcp.dev/reference/auth/modes#what-the-server-advertises). |
| `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](#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](https://frontmcp.dev/reference/auth/modes#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](https://frontmcp.dev/reference/auth/remote), 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:

1. `providerConfig.jwks`.
2. `providerConfig.jwksUri`.
3. `<provider>/.well-known/jwks.json`. (New in 1.9.2.)
4. The `jwks_uri` listed in `<provider>/.well-known/oauth-authorization-server`.
5. The `jwks_uri` listed 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`.

> **Note**
Most providers publish their keys at one of these places, so FrontMCP finds them without configuration. Setting `providerConfig.jwksUri` to your provider's JWKS URL saves the requests that come before it, and is needed when the provider publishes its keys somewhere else and lists them in neither discovery document. For example:

| Provider | `jwksUri` |
| --- | --- |
| Auth0 | `https://<tenant>/.well-known/jwks.json` |
| Okta | `https://<org>/oauth2/default/v1/keys` |
| Keycloak | `https://<host>/realms/<realm>/protocol/openid-connect/certs` |
| Microsoft Entra ID | `https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys` |

[Fetching the keys from your provider](#fetching-the-keys-from-your-provider) shows which URLs FrontMCP asks for.

#### 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](#no_provider_verified-kid) lists how to find out.

> **Pitfall: Keep the server's clock right**
With no leeway, a server whose clock is a few seconds behind your provider's refuses tokens whose `nbf` is the moment they were issued, and one a few seconds ahead refuses tokens just before they expire. This was checked in Node, with tokens signed seconds before the request. Keep the clock synchronized, as with NTP.

#### 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()`](https://frontmcp.dev/reference/sdk/auth) checks them, and `requiredScopes` reads the same list. A token with both claims has the scopes of both. [Reading scopes from `scp`](#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](https://frontmcp.dev/reference/auth/local#tokens). |
| `aud` | The server's address as the request shows it: [`FRONTMCP_PUBLIC_URL`](https://frontmcp.dev/reference/server/config-files), 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](https://frontmcp.dev/reference/auth/remote#choosing-between-transparent-and-remote) 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_SECRET` should be at least 32 bytes. In production, a missing or shorter one [stops the server](https://frontmcp.dev/reference/sdk/create-fetch-handler#production-secrets). 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](https://frontmcp.dev/reference/server/config-files#secrets).
- Changing `JWT_SECRET` invalidates every token signed with the old one.
- How tokens are issued, how long they last and how clients refresh them is in [Local auth](https://frontmcp.dev/reference/auth/local).

`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`](https://frontmcp.dev/reference/auth/modes#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`](https://frontmcp.dev/reference/auth/modes#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.token` is `""`.
- 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 no `WWW-Authenticate` header, 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`](https://frontmcp.dev/reference/sdk/auth#options-that-change-thisauth) points to. |
| `this.auth.claims`, `this.context.authInfo.user` | Every claim. |
| `this.context.authInfo.token` | The token itself, which [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch#forwarding-the-callers-token-to-your-own-service) 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`](https://frontmcp.dev/reference/sdk/auth) and [`this.context.authInfo`](https://frontmcp.dev/reference/sdk/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"`](https://frontmcp.dev/reference/auth/local#thissecurestore). (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`](https://frontmcp.dev/reference/sdk/auth) 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`](https://frontmcp.dev/reference/sdk/create-fetch-handler#production-secrets), whoever is calling; clients on MCP 2026-07-28 are served. See [Auth in production](https://frontmcp.dev/reference/auth/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](https://frontmcp.dev/learn/asking-the-user). | Falls back to `JWT_SECRET`. |

Give every instance the same values. [Environment variables](https://frontmcp.dev/reference/server/config-files#secrets) 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.
- **`local` and `remote`**: from FrontMCP's `/oauth/token`, with `grant_type=refresh_token`. See [Local auth](https://frontmcp.dev/reference/auth/local).

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](#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 the `Host` header; under `createFetchHandler()`, the URL the request was sent to. Behind a proxy that terminates TLS, a token for `https://desk.example.com` is refused because the server expects `http://desk.example.com`, and the audience then depends on a header the client sends. `FRONTMCP_PUBLIC_URL` fixes the address; see [The server's public address](https://frontmcp.dev/reference/auth/modes#the-servers-public-address).
- **A token with no `aud` is accepted by default**, so a token your provider issued for another service, without an audience, works here too. Set `requireAudience: true` if your provider always sets `aud`.
- **Only JWTs.** Opaque access tokens, which some providers issue, are refused with `Token is not a valid JWT`.
- **An unknown `kid` fetches the keys again at most once a minute.** Tokens with made-up `kid`s can't make FrontMCP hammer your provider, but a token signed with a new key that comes less than a minute after another unknown `kid` is refused, though the provider already publishes the key. A minute later it's accepted. See [Rotating signing keys](#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.

```ts whoami.tool.ts active
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) };
  }
}
```

```ts main.ts
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",
    allowAnonymous: true,
    providerConfig: { jwks: JWKS },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts keys.ts
// The provider's public key (kid "desk-2"), so the example runs offline.
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }] };

// nour: scope "tickets:read tickets:write", roles ["agent"], valid until 2100
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyb2xlcyI6WyJhZ2VudCJdfQ.qXeGjz4x1p8iXrWs-ehsvOk_wlOK1s5gyZivhBcLcwbPkcuykeEv9VdocYXzpDuB76qWEcBHnkG81OkimP3uzQ";
// like sam's, but with no sub claim
export const NO_SUB = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQifQ.yMsDsV-11lLyVgnfIzkK_HbKaLB_rDJul45OUymarf_t6mqMK9941Tm7gap6OY_ET5kKeQ1HEk-imndUYSg27A";
```

```ts sign.ts hidden
// Signs more tokens with a key made when the test runs, so no private key is written down.
export async function testIssuer(kid = "test-1") {
  const { publicKey, privateKey } = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
  const { kty, crv, x, y } = await crypto.subtle.exportKey("jwk", publicKey);
  const b64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  const encode = (value: unknown) => b64(new TextEncoder().encode(JSON.stringify(value)));
  async function sign(claims: Record<string, unknown>) {
    const input = `${encode({ alg: "ES256", typ: "JWT", kid })}.${encode(claims)}`;
    const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, new TextEncoder().encode(input));
    return `${input}.${b64(new Uint8Array(signature))}`;
  }
  return { jwk: { kty, crv, x, y, kid, alg: "ES256", use: "sig" }, sign };
}
```

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

/** Sends one tools/call over HTTP, with this Authorization header. */
export async function callAs(authorization: string | undefined, server: Parameters<typeof FrontMcpInstance.createFetchHandler>[0] = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": "whoami",
        ...(authorization ? { authorization } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  const body = await response.json();
  return { status: response.status, challenge: response.headers.get("www-authenticate"), body, caller: body.result?.structuredContent };
}
```

```ts tokens.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { config } from "./main";
import { JWKS, NO_SUB, NOUR } from "./keys";
import { testIssuer } from "./sign";

test("the caller comes from the token's claims", async () => {
  const { caller } = await callAs(`Bearer ${NOUR}`);
  expect(caller).toEqual({
    user: { sub: "nour", name: "Nour Haddad", email: "nour@example.com" },
    isAnonymous: false,
    scopes: ["tickets:read", "tickets:write"],
    roles: ["agent"],
    tenant: null,
    clientId: "nour",
    expiresAt: 4102444800000, // exp, in milliseconds
    hasToken: true,
  });
  expect(new Date(caller.expiresAt).toISOString()).toBe("2100-01-01T00:00:00.000Z");
});

test("Bearer is matched without regard to case", async () => {
  expect((await callAs(`bearer ${NOUR}`)).caller.user.sub).toBe("nour");
});

test("without allowAnonymous, no token means 401", async () => {
  const strict = { ...config, auth: { ...config.auth, allowAnonymous: false } };
  const refused = await callAs(undefined, strict);
  expect(refused).toMatchObject({ status: 401, body: { error: "Unauthorized" } });
  expect(refused.challenge).toMatch(/^Bearer resource_metadata="[^"]*\/\.well-known\/oauth-protected-resource"$/);
});

test("a token without sub names its client: client_id, then azp", async () => {
  const { jwk, sign } = await testIssuer();
  const server = { ...config, auth: { ...config.auth, providerConfig: { jwks: { keys: [...JWKS.keys, jwk] } } } };
  const claims = { iss: "https://auth.example.com", aud: "https://desk.example.com", exp: 4102444800, scope: "tickets:read" };
  const bot = (await callAs(`Bearer ${await sign({ ...claims, client_id: "desk-bot" })}`, server)).caller;
  expect(bot).toMatchObject({ user: { sub: "desk-bot" }, isAnonymous: false, clientId: "desk-bot" });
  const app = (await callAs(`Bearer ${await sign({ ...claims, azp: "desk-console" })}`, server)).caller;
  expect(app).toMatchObject({ user: { sub: "desk-console" }, isAnonymous: false });
});

test("a valid token with none of sub, client_id and azp is refused", async () => {
  const refused = await callAs(`Bearer ${NO_SUB}`);
  expect(refused.status).toBe(401);
  expect(refused.challenge).toContain('error="invalid_token", error_description="Token has no subject: the sub, client_id and azp claims are all missing"');
});

test("an anonymous caller has no token", async ({ mcp }) => {
  const me = (await mcp.tools.call("whoami", {})).json();
  expect(me).toMatchObject({ isAnonymous: true, scopes: ["anonymous"], expiresAt: null, hasToken: false });
});
```

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

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

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

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

```ts main.ts
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",
    allowAnonymous: true,
    providerConfig: { jwks: JWKS },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts keys.ts
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }] };

// Signed with the key above (kid "desk-2") unless the comment says otherwise.
// Each is nour's token (iss https://auth.example.com, aud https://desk.example.com), except:
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.q6QO61dYLhbeoVC1iwpVvcz22D468jtd9iH741yQt6DAB420nCHB2bETwns1TuZ6Z5tZ3A0f7t0oyzhvTW2TkA"; //                  sam, scope "tickets:read"
export const EXPIRED = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3NjcyMjIwMDAsImV4cCI6MTc2NzIyNTYwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.ddxE2nUgXGmLbPTzSAAApH5Z9DmjBSM7LtwSt6t9cM_hhYj8wjlcRDDytd02BU3HGzZz2hHp5c4mV2QvOY4qpw"; //          exp 1 January 2026
export const NOT_YET = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIiwibmJmIjo0MDAwMDAwMDAwfQ.bZ4l0fNoNW1zXP3sDmlhMxX5E83NizqnE0x0nTlkSm3YaMEixBHk5k1FIg6jClPU21IaWwXSZM-aVmBqnE4G4w"; //          nbf 2096
export const OTHER_ISSUER = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUub3JnIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInN1YiI6Im5vdXIiLCJzY29wZSI6InRpY2tldHM6cmVhZCB0aWNrZXRzOndyaXRlIn0.sZ9PPg1-iiL_RbKL-wxb_jRHbHC0EYJsXFyJBPgPya-eRGYjgLfbUbBHz5dQfMoG0FPEsZzx2BUg7q7wL16pVw"; // iss https://login.example.org
export const FORGED = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.6bov-aN3qW9hhnEQkglbC42UBWRH_e83i4ugxYhWFM1Av4k37_9WkOLaoCD97nFQ3NXKDOi5VqWbNBmVAE-JKw"; //            kid "desk-2", signed with another key
export const HS256 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.-TMYi9tpLmHvffDT1vzFF6FmvYneHcoAKAisjRbVEy0"; //              alg HS256, signed with a shared secret
export const UNKNOWN_KID = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvZ3VlIn0.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.zNwADWgEIlIp7j1pOrO0LnpRQbUkkVngPBdNCwowabkOUAUUm7GwQgbji9PrKDrMvvqFb_GMsslYbG3qnEv4DQ"; //     kid "rogue", signed with another key
export const NONE = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ."; //                alg none, no signature
export const OTHER_AUDIENCE = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2JpbGxpbmcuZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.HTIqHuNdNf-PxIGYg9SH1SxvqCJ5RRADpOyfj7ISNLoDWrzcv95HUf2GiwgYJtThmNRQhjy822MpUKZu_aNcHw"; // aud https://billing.example.com
export const NO_AUDIENCE = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIn0.5j2qKY3C6FeHxc_CnM3FWLuvZNVx-GBRTqqIALlcdt4CdVPutvgqidGaiCnqssyI6W-nxfh2jMdUOI-O_BS98g"; //  no aud
export const NO_EXPIRY = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsInN1YiI6Im5vdXIiLCJzY29wZSI6InRpY2tldHM6cmVhZCJ9.p3kPpQtzVyzMOLPgLPZHA5O7MXGYrGc6-ecFTsoRzhiEZEop3HIr9JS2xsh61ezWgMZbogUd8Uh9yIK32bXIPg"; //      no exp
```

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

/** Sends one tools/call over HTTP, with this Authorization header. */
export async function callAs(authorization: string | undefined, server: Parameters<typeof FrontMcpInstance.createFetchHandler>[0] = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": "whoami",
        ...(authorization ? { authorization } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  const body = await response.json();
  return { status: response.status, challenge: response.headers.get("www-authenticate"), body, caller: body.result?.structuredContent };
}

/** The challenge without resource_metadata, the server's own URL. */
export function why(challenge: string | null) {
  return challenge?.replace(/^Bearer resource_metadata="[^"]*"(, )?/, "") ?? null;
}
```

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](#no_provider_verified-kid) 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:

```ts main.ts active
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 {}
```

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

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

```ts keys.ts hidden
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }] };
// nour, aud https://billing.example.com
export const OTHER_AUDIENCE = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2JpbGxpbmcuZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.HTIqHuNdNf-PxIGYg9SH1SxvqCJ5RRADpOyfj7ISNLoDWrzcv95HUf2GiwgYJtThmNRQhjy822MpUKZu_aNcHw";
// nour, iss https://login.example.org
export const OTHER_ISSUER = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUub3JnIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInN1YiI6Im5vdXIiLCJzY29wZSI6InRpY2tldHM6cmVhZCB0aWNrZXRzOndyaXRlIn0.sZ9PPg1-iiL_RbKL-wxb_jRHbHC0EYJsXFyJBPgPya-eRGYjgLfbUbBHz5dQfMoG0FPEsZzx2BUg7q7wL16pVw";
// sam, aud ["https://billing.example.com", "https://desk.example.com"]
export const AUD_LIST = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOlsiaHR0cHM6Ly9iaWxsaW5nLmV4YW1wbGUuY29tIiwiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIl0sImlhdCI6MTc5MDAwMDAwMCwiZXhwIjo0MTAyNDQ0ODAwLCJzdWIiOiJzYW0iLCJzY29wZSI6InRpY2tldHM6cmVhZCJ9.KXoLINKlOQwX3b7gy4wz-XdEchURkMyAh5rzHNZAv7XKEsGGgPXrpqnde_kJUEgnC5a8PNS8r6GobRMs28EQaA";
// sam, iss "https://auth.example.com/" (with a trailing slash)
export const TRAILING_SLASH = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20vIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInN1YiI6InNhbSIsInNjb3BlIjoidGlja2V0czpyZWFkIn0.-oZkS8ywMvLbcqcHQjmtEmZQ5BatBIghnOAuLNgrirKOv3rFA0DyP_SQ1jkPW6u1sP72wOfxyN6pLC5KM8Jq2w";
```

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

/** Sends one tools/call over HTTP, with this Authorization header. */
export async function callAs(authorization: string | undefined, server: Parameters<typeof FrontMcpInstance.createFetchHandler>[0] = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": "whoami",
        ...(authorization ? { authorization } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  const body = await response.json();
  return { status: response.status, challenge: response.headers.get("www-authenticate"), body, caller: body.result?.structuredContent };
}
```

```ts audience.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { config } from "./main";
import { AUD_LIST, OTHER_AUDIENCE, OTHER_ISSUER, TRAILING_SLASH } from "./keys";

test("any audience in the list is accepted", async () => {
  expect((await callAs(`Bearer ${OTHER_AUDIENCE}`)).caller).toMatchObject({ sub: "nour", aud: "https://billing.example.com" });
});

test("a token with a list of audiences needs one of them to match", async () => {
  expect((await callAs(`Bearer ${AUD_LIST}`)).caller).toMatchObject({ sub: "sam" });
});

test("an additional issuer is accepted", async () => {
  expect((await callAs(`Bearer ${OTHER_ISSUER}`)).caller).toMatchObject({ sub: "nour", iss: "https://login.example.org" });
});

test("a trailing slash on iss doesn't matter", async () => {
  expect((await callAs(`Bearer ${TRAILING_SLASH}`)).caller).toMatchObject({ sub: "sam", iss: "https://auth.example.com/" });
});

test("without additionalIssuers, the other issuer is refused", async () => {
  const strict = { ...config, auth: { ...config.auth, providerConfig: { jwks: config.auth.providerConfig.jwks } } };
  expect((await callAs(`Bearer ${OTHER_ISSUER}`, strict)).status).toBe(401);
});
```

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

```ts main.ts active
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 {}
```

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

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

```ts keys.ts
export const CURRENT_KEY = { kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" };
export const NEXT_KEY = { kty: "EC", crv: "P-256", kid: "desk-3", alg: "ES256", use: "sig", x: "HUC34mnzh3DwySBtaFsYYjbSS6ezZIfddV__VBcCvLM", y: "ES_tS-Ox8AUDORB35cCZUTYb1VRsC0BJxCDPGiGmLNY" };

// sam, signed with the current key (kid "desk-2")
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.q6QO61dYLhbeoVC1iwpVvcz22D468jtd9iH741yQt6DAB420nCHB2bETwns1TuZ6Z5tZ3A0f7t0oyzhvTW2TkA";
// nour, signed with the next key (kid "desk-3")
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJuYW1lIjoiTm91ciBIYWRkYWQifQ.sqNK8q9gUMCz_DtU7tJgO04YCvIpgkxNJoK0cIdHUl-zvx2_TfSKOmA6EjOgz5ECbfSTzwgLXsfRz6shPp_gaw";
```

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

/** Sends one tools/call over HTTP, with this Authorization header. */
export async function callAs(authorization: string | undefined, server: Parameters<typeof FrontMcpInstance.createFetchHandler>[0] = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": "whoami",
        ...(authorization ? { authorization } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  const body = await response.json();
  return { status: response.status, challenge: response.headers.get("www-authenticate"), body, caller: body.result?.structuredContent };
}
```

```ts rotation.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { config } from "./main";
import { NEXT_KEY, NOUR, SAM } from "./keys";

test("tokens signed with either key are accepted", async () => {
  expect((await callAs(`Bearer ${SAM}`)).caller).toEqual({ sub: "sam" });
  expect((await callAs(`Bearer ${NOUR}`)).caller).toEqual({ sub: "nour" });
});

test("once the old key is removed, its tokens are refused", async () => {
  const nextOnly = { ...config, auth: { ...config.auth, providerConfig: { jwks: { keys: [NEXT_KEY] } } } };
  expect((await callAs(`Bearer ${SAM}`, nextOnly)).status).toBe(401);
  expect((await callAs(`Bearer ${NOUR}`, nextOnly)).status).toBe(200);
});
```

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

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

```ts idp.example.ts
// Stands in for your identity provider, which the Playground can't reach.
import { JWKS } from "./keys";

export const PROVIDER_JWKS = JWKS;
// Before the provider added its new key, desk-3
export const BEFORE_ROTATION = { keys: [JWKS.keys[0]] };

/** A document that is each of these in turn, then the last one. */
export function inTurn(...versions: unknown[]) {
  return () => (versions.length > 1 ? versions.shift() : versions[0]);
}

/** Answers GETs for these paths with JSON, and 404 for anything else, until stop(). */
export function provider(documents: Record<string, unknown>) {
  const realFetch = globalThis.fetch;
  const requested: string[] = [];
  globalThis.fetch = (async (input: RequestInfo | URL) => {
    const url = new URL(input instanceof Request ? input.url : String(input));
    requested.push(url.href);
    const entry = documents[url.pathname];
    const document = typeof entry === "function" ? entry() : entry;
    return document ? Response.json(document) : new Response("Not found", { status: 404 });
  }) as typeof fetch;
  return { requested, stop: () => void (globalThis.fetch = realFetch) };
}
```

```ts call-as.ts
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";

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

/** Sends one tools/call per Authorization header, in order, to one server with this auth. */
export async function callAs(authorizations: string[], auth: object) {
  const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth });
  const results = [];
  for (const authorization of authorizations) {
    const response = await handler(
      new Request("https://desk.example.com/", {
        method: "POST",
        headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "whoami", authorization },
        body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
      }),
    );
    const body = await response.json();
    results.push({ status: response.status, caller: body.result?.structuredContent });
  }
  return results;
}
```

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

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

```ts keys.ts hidden
// The realm's two public keys, kid "desk-2" and "desk-3".
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }, { kty: "EC", crv: "P-256", kid: "desk-3", alg: "ES256", use: "sig", x: "HUC34mnzh3DwySBtaFsYYjbSS6ezZIfddV__VBcCvLM", y: "ES_tS-Ox8AUDORB35cCZUTYb1VRsC0BJxCDPGiGmLNY" }] };
// sam (kid "desk-2") and nour (kid "desk-3"), iss http://localhost:8080/realms/desk
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL2Rlc2siLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.BZdJV4dRFIlsvrcDFXUOkzp7O4MUHMpTD-7ZcckJDCNMOGIII2dtcHGnqbDwfGeFeG16tsjLXtL-2QJivHaCFQ";
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL2Rlc2siLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.JLZW8EfOeeHVlU_cJvLjbJAHOFRyC9i-EF1k7p068AqiJJlE6sNZK0EAczJqh1P8th_uif08AxtCy8MTJekX2w";
// kid "rogue", a key the realm doesn't have
export const ROGUE = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJvZ3VlIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL2Rlc2siLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.BZdJV4dRFIlsvrcDFXUOkzp7O4MUHMpTD-7ZcckJDCNMOGIII2dtcHGnqbDwfGeFeG16tsjLXtL-2QJivHaCFQ";
```

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:

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

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

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

```ts send.ts
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";

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

/** Calls whoami over HTTP, on a server with this auth, sending these headers. */
export async function send(auth: object, headers: Record<string, string>) {
  const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], auth });
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "whoami", ...headers },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "whoami", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  const body = await response.json();
  return { status: response.status, challenge: response.headers.get("www-authenticate"), body, caller: body.result?.structuredContent };
}
```

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](https://frontmcp.dev/reference/auth/authorities) rules do, point `claimsMapping.permissions` at `scp`:

```ts close-ticket.tool.ts active
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 };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";
import { JWKS } from "./keys";

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] })
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: JWKS },
  },
  authorities: { claimsMapping: { permissions: "scp" }, profiles: {} },
};

@FrontMcp(config)
export default class Server {}
```

```ts keys.ts hidden
export const JWKS = { keys: [{ kty: "EC", crv: "P-256", kid: "desk-2", alg: "ES256", use: "sig", x: "KymdjcQ-mm_ofNr69J2iGdw3tsIOuJnJvDndWXi2Zjo", y: "OmYXPyMjP0lIs6X0MGESbC2mXQw-UhzO37JFs6qdbAg" }] };
// sam, scp "tickets:read tickets:write" (a string)
export const SCP_STRING = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwic2NwIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUifQ.aHWS1wnQ9TQHFTXOCsHC5l4zdiXGqwH0aFrZv0dvyjx1kEErYocS5JzMO1G-VILPFOcQ4mT8PEfXSJivRamTVw";
// sam, scp ["tickets:read", "tickets:write"] (a list)
export const SCP_LIST = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoic2FtIiwic2NwIjpbInRpY2tldHM6cmVhZCIsInRpY2tldHM6d3JpdGUiXX0.OQmzjv-KTU8sOqL7UGVyilKI_0Y6RXykv2b2zgHNeMmRAwVqop750LgJGNuewF2dRx4hGw3L3v--5eqeM9M-IA";
```

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

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

```ts scp.test.ts
import { test, expect } from "@frontmcp/testing";
import { callAs } from "./call-as";
import { config } from "./main";
import { SCP_LIST, SCP_STRING } from "./keys";

test("scp gives the caller's scopes, as a string or a list, and the mapping makes them permissions", async () => {
  for (const token of [SCP_STRING, SCP_LIST]) {
    const { result } = await callAs(token);
    expect(result.structuredContent).toEqual({ id: "T-1", status: "closed", scopes: ["tickets:read", "tickets:write"], permissions: ["tickets:read", "tickets:write"] });
  }
});

test("requiredScopes reads scp too", async () => {
  expect((await callAs(SCP_STRING, { ...config, auth: { ...config.auth, requiredScopes: ["tickets:read"] } })).status).toBe(200);
  expect((await callAs(SCP_LIST, { ...config, auth: { ...config.auth, requiredScopes: ["tickets:export"] } })).status).toBe(403);
});
```

`claimsMapping` also maps roles and the user id: see [Options that change `this.auth`](https://frontmcp.dev/reference/sdk/auth#options-that-change-thisauth). The same mapping is what [authorities](https://frontmcp.dev/reference/auth/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.

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

1. **`exp`** is in the past, or **`nbf`** in the future, by the server's clock. (A missing `exp` gets `missing required "exp" claim` instead.) There's no leeway, so a clock a few seconds off matters.
2. **`iss`** isn't `provider` (a trailing slash doesn't matter). A provider may use a different issuer than its URL, such as a tenant-specific one: use the exact `iss` from a token as `provider`, or add it to `additionalIssuers`.
3. **The header's `kid`** isn't in the keys FrontMCP has. With inline `jwks`, add the key. Fetched keys are fetched again for an unknown `kid`, 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.
4. **FrontMCP found no keys.** Without `jwks` or `jwksUri`, it only asks `<provider>/.well-known/jwks.json`, `<provider>/.well-known/oauth-authorization-server` and `<provider>/.well-known/openid-configuration`: see [Where the keys come from](#where-the-keys-come-from). The server's debug logs show each failed fetch.
5. **The header's `alg`** is `HS256` or `none`. `transparent` mode 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`](https://frontmcp.dev/reference/sdk/auth#options-that-change-thisauth) 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 `scope` and `scp`. Point [`claimsMapping.permissions`](https://frontmcp.dev/reference/sdk/auth#options-that-change-thisauth) at it, and check `hasPermission()` instead.
- The server runs FrontMCP before 1.8.7, and the client is on a session (a protocol before 2026-07-28): `this.auth.scopes` was empty there, and `this.context.authInfo.scopes` had the scopes. Upgrade, or read `this.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](https://frontmcp.dev/reference/sdk/create-fetch-handler#production-secrets).
