# Security headers and transport

> What FrontMCP does at the HTTP layer: the security headers it sends and how to turn on HSTS and a CSP, bind address and host checks, body size limits, the endpoints the Node server exposes, and the startup audit.

Source: https://frontmcp.dev/reference/deployment/security

FrontMCP guards its transport in a few ways by default: it listens on `127.0.0.1`, refuses requests for host names it doesn't answer to, sends no CORS headers, refuses JSON bodies over 4 MB, and sends `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` on every response, with no `X-Powered-By`. `Strict-Transport-Security` and a `Content-Security-Policy` are off until you set them, in `http.securityHeaders`, in `FRONTMCP_*` environment variables, or in `frontmcp.config`. This page lists what each runtime does and how to set the rest.

```ts
@FrontMcp({
  http: {
    cors?, bodyLimit?, urlencodedLimit?,
    securityHeaders?: { hsts?, contentTypeOptions?, frameOptions?, custom?, csp?: { enabled?, directives?, reportUri?, reportOnly? } },
    security?: { strict?, bindAddress?, dnsRebindingProtection?: { enabled?, allowedHosts?, allowedOrigins? } },
  },
})
```

---

## Reference

### The headers FrontMCP sends

| Response | FrontMCP's Node server (and the Vercel and Lambda handlers) | Fetch handler (Workers, `createFetchHandler()`) |
| --- | --- | --- |
| Every response, `401`, `403`, `404` and `413` included | `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and whatever you add with [`securityHeaders`](#securityheaders). Never `X-Powered-By`. | The same |
| MCP and health answers | `Content-Type`, `Cache-Control: no-cache, no-transform`, `ETag` | `Content-Type`; `Cache-Control: no-store` on health checks |
| `401` | `WWW-Authenticate` | `WWW-Authenticate` |
| Sessions of clients before MCP 2026-07-28 | `Mcp-Session-Id` | |
| A path nothing serves | Express's `404` page, with its own `Content-Security-Policy: default-src 'none'` | JSON `404` |
| The sign-in and consent pages of `local` and `remote` auth | Their own `Content-Security-Policy`: see [Custom login UI](https://frontmcp.dev/reference/auth/login-ui#security) | The same |

No response carries `Strict-Transport-Security`, `Content-Security-Policy` or `Referrer-Policy` until you configure it. MCP answers are JSON or event streams for programs, not pages, so a browser has little to protect there, but a security scanner will flag a missing HSTS, and HSTS matters for the host as a whole. Set it with `securityHeaders`, or at [the proxy](https://frontmcp.dev/reference/deployment/node#putting-nginx-in-front) where TLS ends.

> **Note**
In 1.8.6 no response carried these headers, `X-Powered-By: Express` was on every response, and `FRONTMCP_HSTS`, `FRONTMCP_CSP_ENABLED` and `deployments[].server.headers` did nothing. Now `nosniff` and `X-Frame-Options: DENY` are on by default, `X-Powered-By` is gone, and the settings below work. A proxy that adds `X-Content-Type-Options` too makes the header appear twice: drop it from the proxy.

### `securityHeaders`

`http.securityHeaders` sets the headers in code. Each option has an environment variable, which the server reads when it starts. The order is the option, then the variable, then the default.

| Option | Variable | Default |
| --- | --- | --- |
| `hsts` | `FRONTMCP_HSTS`, like `max-age=31536000; includeSubDomains` | Not sent |
| `contentTypeOptions` | `FRONTMCP_CONTENT_TYPE_OPTIONS` | `nosniff` |
| `frameOptions` | `FRONTMCP_FRAME_OPTIONS` | `DENY` |
| `custom`, headers sent as they are | `FRONTMCP_HEADERS_CUSTOM`, a JSON object | None |
| `csp.enabled` | `FRONTMCP_CSP_ENABLED`, `1` or `true` | Off |
| `csp.directives`, a record of name to value or to an array of values; `''` gives a directive with no value | `FRONTMCP_CSP_DIRECTIVES`, like `default-src 'none'; frame-ancestors 'none'` | None |
| `csp.reportUri` | `FRONTMCP_CSP_REPORT_URI` | None |
| `csp.reportOnly`, sends `Content-Security-Policy-Report-Only` | `FRONTMCP_CSP_REPORT_ONLY`, `1` or `true` | Off |

`false` in code, or `off`, `false` or `none` in a variable, leaves `hsts`, `contentTypeOptions` or `frameOptions` out. With `csp.enabled: true` and no directives, the header is empty and isn't sent.

#### From `frontmcp.config`

`deployments[].server` takes the same settings, as `csp: { enabled, directives, reportUri, reportOnly }` and `headers: { hsts, contentTypeOptions, frameOptions, custom }`. `frontmcp dev` and the `node`, `distributed`, `vercel`, `lambda` and `cloudflare` builds turn them into `FRONTMCP_*` variables, which a variable the platform has already set wins over: the `cloudflare`, `vercel`, `lambda` and `distributed` builds write them into `serverless-setup.js`, and the `node` build into the top of its bundle ([what the bundle reads](https://frontmcp.dev/reference/deployment/node#what-the-bundle-reads-from-frontmcpconfig)). Changed in 1.9: before, the `node` build ignored `deployments[].server`. `deployments[].server.http.cors` becomes `FRONTMCP_CORS_*` the same way, used when `@FrontMcp` sets no `http.cors`; `bodyLimit` and `urlencodedLimit` aren't `frontmcp.config` fields, only `@FrontMcp({ http })` ones.

### `http.security`

| Option | Default | What it does |
| --- | --- | --- |
| `bindAddress` | `"loopback"` | Where the Node server listens: `"loopback"` (`127.0.0.1`), `"all"` (`0.0.0.0`) or an address. First match wins: this option, then `FRONTMCP_BIND_ADDRESS`, then `strict` (`127.0.0.1`, or `0.0.0.0` in distributed mode), then distributed mode (`0.0.0.0`), then `127.0.0.1`. |
| `dnsRebindingProtection.allowedHosts` | Derived from the address | The `Host` (and `X-Forwarded-Host`) values the server answers. On `127.0.0.1` the derived list is `localhost`, `127.0.0.1` and `[::1]`, enforced; on `0.0.0.0` nothing is enforced until you list hosts. `FRONTMCP_ALLOWED_HOSTS` sets it too. See [host checks](https://frontmcp.dev/reference/deployment/node#host-checks). |
| `dnsRebindingProtection.allowedOrigins` | none | When set, a request whose `Origin` isn't listed gets `403` `Invalid Origin header`. A request without `Origin`, which is every client that isn't a browser, is allowed. |
| `dnsRebindingProtection.enabled` | on for the Node server | `false` turns the checks off, a list included. Under `createFetchHandler()` there's no check until you give `allowedHosts`, and then it's enforced: `enabled: true` isn't needed ([CORS and host checks](https://frontmcp.dev/reference/sdk/create-fetch-handler#cors-and-host-checks)). |
| `strict` | `false` | Loopback binding and host checks, which the defaults already give a standalone server. It doesn't change CORS, and `bindAddress: "all"` or `FRONTMCP_BIND_ADDRESS=all` wins over it: a strict server on `0.0.0.0` without `allowedHosts` still answers any host. |

A refused request gets `403` `{"error":"Forbidden","message":"Invalid Host header"}` (or `…X-Forwarded-Host…`, `…Origin…`) before any other check, health checks included.

### Body size limits

| Runtime | Limit |
| --- | --- |
| Node server, Vercel, Lambda | `http.bodyLimit`, `"4mb"` by default, for JSON; `http.urlencodedLimit`, else `bodyLimit`, for form bodies like the OAuth forms. A bigger body gets `413` with `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Payload Too Large","data":{"limit":1024,"length":3008}}}`. |
| Fetch handler | `http.bodyLimit`, `"4mb"` by default, with the same `413` and error. |

The limit is how much one request may make the server buffer. Size it to your largest legitimate tool input; 4 MB is generous for a public server. A platform's own limit, like Cloudflare's, still applies first.

### What the Node server exposes

| Path | What it is |
| --- | --- |
| The MCP endpoint (`http.entryPath`, else `/`) | `POST` for calls, `GET` and `DELETE` for the sessions of older clients. |
| `/sse` and `/message`, under the MCP endpoint's path: `/mcp/sse` when `http.entryPath` is `/mcp` | The HTTP+SSE transport of old clients, on by default (`transport.protocol: "legacy"`). `GET /sse` opens an event stream, and a session, for anyone who asks, and names `/message?sessionId=…` for the client's posts, which answer `404` `invalid session id` for an id the server didn't make. With `"modern"` or `"stateless-api"`, `/sse` is a `404` and the MCP endpoint still works: see [`transport`](https://frontmcp.dev/reference/sdk/frontmcp#transport). |
| `/healthz`, `/health`, `/readyz` | [Health checks](https://frontmcp.dev/reference/deployment/health-and-metrics), public. |
| `/metrics` | With `metrics.enabled`, public unless `auth: "token"`. |
| `/.well-known/*`, `/oauth/*` | With `local` or `remote` auth, public by design. Not rate-limited: see [rate limits](https://frontmcp.dev/reference/auth/production#rate-limits). |
| `http.routes` | Yours, public unless `auth: true`. |

Everything except the MCP endpoint and your authenticated routes answers without credentials, so a proxy in front can restrict paths the public doesn't need, like `/metrics` and `/sse`.

### The startup audit

In production, or in distributed mode, the Node server logs `[Security]` lines before it listens. They report and change nothing:

| Line | Means |
| --- | --- |
| `CORS_DISABLED` | No CORS headers (the default). |
| `CORS_ORIGIN_TRUE` | `cors: { origin: true }`, which lets any web page read responses. |
| `BIND_RESTRICTED` | Listening on `127.0.0.1`. |
| `BIND_ALL_INTERFACES`, `BIND_ALL_INTERFACES_DISTRIBUTED` | Listening on `0.0.0.0`. |
| `DNS_REBINDING_PROTECTED` | Host checks are on and enforced: against the loopback names on `127.0.0.1`, or against your list. |
| `DNS_REBINDING_NOT_ENFORCED` | The server listens on `0.0.0.0` with no `Host` list, so no host is refused. A `[frontmcp] DNS-rebinding protection is not enforcing a Host allow-list` line follows. Set `FRONTMCP_ALLOWED_HOSTS`. |
| `DNS_REBINDING_NOT_APPLICABLE` | The server listens on a Unix socket, which has no host to check; the file's permissions are the boundary. |
| `STRICT_MODE_HINT` | Always. |

Changed in 1.9: before, on `0.0.0.0` with no `Host` list the audit said `DNS_REBINDING_PROTECTED`, though nothing was enforced. Changed in 1.8.7: the audit printed `DNS_REBINDING_UNPROTECTED` on every server, even one whose `Host` checks worked.

#### Caveats

- FrontMCP speaks plain HTTP, so TLS always ends in front of it. Then set `FRONTMCP_PUBLIC_URL`, and `FRONTMCP_TRUST_PROXY` if the proxy sets `X-Forwarded-For`: see [HTTPS and the public URL](https://frontmcp.dev/reference/auth/production#https-and-the-public-url).
- CORS is off unless you set `http.cors`; it controls which web pages can read responses, and isn't access control. See [letting web pages connect](https://frontmcp.dev/reference/sdk/create-fetch-handler#letting-web-pages-connect).
- The Playground examples below run the fetch handler, which is what they can show. What this page says about the Node server was checked by running it with `curl`: with FrontMCP 1.9.2, the default headers, the startup audit and host checks; with 1.9.1, `deployments[].server` through the `node` build; with 1.8.7, every header above on the `401`, `403`, `404` and `413` answers, the variables, `http.securityHeaders` winning over them, and `deployments[].server` through `frontmcp dev` and the `vercel` build (the other builds write the same lines).

---

## Usage

### Turning on HSTS and a CSP

Set `securityHeaders` in `@FrontMcp`. The same option works on a Node server, a Worker and `createFetchHandler()`, and the headers land on every answer, errors included:

```ts headers.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

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

async function answers(config: Record<string, unknown>) {
  const handler = await FrontMcpInstance.createFetchHandler(config as never);
  return [await handler(callTicket()), await handler(new Request("https://desk.example.com/healthz")), await handler(new Request("https://desk.example.com/nope"))];
}

const base = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk] };

test("every response carries nosniff and X-Frame-Options, and none carries HSTS or a CSP", async () => {
  for (const response of await answers(base)) {
    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();
    expect(response.headers.get("content-security-policy")).toBeNull();
  }
});

test("`http.securityHeaders` adds HSTS, a CSP and your own headers to every response", async () => {
  const http = {
    securityHeaders: {
      hsts: "max-age=31536000; includeSubDomains",
      csp: { enabled: true, directives: { "default-src": "'none'", "frame-ancestors": ["'none'"] } },
      custom: { "Referrer-Policy": "no-referrer" },
    },
  };
  const [call, health, notFound] = await answers({ ...base, http });
  expect((await call.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
  for (const response of [call, health, notFound]) {
    expect(response.headers.get("strict-transport-security")).toBe("max-age=31536000; includeSubDomains");
    expect(response.headers.get("content-security-policy")).toBe("default-src 'none'; frame-ancestors 'none'");
    expect(response.headers.get("referrer-policy")).toBe("no-referrer");
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
  }
});

test("`false` leaves a default header out, and `reportOnly` changes the CSP's name", async () => {
  const http = { securityHeaders: { frameOptions: false as const, csp: { enabled: true, directives: { "default-src": "'self'" }, reportOnly: true } } };
  const [call] = await answers({ ...base, http });
  expect(call.headers.get("x-frame-options")).toBeNull();
  expect(call.headers.get("content-security-policy")).toBeNull();
  expect(call.headers.get("content-security-policy-report-only")).toBe("default-src 'self'");
});
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

Set HSTS only on a server that's reached over HTTPS: a browser that sees it refuses plain HTTP for that host until the `max-age` runs out. The 404 page of FrontMCP's Node server keeps its own `Content-Security-Policy: default-src 'none'`, whatever the server's policy says.

### Setting the headers from the environment

The same settings as variables, for a server whose code you'd rather not change per environment:

```bash
FRONTMCP_HSTS='max-age=31536000; includeSubDomains' \
FRONTMCP_CSP_ENABLED=1 FRONTMCP_CSP_DIRECTIVES="default-src 'none'; frame-ancestors 'none'" \
FRONTMCP_FRAME_OPTIONS=SAMEORIGIN \
FRONTMCP_HEADERS_CUSTOM='{"Referrer-Policy":"no-referrer"}' \
node dist/node/help-desk.bundle.js
```

`curl -i http://127.0.0.1:3000/healthz` then shows `Strict-Transport-Security`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy` and the `Content-Security-Policy`. In `frontmcp.config`, the same looks like this, for a target that has a setup file:

```ts frontmcp.config.ts
deployments: [
  {
    target: "vercel",
    server: {
      csp: { enabled: true, directives: { "default-src": "'none'", "frame-ancestors": "'none'" } },
      headers: { hsts: "max-age=31536000; includeSubDomains", custom: { "Referrer-Policy": "no-referrer" } },
    },
  },
],
```

The `vercel` build wrote `if (process.env.FRONTMCP_HSTS === undefined) process.env.FRONTMCP_HSTS = "max-age=31536000; includeSubDomains";` and its siblings into `dist/vercel/serverless-setup.js`, and the function answered with the headers.

### Limiting request size

`http.bodyLimit` applies to the fetch handler too, so a Worker needs no wrapper. A body over the limit gets `413` before any tool runs, and a body under it goes through:

```ts limit.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDesk], http: { bodyLimit: "1kb" } };

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

test("a body over `http.bodyLimit` gets 413, and the tool never runs", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(callTicket("x".repeat(50_000)));
  expect(response.status).toBe(413);
  expect(await response.json()).toEqual({
    jsonrpc: "2.0",
    id: null,
    error: { code: -32600, message: "Payload Too Large", data: { limit: 1024, length: expect.any(Number) } },
  });
});

test("a body under it is served", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(callTicket("T-1"));
  expect(response.status).toBe(200);
  expect((await response.json()).result.structuredContent).toEqual({ id: "T-1", title: "Cannot log in", status: "open" });
});

test("without `bodyLimit` the limit is 4 MB", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ info: config.info, apps: config.apps });
  expect((await handler(callTicket("x".repeat(3_000_000)))).status).toBe(200);
  const tooBig = await handler(callTicket("x".repeat(5_000_000)));
  expect(tooBig.status).toBe(413);
  expect((await tooBig.json()).error.data.limit).toBe(4 * 1024 * 1024);
});
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}

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

The fetch handler reads the body once to measure it. A platform limit in front, like Cloudflare's own, still applies first.

### Answering only your host names

```bash
FRONTMCP_BIND_ADDRESS=all FRONTMCP_ALLOWED_HOSTS=desk.example.com,127.0.0.1:3000 node dist/node/help-desk.bundle.js
```

Requests for `desk.example.com` (on any default port) and the local health check are served; others get `403` `Invalid Host header`. In code, `http: { security: { dnsRebindingProtection: { allowedHosts: ["desk.example.com"] } } }` does the same, and wins over the variable. [Host checks](https://frontmcp.dev/reference/deployment/node#host-checks) has the rules.

---

## Troubleshooting

### `413 Payload Too Large`, `{"code":-32600,"message":"Payload Too Large","data":{"limit":…,"length":…}}`

The JSON body is bigger than `http.bodyLimit` (4 MB by default, on the fetch handler too). Raise it for tools that take large inputs, like base64 files: `http: { bodyLimit: "10mb" }`.

### `FRONTMCP_HSTS`, `FRONTMCP_CSP_ENABLED` or `deployments[].server.headers` has no effect

A variable the platform already defines wins over the one the build writes. A `node` build of 1.8.7 or earlier ignored `deployments[].server`: build again with 1.9.2, set the variables where the server runs, or use `http.securityHeaders`. CSP also needs `FRONTMCP_CSP_ENABLED=1` (or `true`): directives alone send nothing.

### A header appears twice

The proxy adds a header FrontMCP sends too, most often `X-Content-Type-Options: nosniff` or `X-Frame-Options`. Drop it from the proxy, or set it to `false` in `securityHeaders`.

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

`allowedOrigins` is set, and a browser page from another origin sent the request. Add its origin, scheme included, like `https://app.example.com`. Clients that aren't browsers send no `Origin` and aren't affected.

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

See [Node.js and Docker](https://frontmcp.dev/reference/deployment/node#errorforbiddenmessageinvalid-host-header).

### `[Security] DNS_REBINDING_NOT_ENFORCED`

The server listens on `0.0.0.0` and has no `Host` list, so it answers any host name. Set `FRONTMCP_ALLOWED_HOSTS` to the names your clients and health checks use. (FrontMCP 1.8.7 printed `DNS_REBINDING_PROTECTED` here.)
