# Auth in production

> What to set and check before a FrontMCP server authenticates real users, from secrets, HTTPS and allowed hosts to token checks, what production hides, rate limits and several instances, and the gaps to work around.

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

A server that behaves in development can be open, or broken, once it's deployed. This page is the checklist for an authenticated FrontMCP server: the secrets production requires, who can reach the server, how strictly tokens are checked, what `NODE_ENV=production` hides from callers and what it still tells them, rate limits, running several instances, and the places where FrontMCP 1.9.3 leaves a server more open than its settings suggest. Each item says what FrontMCP does, and links to the page that covers it in full.

```bash
NODE_ENV=production
MCP_SESSION_SECRET=…        # every server with clients before MCP 2026-07-28; openssl rand -hex 32
JWT_SECRET=…                # local and remote modes; at least 32 bytes
VAULT_SECRET=…              # optional; the same on every instance
FRONTMCP_PUBLIC_URL=https://desk.example.com
```

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  auth: { mode: "transparent", provider: "https://auth.example.com", expectedAudience: "https://desk.example.com", requireAudience: true },
  authorities: { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } } },
  http: {
    cors: { origin: ["https://app.example.com"] },
    security: { dnsRebindingProtection: { enabled: true, allowedHosts: ["desk.example.com"], allowedOrigins: ["https://app.example.com"] } },
  },
  throttle: { enabled: true, global: { maxRequests: 600, windowMs: 60_000, partitionBy: "ip" } },
})
export default class Server {}
```

---

## Reference

### The checklist

| | Check | Why |
| --- | --- | --- |
| 1 | [Run with `NODE_ENV=production`](#production-mode) | Internal error messages and stacks stop reaching callers, and FrontMCP stops making up secrets. |
| 2 | [Set `MCP_SESSION_SECRET`, and `JWT_SECRET` in `local` and `remote` modes](#secrets) | Without them a production server refuses older clients' sessions with `500`, or doesn't start. |
| 3 | [Give every instance the same secrets](#running-several-instances) | Tokens, sessions and questions to the user must verify on any instance. |
| 4 | [Serve over HTTPS, and set `FRONTMCP_PUBLIC_URL`](#https-and-the-public-url) | FrontMCP serves plain HTTP, and builds its URLs, including the audience it expects, from the request. |
| 5 | [Allow only your hosts and origins](#hosts-origins-and-cors) | Blocks DNS rebinding and requests meant for another site. |
| 6 | [List CORS origins](#hosts-origins-and-cors) | `origin: true` lets every web page read responses, with credentials. |
| 7 | [Check the token's audience strictly](#token-checks) | A token without `aud`, meant for another service, is accepted by default. |
| 8 | [Rate-limit requests](#rate-limits) | Nothing slows down key guessing by default. |
| 9 | [Know what production still tells callers](#what-production-hides-and-what-it-doesnt) | Some messages describe your rules and your runtime, in production too. |
| 10 | [Work around the 1.9.3 gaps](#gaps-to-work-around-in-193) | A few behaviours leave a server more open than you'd expect, with no error. |

### Production mode

FrontMCP is in production when the environment variable `NODE_ENV` is `production`. Then:

- A tool's plain `Error`, an internal error, a resource or prompt that throws, and a guard that throws reach the caller as `Internal FrontMCP error. Please contact support with error ID: err_…`. The log has the message and stack, [next to the same ID](https://frontmcp.dev/reference/server/logging).
- No `_meta.stack` is sent.
- FrontMCP refuses to make up the secrets it would otherwise derive from the machine or generate per process: see [Secrets](#secrets).
- `local` mode turns off dynamic client registration: `POST /oauth/register` answers `404` `{"error":"access_denied","error_description":"Dynamic Client Registration is disabled."}`, and the server's metadata has no `registration_endpoint`. Since unknown clients are refused by default ([`requireRegisteredClients`](https://frontmcp.dev/reference/auth/local#letting-only-known-clients-in)), only the clients in `dcr.clients` and [CIMD](https://frontmcp.dev/reference/auth/cimd) clients can then sign in, unless you set `dcr.enabled`.
- [`/readyz`](https://frontmcp.dev/reference/server/observability#health) leaves out each probe's details.

The Playground always runs in development. What this page says about production was checked in Node with `NODE_ENV=production`, and [Seeing what production sends](#seeing-what-production-sends) formats errors the way production does.

### Secrets

| Variable | Used for | Without it, in production |
| --- | --- | --- |
| `MCP_SESSION_SECRET` | Encrypting the session ids of clients before MCP 2026-07-28. | Their `initialize` gets `500` with the code `SESSION_SECRET_REQUIRED`, whoever they are. Clients on MCP 2026-07-28 have no session and are served, and `/healthz` still answers `200`, so a health check won't notice. Set it on every server that older clients may reach. (Changed in 1.8.5: before, anonymous and static-key callers on 2026-07-28 got the `500` too.) |
| `JWT_SECRET` | Signing the tokens FrontMCP issues in `local` and `remote` modes (HS256). At least 32 bytes. `local.signKey` and `local.jwks` don't replace it: they're accepted and not used yet. | The server doesn't start: `createFetchHandler()` and the Node server reject with `JwtSecretRequiredError`, or `JwtSecretWeakError` for a shorter one. On an edge isolate, every request, `/healthz` included, gets `500` `JWT_SECRET_REQUIRED` or `JWT_SECRET_INVALID`. |
| `VAULT_SECRET` | Signing the `requestState` of a 2026-07-28 call that [asks the user](https://frontmcp.dev/learn/asking-the-user) something. Falls back to `JWT_SECRET`. | No error: FrontMCP signs with a random key per process. That's fine for one instance; see [several instances](#running-several-instances). Since 1.8.6, a production server that sets `redis`, or `transport.persistence` as an object, logs a warning at startup, once: `requestState is signed with a per-process key (neither VAULT_SECRET nor JWT_SECRET is set). Multi-round tools (elicit/sample) restart when a round lands on another instance; set VAULT_SECRET (or JWT_SECRET) to the same value on every instance.` |

FrontMCP's Node server and `createFetchHandler()` both answer with a JSON body that names the code and the fix, `{"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED",…}`, and the Node server logs the code too. (Before 1.8.7 the Node server answered a plain `Internal Server Error`.) [Production secrets](https://frontmcp.dev/reference/sdk/create-fetch-handler#production-secrets) shows the answer, and [Configuration files](https://frontmcp.dev/reference/server/config-files#secrets) lists every secret FrontMCP reads. Generate each with `openssl rand -hex 32`, and keep them in your platform's secret store, not in the image or the repository. Give each server its own `JWT_SECRET` anyway. The tokens FrontMCP issues name their server in `iss` and `aud`, so since 1.8.3 another server that shares the secret refuses them ([Tokens](https://frontmcp.dev/reference/auth/local#tokens)), but whoever holds the secret can sign any token they like for any of those servers.

#### Rotating secrets and keys

| What | Effect of changing it | How to rotate |
| --- | --- | --- |
| `MCP_SESSION_SECRET` | Existing session ids stop decrypting, and a request that carries one gets `404` `invalid session id`. The client starts a new session with `initialize`, as MCP clients do on a `404`. Clients on MCP 2026-07-28 have no session and notice nothing. | Change it and restart. Sessions in memory end with the restart anyway; with `transport.persistence` on Redis, the new secret is what ends them. (Changed in 1.8.6: before, a server with Redis served an id minted under another secret.) |
| `JWT_SECRET` | Every token FrontMCP issued fails with `401` `error_description="signature verification failed"`, so every user signs in again. It takes one key; there's no list of old ones. | Change it at a quiet time, and expect the sign-ins. |
| `VAULT_SECRET` | A tool that's in the middle of asking the user several questions asks the first one again. | Change it and restart. |
| [Static keys](https://frontmcp.dev/learn/authenticating-clients#requiring-a-shared-key-static-mode) | A client with the old key is refused. | Deploy with both keys in `tokens`, move clients over, then remove the old one. |
| Your identity provider's signing key (`transparent`) | FrontMCP caches the provider's keys for 6 hours, and fetches them again when a token names a `kid` they don't have, at most once a minute. A key the provider publishes is accepted on its first token. (Changed in 1.9.3: before, tokens signed with a new key failed until the cache expired.) | Publish the new key before the provider signs with it, as providers do. With keys in `providerConfig.jwks`, deploy the new one first. See [Rotating signing keys](https://frontmcp.dev/reference/auth/tokens#rotating-signing-keys). |

### HTTPS and the public URL

FrontMCP's Node server speaks plain HTTP. Terminate TLS in front of it: a load balancer, a reverse proxy, or your platform's edge. Then tell FrontMCP its public address:

```bash
FRONTMCP_PUBLIC_URL=https://desk.example.com
```

FrontMCP builds the URLs it hands out from the request: the `resource_metadata` link in a `401`, its OAuth metadata and, without `expectedAudience`, the audience it expects, in `transparent` mode and for the tokens it issues itself in `local` and `remote` mode. Behind a proxy that terminates TLS, the request says `http://`, so without this variable FrontMCP sends clients to `http://desk.example.com/.well-known/oauth-protected-resource`, and refuses tokens issued for `https://desk.example.com` with `Token audience does not match expected audiences. Got: https://desk.example.com. Expected one of: http://desk.example.com`. It ignores `X-Forwarded-Proto` unless `FRONTMCP_TRUST_PROXY` is set, and that variable also makes it trust `X-Forwarded-For` for the [client's address](https://frontmcp.dev/reference/sdk/create-fetch-handler#the-clients-address). Set `expectedAudience` as well, so the audience doesn't depend on the URL.

FrontMCP adds `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` to every response and removes `X-Powered-By`. It sends no `Strict-Transport-Security` or `Content-Security-Policy` until you ask: set them with [`http.securityHeaders`](https://frontmcp.dev/reference/sdk/frontmcp#http), or the [response-header variables](https://frontmcp.dev/reference/server/config-files#response-headers), or at the proxy. The sign-in and consent pages send their own ([Custom login UI](https://frontmcp.dev/reference/auth/login-ui#security)), and every response of `/oauth/token` and `/oauth/register`, errors included, has `Cache-Control: no-store` and `Pragma: no-cache` (changed in 1.9.3: before, they had neither).

### Hosts, origins and CORS

FrontMCP's Node server listens on `127.0.0.1` unless you set `http.security.bindAddress` or `FRONTMCP_BIND_ADDRESS` ([`http`](https://frontmcp.dev/reference/sdk/frontmcp#http)). Once it listens on a public address, check which hosts and pages requests are meant for:

```ts
http: {
  security: {
    dnsRebindingProtection: { enabled: true, allowedHosts: ["desk.example.com"], allowedOrigins: ["https://app.example.com"] },
  },
}
```

| Request | Answer |
| --- | --- |
| `Host` not in `allowedHosts` | `403` `{"error":"Forbidden","message":"Invalid Host header"}` |
| `X-Forwarded-Host` not in `allowedHosts` | `403` `…"Invalid X-Forwarded-Host header"` |
| `Origin` not in `allowedOrigins` | `403` `…"Invalid Origin header"` |
| No `Origin` header | Allowed. MCP clients that aren't browsers don't send one. |

The lists apply without an `enabled` key; `enabled: false` switches the check off, and the lists with it. `http.security.strict: true` doesn't add a host list of its own under `createFetchHandler()`, so list your hosts. The environment variable `FRONTMCP_ALLOWED_HOSTS` sets `allowedHosts` for the Node server ([Environment variables](https://frontmcp.dev/reference/server/config-files#starting-and-serving)). A browser won't let a script set `Origin`, so the `Origin` check was verified in Node; [Allowing only your hosts](#allowing-only-your-hosts) shows the host checks.

**CORS** decides which web pages may read the server's responses, and is off unless you set `http.cors` ([CORS and host checks](https://frontmcp.dev/reference/sdk/create-fetch-handler#cors-and-host-checks)). List the origins of your web clients. `origin: true` reflects every origin back, and with `credentials: true` it also sends `Access-Control-Allow-Credentials: true`, so any web page can make credentialed requests and read the answers. CORS isn't access control either: anything that isn't a browser ignores it.

### Token checks

In `transparent` mode, FrontMCP checks a token's signature, that its issuer is `provider` (or one of `providerConfig.additionalIssuers`), its expiry, and its audience. [What's checked](https://frontmcp.dev/reference/auth/tokens#whats-checked) has every rule; these are the ones to tighten for production:

| Token | By default | To refuse it |
| --- | --- | --- |
| No `aud` claim | Accepted, even with `expectedAudience` set. A token your provider issued for another service is accepted if it has no audience. | `requireAudience: true`: `401` `error_description="Token is missing audience claim"`. |
| An `aud` that isn't `expectedAudience` | `401`, `Token audience does not match expected audiences. Got: … Expected one of: …` | Already refused. Set `expectedAudience`; without it the expected audience is the request's URL. |
| No `exp` claim | Refused since 1.8.3, with `401` `missing required "exp" claim` (before 1.8.4, the generic `no_provider_verified (kid=…)`). | Already refused. Have your provider always set `exp`. |
| No `sub`, `client_id` or `azp` claim | Refused since 1.9.3, with `401` `Token has no subject: the sub, client_id and azp claims are all missing`. Before, its caller looked anonymous. | Already refused. Have your provider always set `sub`. |
| Missing a scope in `requiredScopes` | `403` `error="insufficient_scope"`. Anonymous callers (`allowAnonymous`) aren't checked. | Already refused. |

Keep `verifyIssuer` at its default. `false` accepts tokens from any issuer whose keys FrontMCP has.

In `local` and `remote` mode, FrontMCP checks the tokens it issued itself: signature, expiry, its own `iss`, and an `aud` that is the server's URL, or one of `expectedAudience`. [Local auth](https://frontmcp.dev/reference/auth/local#tokens) has the details.

### Rate limits

Unless you configure it, FrontMCP limits nothing. [`throttle.global`](https://frontmcp.dev/reference/sdk/guard#global) limits every request the server gets. With `partitionBy: "global"` or `"ip"` it's counted before authentication, so requests with a wrong key count too: that's what slows down someone guessing keys. With `"global"`, one guesser also locks out everyone else, so partition by `"ip"`, behind a proxy with `FRONTMCP_TRUST_PROXY` set so the address is the client's. Under `"userId"`, all anonymous callers share one count.

The discovery documents under `/.well-known/` and the OAuth endpoints, `/oauth/authorize`, `/oauth/token` and the rest, count toward `throttle.global` too, so the same limit slows down someone guessing at the token endpoint. (Changed in 1.9.2: before, they never ran the throttle.) [`publicAccess.rateLimit`](https://frontmcp.dev/reference/auth/modes#publicaccess) limits anonymous tool and prompt calls separately, even with the throttle off.

The counts live in memory unless `throttle.storage` points at Redis, `{ type: "redis", redis: { url } }`, so each instance counts on its own. See [storage](https://frontmcp.dev/reference/sdk/guard#storage-and-keyprefix).

Rate limits fail closed, since they're a security control. When that Redis can't be reached at startup, the server doesn't start: `createFetchHandler()` and the Node server reject with a `GuardStorageUnavailableError` that names `throttle.storage` and the reason. `throttle.storage.fallback: "memory"` starts the server anyway, with each instance counting on its own, and logs `[storage] Warning: Failed to connect to redis, falling back to memory.` (Changed in 1.8.6: the error was `StorageConnectionError: Failed to connect to Redis: Reached the max retries per request limit`, which names neither the setting nor the fix, and the failed client kept logging `[ioredis] Unhandled error event`.) A Redis that stops answering while the server runs gets the same choice: calls that need a check are refused with a `503` `GuardStorageUnavailableError`, or, with `fallback: "memory"`, counted per instance until it's back: see [the guard's page](https://frontmcp.dev/reference/sdk/guard#calls-fail-with-guard_storage_unavailable-while-the-server-runs).

### Running several instances

| Shared state | What to do | If you don't |
| --- | --- | --- |
| `MCP_SESSION_SECRET` | The same value everywhere. | Clients on MCP 2026-07-28 are fine, since they have no session. An older client's session id from one instance doesn't decrypt on another, and gets `404` `invalid session id`. |
| `JWT_SECRET` (`local`, `remote`) | The same value everywhere. | A token issued by one instance fails on another with `signature verification failed`. |
| `VAULT_SECRET`, or `JWT_SECRET` | The same value everywhere. | A tool that asks the user two questions, answered on different instances, asks the first one again. The mismatch is only logged, as `rejected requestState { reason: 'bad-signature' }`, and since 1.8.6 the log also has a `hint` that names `VAULT_SECRET`. |
| Sign-in state in `local` and `remote` modes | `tokenStorage: { redis: … }`. | Pending sign-ins, codes and refresh tokens are in memory: a sign-in that starts on one instance and finishes on another fails with `Authorization request has expired`, and after a restart no client can refresh, so users sign in again when their access token expires. See [Local auth](https://frontmcp.dev/reference/auth/local#storage). |
| Sessions of clients before MCP 2026-07-28 | `transport.persistence` with Redis, or sticky sessions. | A session lives on the instance that created it; elsewhere the client gets `404` `session not initialized` and must start again. |
| Rate-limit counts | `throttle.storage` with Redis. | Each instance allows the full limit. |
| Client metadata documents ([CIMD](https://frontmcp.dev/reference/auth/cimd)) | `cimd.cache: { type: "redis", redis }`. | Each instance fetches and caches each client's document on its own, so a changed document can be in use on one instance and not yet on another. |

Clients on MCP 2026-07-28 keep no sessions, and each of their requests is authenticated on its own, so they need only the secrets. Redis storage needs a real Redis, so it isn't shown on this site; [Tokens and sessions](https://frontmcp.dev/reference/auth/tokens) and [`transport`](https://frontmcp.dev/reference/sdk/frontmcp#transport) list the options. `MACHINE_ID` is only used outside production, in place of `MCP_SESSION_SECRET`, so sharing it between instances changes nothing in production.

Only the rate limits fail closed. A production server whose `redis` option can't be reached still starts, keeps sessions and pending questions in memory, and logs `[storage] Warning: Failed to connect to redis, falling back to memory.` once (before 1.8.6 the failed client also logged `[ioredis] Unhandled error event` over and over), so an instance that started without Redis answers `404` `session not initialized` to a session another instance opened. FrontMCP tries the session store again, after a second and then at doubling intervals up to 30 seconds, and logs `Session store recovered after startup failure` when Redis answers. Until then `/readyz` is `503` `not_ready`, with the `session-store` check `unhealthy`, so a load balancer that uses it keeps traffic away. (Before 1.8.7 a Redis that was down at startup stayed unused until the server restarted.)

### What production hides, and what it doesn't

| Hidden in production | Still sent word for word |
| --- | --- |
| A tool's plain `Error` or `InternalMcpError` (`TOOL_EXECUTION_ERROR`, `SERVER_ERROR`, `INTERNAL_ERROR`) | A `PublicMcpError` and its subclasses, as intended |
| A resource or prompt that throws (`RESOURCE_READ_ERROR`, …) | An [`authorities`](https://frontmcp.dev/reference/auth/authorities#what-a-refused-caller-gets) refusal, which names the roles or claims that would have let the caller in |
| A guard that throws, in a call or in `tools/list` | `ENTRY_UNAVAILABLE`, which lists the server's OS, runtime, deployment, provider and environment |
| `@frontmcp/auth`'s internal errors | A `401`'s `WWW-Authenticate` description, which names the audience the server expects |
| `INVALID_OUTPUT` details | Rate-limit, concurrency and timeout errors |
| Stacks, in `_meta.stack` | `Tool "…" not found`, and other errors about the request |

None of the right-hand column is a secret by itself, but together it describes your rules and your deployment to anyone who can call the server. [Error classes](https://frontmcp.dev/reference/sdk/error-classes) marks what each class keeps.

### Gaps to work around in 1.9.3

Each of these behaves differently from what its settings suggest, with no error:

| Gap | What happens | What to do |
| --- | --- | --- |
| `local` mode's built-in sign-in page | Asks for an email and checks nothing: anyone can sign in as anyone. | Check credentials in your own `authenticate` or sign-in page, or use `remote` or `transparent`. [Local auth](https://frontmcp.dev/reference/auth/local) |
| `this.context.sessionId` and `this.auth.sessionId` | Both are the same new `anon:…` id on every 2026-07-28 request, signed-in callers included. | Identify callers by `this.auth.user.sub`. |
| `local.signKey`, `local.jwks`, and public mode's `signKey` and `jwks` | Accepted and not used: tokens are HS256, signed with `JWT_SECRET`. | Set `JWT_SECRET`, the same on every instance. [Secrets](#secrets) |

FrontMCP 1.8.3 closed the other gaps this table listed. `@Agent({ authorities })`, rules on resource templates, rules that check nothing and the skills HTTP endpoints are enforced, or stop the server ([Authorities](https://frontmcp.dev/reference/auth/authorities#rules-that-check-nothing)). `local` and `remote` mode refuse unknown clients by default, grant only the scopes in `allowedScopes`, tie each sign-in to the browser that started it and each token to its server, and a sign-in declined at the provider ends without a token ([Local auth](https://frontmcp.dev/reference/auth/local), [Remote auth](https://frontmcp.dev/reference/auth/remote#checking-that-the-caller-signed-in)). A `transparent` token without `exp` is refused.

FrontMCP 1.8.4 closed more. An authorities profile name the server doesn't know stops it at startup, and `createFetchHandler()` rejects a misconfigured server when it's called instead of on its first request. Under `createFetchHandler()`, the tokens `local` and `remote` mode issue name the address the request came to, and `FRONTMCP_PUBLIC_URL` sets their `iss` ([Local auth](https://frontmcp.dev/reference/auth/local#tokens)). A request on MCP 2026-07-28 is never in a session, so `this.secureStore` with `scope: "session"` follows the signed-in user instead of an id the caller sends. Check the servers you upgrade for what these changes now refuse.

FrontMCP 1.8.5 closed more. Every entry point, FrontMCP's Node server included, derives the tokens' `iss` the same way, from `local.issuer`, `FRONTMCP_PUBLIC_URL` or the request's address, and the discovery documents and every redirect to the client, errors included, name it. A client on MCP 2026-07-28 gets no session, so a server without `MCP_SESSION_SECRET` serves its anonymous and static-key callers.

FrontMCP 1.8.6 closed more. A client before MCP 2026-07-28 can use a session only with an id this server minted for this caller. An id minted under another `MCP_SESSION_SECRET`, a session that another token or another `static` key opened, and a made-up id all get `404` `invalid session id`, and the client sends `initialize` again. Before, a server with Redis served an id minted under another secret, and a `static` server let a second key carry on the first key's session. Two problems now say what's wrong: a production server that signs `requestState` with a per-process key while it shares state warns at startup, and a `throttle.storage` on a Redis that can't be reached stops the server with an error that names it ([Secrets](#secrets), [Rate limits](#rate-limits)).

FrontMCP 1.8.7 closed more. `this.auth.scopes` and `this.auth.hasScope()` have the token's scopes for clients before MCP 2026-07-28 too, where they were empty and only `this.context.authInfo.scopes` had them, so a tool that checks scopes works for every client. A missing `MCP_SESSION_SECRET` is the structured `server_misconfigured` answer on FrontMCP's Node server, as on every other entry point. Every response carries `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`, and `createFetchHandler()` refuses a body over `http.bodyLimit` with `413` and serves `health`, which it used to ignore.

FrontMCP 1.9.0 closed more. `authorities.pipes` run, so fields they add to `this.auth` are there for a tool to check ([Authorities](https://frontmcp.dev/reference/auth/authorities#adding-fields-to-thisauth)), and an `auth.ui` sign-in page builds in a project that uses ES modules, where it used to fall back to the built-in page ([Custom login UI](https://frontmcp.dev/reference/auth/login-ui#the-built-in-page-shows-instead-of-your-authui-component)).

FrontMCP 1.9.2 closed more. `publicAccess` limits what anonymous callers may use, and how often ([Auth modes](https://frontmcp.dev/reference/auth/modes#publicaccess)), and a public server gives its callers its `anonymousScopes`. An app with its own `auth` on the shared endpoint of a `public` or `static` server stops the server from starting, where the server used to serve the app's tools to anyone it let in ([Auth modes](https://frontmcp.dev/reference/auth/modes#auth-for-one-app)). The OAuth endpoints and discovery documents count toward `throttle.global`, and `throttle.ipFilter.trustProxy` is read. `this.fetch()` sends no empty `Authorization: Bearer ` for an anonymous caller, `env.` paths in authorities rules read the server's environment, `claimsResolver` changes `this.auth` as well as rules, and a token without `sub` names its client. A server that relied on any of the old behaviour may now refuse a call, or not start: check it after the upgrade.

FrontMCP 1.9.3 closed more. A valid token that names no caller, with no `sub`, `client_id` or `azp`, is refused rather than served as an anonymous caller, and `/oauth/token` and `/oauth/register` answer with `Cache-Control: no-store` ([Token checks](#token-checks)). A `transparent` server fetches its provider's keys again for a `kid` it doesn't know, so a rotated key works at once. A `public` or `static` server no longer points clients at a made-up authorization server, and its anonymous tokens last `sessionTtl`, an hour by default, instead of a day ([Auth modes](https://frontmcp.dev/reference/auth/modes#what-the-server-advertises)). `cimd.enabled: false` refuses metadata URLs, and `cimd.cache.type: "redis"` shares the cache ([Client ID metadata](https://frontmcp.dev/reference/auth/cimd)). A remote provider's token is renewed with its refresh token, and survives the client refreshing FrontMCP's ([Remote auth](https://frontmcp.dev/reference/auth/remote#calling-the-providers-api-as-the-user)). Check that your clients get a new anonymous token when theirs expires.

Two things need nothing from you: the caller's token and `x-frontmcp-*` headers are sent nowhere unless you list the origin in `fetch.forwardCallerTokenTo`, and every request has its own `this.context`, so concurrent callers don't see each other's identity.

---

## Usage

### Checking tokens strictly

This server accepts tokens from `auth.example.com` for `https://desk.example.com`. The tests send three tokens for the same user: one issued for another service, one without an audience, and one without an expiry. The Playground's own client has no token, so the server also lets anonymous callers in:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { JWKS } from "./tokens";

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

@App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] })
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 client.ts
import { FrontMcpInstance } from "@frontmcp/sdk";

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

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

```ts tokens.ts
// Tokens from auth.example.com for the user nour, signed ahead of time with the key in JWKS.
// Issued for another service: aud "https://billing.example.com"
export const FOR_BILLING = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2JpbGxpbmcuZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIn0.PRWFcq9kceBZg7WFaoRelZIrZIp1zTD641VNbqUxLDC2-a7fhMaU_2fLBx5ar9PHMPvoqNBPg-6YrXW7qz_2fw";
// No aud claim at all
export const NO_AUDIENCE = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic3ViIjoibm91ciIsInNjb3BlIjoidGlja2V0czpyZWFkIn0.qXWtmmXKB7B3qpFp9mzrbW_R3gYmhuVH59nXBLOXz9kBSi3Jh_M5nNr9_lzIw6d8ZiDTe2tTkWdM9PaG4pm9qg";
// No exp claim
export const NO_EXPIRY = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMyJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsInN1YiI6Im5vdXIiLCJzY29wZSI6InRpY2tldHM6cmVhZCJ9.6CSvg32V_MRXlpn5NqizoTovzp2R1JrgeK0zAGlX9ldvCTAgJoVBCZK2XUUd5-Sud1ifSAINcLo_VXbGa1VdOg";
// The provider's public key. Usually FrontMCP fetches it; here it's inline so the example runs offline.
export const JWKS = { keys: [{"kty":"EC","x":"E5CNMEh_im_-kUoq-qbsGQa7NQBkf3vt8hxAFPH9OGk","y":"HCve2s_TG0bffFc8xEokraDFtq2SWxIE6z2GJfAY52g","crv":"P-256","kid":"desk-3","alg":"ES256","use":"sig"}] };
```

```ts tokens.test.ts
import { test, expect } from "@frontmcp/testing";
import { whoamiWith } from "./client";
import { config } from "./main";
import { FOR_BILLING, NO_AUDIENCE, NO_EXPIRY } from "./tokens";

const strict = { ...config, auth: { ...config.auth, requireAudience: true } };

test("a token for another service is refused, and the answer says why", async () => {
  const response = await whoamiWith(config, FOR_BILLING);
  expect(response.status).toBe(401);
  expect(response.headers.get("www-authenticate")).toContain(
    'error_description="Token audience does not match expected audiences. Got: https://billing.example.com. Expected one of: https://desk.example.com"',
  );
});

test("a token without an audience is accepted by default", async () => {
  expect((await whoamiWith(config, NO_AUDIENCE)).status).toBe(200);
});

test("✅ requireAudience refuses it", async () => {
  const response = await whoamiWith(strict, NO_AUDIENCE);
  expect(response.status).toBe(401);
  expect(response.headers.get("www-authenticate")).toContain('error_description="Token is missing audience claim"');
});

test("a token without exp is refused", async () => {
  const response = await whoamiWith(config, NO_EXPIRY);
  expect(response.status).toBe(401);
  expect(response.headers.get("www-authenticate")).toContain('error_description="missing required \\"exp\\" claim"');
});
```

The refusal is an HTTP `401` with the reason in `WWW-Authenticate`, before any tool runs; in production too, and it tells the caller which audience the server expects.

### Allowing only your hosts

`allowedHosts` checks the host a request was sent to, and the host a proxy says it was sent to:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Check that the server answers.", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: {
    security: {
      dnsRebindingProtection: { allowedHosts: ["desk.example.com", "playground.local"], allowedOrigins: ["https://app.example.com"] },
    },
  },
};

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

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

async function ping(url: string, headers: Record<string, string> = {}, server = config) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  return handler(
    new Request(url, {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "ping", ...headers },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "ping", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
}

test("the server's own host is served", async () => {
  expect((await ping("https://desk.example.com/")).status).toBe(200);
});

test("another host is refused", async () => {
  const response = await ping("https://desk.attacker.example/");
  expect(response.status).toBe(403);
  expect(await response.json()).toEqual({ error: "Forbidden", message: "Invalid Host header" });
});

test("so is another forwarded host", async () => {
  const response = await ping("https://desk.example.com/", { "x-forwarded-host": "desk.attacker.example" });
  expect(await response.json()).toEqual({ error: "Forbidden", message: "Invalid X-Forwarded-Host header" });
});

test("`enabled: false` switches the check off", async () => {
  const off = { ...config, http: { security: { dnsRebindingProtection: { ...config.http.security.dnsRebindingProtection, enabled: false } } } };
  expect((await ping("https://desk.attacker.example/", {}, off)).status).toBe(200);
});
```

`playground.local` is the host the Playground's own client uses. A real server lists only its public host names, with the port if clients use one other than 80 or 443.

### Response headers and body size

Every response carries `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`, with no `X-Powered-By`. `http.securityHeaders` turns one off with `false` or adds the ones FrontMCP leaves to you, and `http.bodyLimit` caps what a request may send:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Check that the server answers.", inputSchema: { note: z.string().optional() } })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  http: {
    bodyLimit: "1kb",
    securityHeaders: {
      hsts: "max-age=63072000; includeSubDomains",
      frameOptions: false as const,
      csp: { enabled: true, directives: { "default-src": ["'none'"] } },
    },
  },
};

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

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

const request = (note = "") =>
  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": "ping" },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "ping", arguments: { note }, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
  });

test("responses carry nosniff and no X-Powered-By by default", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ info: config.info, apps: config.apps });
  const response = await handler(request());
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
  expect(response.headers.get("x-frame-options")).toBe("DENY");
  expect(response.headers.get("x-powered-by")).toBeNull();
  expect(response.headers.get("strict-transport-security")).toBeNull();
});

test("http.securityHeaders adds HSTS and a policy, and turns the frame header off", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(request());
  expect(response.headers.get("strict-transport-security")).toBe("max-age=63072000; includeSubDomains");
  expect(response.headers.get("content-security-policy")).toBe("default-src 'none'");
  expect(response.headers.get("x-frame-options")).toBeNull();
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});

test("a body over http.bodyLimit is refused with 413", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  expect((await handler(request("x".repeat(100)))).status).toBe(200);
  const response = await handler(request("x".repeat(4000)));
  expect(response.status).toBe(413);
  expect(await response.json()).toMatchObject({ error: { code: -32600, message: "Payload Too Large" } });
});
```

A `frontmcp.config` can set the headers per deployment, and `FRONTMCP_HSTS` and its siblings set them from the environment: [Configuration files](https://frontmcp.dev/reference/server/config-files#response-headers) lists them.

### Seeing what production sends

Errors are formatted for production by the same code whatever threw them. The test takes three errors a server really throws, from an in-process server, and formats them with `createErrorHandler({ isDevelopment: false })`, which is what `NODE_ENV=production` means to `tools/call`:

```ts tools.ts active
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "sync_ticket", description: "Copy a ticket to the CRM.", inputSchema: { id: z.string() } })
export class SyncTicket extends ToolContext {
  async execute({ id }: { id: string }): Promise<{ synced: string }> {
    throw new Error(`connect ECONNREFUSED 10.0.4.12:5432 while syncing ${id}`);
  }
}

@Tool({ name: "purge_tickets", description: "Delete closed tickets. Admins only.", inputSchema: {}, authorities: "admin" })
export class PurgeTickets extends ToolContext {
  async execute() {
    return { purged: 12 };
  }
}

@Tool({ name: "deno_report", description: "Build a report. Only on Deno.", inputSchema: {}, availableWhen: { runtime: ["deno"] } })
export class DenoReport extends ToolContext {
  async execute() {
    return { ok: true };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { DenoReport, PurgeTickets, SyncTicket } from "./tools";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  authorities: { claimsMapping: { roles: "roles" }, profiles: { admin: { roles: { any: ["admin"] } } } },
};

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

```ts production.test.ts
import { test, expect } from "@frontmcp/testing";
import { createErrorHandler, FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

const production = createErrorHandler({ isDevelopment: false });

/** What calling this tool in-process throws. */
async function errorFrom(name: string): Promise<unknown> {
  const server = await FrontMcpInstance.createDirect(config);
  try {
    await server.callTool(name, name === "sync_ticket" ? { id: "T-1" } : {});
  } catch (error) {
    return error;
  } finally {
    await server.dispose();
  }
  throw new Error(`${name} didn't fail`);
}

test("a plain Error becomes an error ID", async () => {
  const result = production.handle(await errorFrom("sync_ticket"));
  expect(result.content[0].text).toBe(`Internal FrontMCP error. Please contact support with error ID: ${result._meta?.errorId}`);
  expect(result._meta).not.toHaveProperty("stack");
});

test("an authorities refusal names the roles that would pass", async () => {
  const result = production.handle(await errorFrom("purge_tickets"));
  expect(result._meta?.code).toBe("AUTHORITY_DENIED");
  expect(result.content[0].text).toBe(`Access denied to Tool "help-desk:purge_tickets": profile:admin: roles.any: user has none of 'admin'`);
});

test("ENTRY_UNAVAILABLE describes where the server runs", async () => {
  const result = production.handle(await errorFrom("deno_report"));
  expect(result._meta?.code).toBe("ENTRY_UNAVAILABLE");
  expect(result.content[0].text).toMatch(/\(current: \{"os":"[^"]+","platform":"[^"]+","runtime":"[^"]+","deployment":/);
});
```

The Call tab shows the development answer, with the database's address in it. Fail with a [`PublicMcpError`](https://frontmcp.dev/reference/sdk/fail) for what the model should read. The other two go out as they are. A tool refused by `authorities` isn't in the caller's list, so its refusal mostly reaches someone probing the server by name; where the rule itself is sensitive, check in `execute()` with a message of your own. Where the server's runtime is private, leave a tool out of the server rather than hiding it with [`availableWhen`](https://frontmcp.dev/reference/server/environment).

### Slowing down key guessing

`throttle.global` counts every request before authentication, including the ones refused for a wrong key, and the requests for the OAuth endpoints and discovery documents:

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "ping", description: "Check that the server answers.", inputSchema: {} })
export class Ping extends ToolContext {
  async execute() {
    return { ok: true };
  }
}

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  // The Playground's client has no key, so the key is required only in the tests.
  throttle: { enabled: true, global: { maxRequests: 2, windowMs: 60_000 } },
};

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

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

const locked = { ...config, auth: { mode: "static" as const, tokens: ["desk-key-1"] } };

function ping(key: string) {
  return 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": "ping", authorization: `Bearer ${key}` },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "ping", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
  });
}

test("wrong keys count toward the limit", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(locked);
  expect((await handler(ping("guess-1"))).status).toBe(401);
  expect((await handler(ping("guess-2"))).status).toBe(401);
  const third = await handler(ping("guess-3"));
  expect(third.status).toBe(429);
  expect(third.headers.get("retry-after")).toMatch(/^\d+$/);
});

test("with partitionBy global, the right key is locked out too", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(locked);
  await handler(ping("guess-1"));
  await handler(ping("guess-2"));
  expect((await handler(ping("desk-key-1"))).status).toBe(429);
});

test("the discovery documents count too", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(locked);
  const metadata = () => handler(new Request("https://desk.example.com/.well-known/oauth-protected-resource"));
  expect((await metadata()).status).toBe(200);
  expect((await metadata()).status).toBe(200);
  expect((await metadata()).status).toBe(429);
  expect((await handler(ping("desk-key-1"))).status).toBe(429);
});
```

On a real server, partition by `"ip"`, so one guesser doesn't lock everyone out. A request whose address FrontMCP doesn't know, like every request in the Playground, counts under one shared key, `"ip:unresolved"`, which is why this example uses `"global"`.

---

## Troubleshooting

### `{"error":"server_misconfigured","code":"SESSION_SECRET_REQUIRED",…}`

The server runs with `NODE_ENV=production` and no `MCP_SESSION_SECRET`, and a client before MCP 2026-07-28 sent `initialize`. Set one on every instance, the same everywhere. FrontMCP's Node server answers the same JSON, and logs `SESSION_SECRET_REQUIRED`. If older clients fail while clients on MCP 2026-07-28 work, it's this.

### `JwtSecretRequiredError`, or `JWT_SECRET_REQUIRED` or `JWT_SECRET_INVALID` on every path

A `local` or `remote` server in production needs `JWT_SECRET`, at least 32 bytes. Until then `createFetchHandler()` and the Node server reject, and an edge isolate answers every request, `/healthz` included, with `500`.

### Everyone has to sign in again after a deploy

`JWT_SECRET` changed, or differs between instances: tokens fail with `401` `error_description="signature verification failed"`. Keep it the same across deploys and instances. In development, where no `JWT_SECRET` is set, FrontMCP makes one up per process, so every restart does this. The upgrade to 1.8.3 does it once for `local` and `remote` servers: tokens issued before have no `aud` and fail with `Token audience does not match this resource`, so clients refresh them or sign in again ([Tokens](https://frontmcp.dev/reference/auth/local#tokens)). The upgrade to 1.8.4 does it once for a `local` or `remote` server without `local.issuer` that sets `FRONTMCP_PUBLIC_URL` or runs on `createFetchHandler()`, and the upgrade to 1.8.5 for one on FrontMCP's Node server reached at another address than `localhost:<port>`, or with `FRONTMCP_PUBLIC_HOST` or `expectedAudience` set: its tokens' `iss` changed, so older ones fail with `unexpected "iss" claim value`.

### `Token audience does not match expected audiences. Got: https://… Expected one of: http://…`

The server is behind a proxy that terminates TLS, has no `expectedAudience`, and doesn't know its public URL, so it expects an `http://` audience. Set `expectedAudience`, and `FRONTMCP_PUBLIC_URL` for the URLs it hands out. See [HTTPS and the public URL](#https-and-the-public-url).

### `{"error":"Forbidden","message":"Invalid Host header"}`

The request's `Host` isn't in `dnsRebindingProtection.allowedHosts` (or `FRONTMCP_ALLOWED_HOSTS`). Hosts are compared with their port, apart from 80 and 443, so a server clients reach at `desk.example.com:8443` lists `desk.example.com:8443`. A health check that uses the instance's own address, like `10.0.3.7:3000`, is refused too, `/healthz` included: list that address, or have the check send the public host name. `Invalid X-Forwarded-Host header` and `Invalid Origin header` are the same for the proxy's header and a browser's `Origin`.

### Every caller gets `429 Rate limit exceeded`

`throttle.global` with `partitionBy: "global"` shares one count between every caller, and wrong keys count too. Partition by `"ip"`, and set `FRONTMCP_TRUST_PROXY` behind a proxy so each client has its own address.

### A question to the user is asked again

The tool asks more than one question, and the answers reached instances with different `VAULT_SECRET`s (or `JWT_SECRET`s, or none, so each made up its own). The log has `rejected requestState { reason: 'bad-signature' }`, and, when the key is per-process, a `hint` that names `VAULT_SECRET`. A production server that sets `redis`, or `transport.persistence` as an object, without one of the secrets also warns at startup. Give every instance the same secret.

### `404` with `invalid session id`

A client before MCP 2026-07-28 sent an `Mcp-Session-Id` that this server can't verify for this caller: it was minted under another `MCP_SESSION_SECRET` (the secret was rotated, or the instances differ), the client's token isn't the one the session started with, or it isn't a session id at all. The client has to send `initialize` again, which MCP clients do on a `404`. An id that does verify but that this instance has no session for, because sessions are in memory and another instance minted it, gets `session not initialized` instead. See [Sessions](https://frontmcp.dev/reference/auth/tokens#older-clients).

### `throttle.storage (redis) is unavailable`

The server rejects at startup with a `GuardStorageUnavailableError`, because `throttle.storage` points at a Redis that couldn't be reached. Fix the address, or set `throttle.storage.fallback: "memory"` to start with per-instance counts. See [Rate limits](#rate-limits).

### `App-level auth is not enforced on the shared endpoint`

An app has its own `auth`, on the shared endpoint of a `public` or `static` server, which can't check it, so the server doesn't start. Give the app its own endpoint, or put the `auth` on `@FrontMcp`: see [Protecting one app's tools](https://frontmcp.dev/reference/auth/modes#protecting-one-apps-tools).
