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
| 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. 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 | 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 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.
| 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). 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. |
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). |
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. |
/healthz, /health, /readyz | Health checks, 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. |
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, andFRONTMCP_TRUST_PROXYif the proxy setsX-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[].serverthrough thenodebuild; with 1.8.7, every header above on the401,403,404and413answers, the variables,http.securityHeaderswinning over them, anddeployments[].serverthroughfrontmcp devand thevercelbuild (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:
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.jscurl -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:
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:
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.jsRequests 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.)