Security headers and transport

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.

@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

ResponseFrontMCP's Node server (and the Vercel and Lambda handlers)Fetch handler (Workers, createFetchHandler())
Every response, 401, 403, 404 and 413 includedX-Content-Type-Options: nosniff, X-Frame-Options: DENY, and whatever you add with securityHeaders. Never X-Powered-By.The same
MCP and health answersContent-Type, Cache-Control: no-cache, no-transform, ETagContent-Type; Cache-Control: no-store on health checks
401WWW-AuthenticateWWW-Authenticate
Sessions of clients before MCP 2026-07-28Mcp-Session-Id
A path nothing servesExpress's 404 page, with its own Content-Security-Policy: default-src 'none'JSON 404
The sign-in and consent pages of local and remote authTheir own Content-Security-Policy: see Custom login UIThe 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 where TLS ends.

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.

OptionVariableDefault
hstsFRONTMCP_HSTS, like max-age=31536000; includeSubDomainsNot sent
contentTypeOptionsFRONTMCP_CONTENT_TYPE_OPTIONSnosniff
frameOptionsFRONTMCP_FRAME_OPTIONSDENY
custom, headers sent as they areFRONTMCP_HEADERS_CUSTOM, a JSON objectNone
csp.enabledFRONTMCP_CSP_ENABLED, 1 or trueOff
csp.directives, a record of name to value or to an array of values; '' gives a directive with no valueFRONTMCP_CSP_DIRECTIVES, like default-src 'none'; frame-ancestors 'none'None
csp.reportUriFRONTMCP_CSP_REPORT_URINone
csp.reportOnly, sends Content-Security-Policy-Report-OnlyFRONTMCP_CSP_REPORT_ONLY, 1 or trueOff

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

OptionDefaultWhat 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.allowedHostsDerived from the addressThe 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.
dnsRebindingProtection.allowedOriginsnoneWhen 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.enabledon for the Node serverfalse 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).
strictfalseLoopback 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

RuntimeLimit
Node server, Vercel, Lambdahttp.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 handlerhttp.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

PathWhat 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 /mcpThe 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.
/healthz, /health, /readyzHealth checks, public.
/metricsWith metrics.enabled, public unless auth: "token".
/.well-known/*, /oauth/*With local or remote auth, public by design. Not rate-limited: see rate limits.
http.routesYours, 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:

LineMeans
CORS_DISABLEDNo CORS headers (the default).
CORS_ORIGIN_TRUEcors: { origin: true }, which lets any web page read responses.
BIND_RESTRICTEDListening on 127.0.0.1.
BIND_ALL_INTERFACES, BIND_ALL_INTERFACES_DISTRIBUTEDListening on 0.0.0.0.
DNS_REBINDING_PROTECTEDHost checks are on and enforced: against the loopback names on 127.0.0.1, or against your list.
DNS_REBINDING_NOT_ENFORCEDThe 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_APPLICABLEThe server listens on a Unix socket, which has no host to check; the file's permissions are the boundary.
STRICT_MODE_HINTAlways.

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.
  • 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.
  • 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:

Open
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'");
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

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:

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:

Open
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);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

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

[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.)