Skip to content

Search docs

Jump between documentation pages.

Browse docs

Boot guards

Think of it like…the engine check that won't let your car start if the parking brake is on, the doors aren't shut, or a seatbelt isn't buckled. It is much better to fail loudly in the driveway than to discover the problem at the first intersection under load. Boot guards turn the most common misconfigurations (wildcard CORS with credentials, weak session secrets, unconfigured proxy headers) into refuse-to-start errors.

Daloy ships the boot-guards slice of the secure-by-default initiative: refuse-to-boot / first-request guards that turn the most common production misconfigurations into loud failures during startup instead of silent vulnerabilities under load. They cover weak session secrets, wildcard CORS, missing CSRF, auth: declarations with nothing enforcing them, unauthenticated mcpRoutes() endpoints, and spoofable forwarded / vendor client-IP headers.

Every guard is gated on the resolved environment being production (sources: app({ env: "production" }), then app({ production: true }), then NODE_ENV === "production") so dev and CI workflows keep working with sample secrets and ad-hoc headers. The single master escape hatch app({ secureDefaults: false }) disables every boot guard at once.

Fail in the driveway, not the intersection
  1. 01startupApp boots in productionenv: "production"
  2. 02guardsRefuse-to-boot checkssecret, cors '*', csrf, auth:, mcp, forwarded IP
  3. 03misconfigRefuses to start / first request 500loud error during startup
  4. 04cleanServes trafficall guards satisfied
In production each guard turns a common misconfiguration into a refuse-to-boot or first-request 500 instead of a silent vulnerability under load. Dev and CI keep working with sample secrets.

1. Weak session secret refuse-to-boot

app.use(session({ secret })) now refuses to register in production when the secret is shorter than 32 UTF-8 bytes, matches a well-known placeholder ("changeme", "your-jwt-secret", "it-is-very-secret", …), or is a single repeated character ("a".repeat(64), "0".repeat(64)). The check runs synchronously inside app.use(...) so the process exits during startup, not on first request.

ts
import { App, session } from "@daloyjs/core";

const app = new App({ env: "production" });

// Throws at boot - secret is >= 16 chars, but < 32 bytes.
app.use(session({ secret: "sixteen-chars-ok" }));

// Also throws - known weak placeholder.
app.use(session({ secret: "your-session-secret-for-production" }));

// Generate one with: openssl rand -base64 48
app.use(session({ secret: process.env.SESSION_SECRET! }));

Third-party session implementations can opt into the same check by stamping SESSION_HOOK_MARKER and SESSION_SECRETS_MARKER on the returned Hooks object. The standalone helper assertStrongSecret(secret, scope) is also exported for use in your own boot code.

2. cors({ origin: "*" }) refuse-to-boot

A wildcard CORS origin exposes every state-changing route cross-origin and is almost never what production wants. Daloy now refuses to register a cors() hook whose origin is "*" or an array containing "*" in production.

ts
import { App, cors } from "@daloyjs/core";

const app = new App({ env: "production" });

// Throws at boot.
app.use(cors({ origin: "*" }));

// Use an explicit allowlist instead.
app.use(cors({ origin: ["https://app.example.com"] }));

// Or a predicate.
app.use(cors({ origin: (o) => o.endsWith(".example.com") }));

3. session() + state-changing route without csrf()

When any route accepts POST, PUT, PATCH, or DELETE AND a session() hook is installed, a csrf() hook must also be installed. The check runs on first request (because route registration order is unknown until then) and the boot error is cached so every subsequent request rethrows the same failure until you fix the wiring.

ts
import { App, session, csrf } from "@daloyjs/core";

const app = new App({ env: "production" });
app.use(session({ secret: process.env.SESSION_SECRET! }));
app.use(csrf({ strategy: "fetch-metadata", allowedOrigins: ["https://app.example.com"] }));

app.route({
  method: "POST",
  path: "/items",
  // ...
});

Non-browser apps (machine-to-machine APIs, webhook receivers behind bearer auth) can acknowledge that CSRF does not apply with app({ csrf: "off" }):

ts
const app = new App({ env: "production", csrf: "off" });
app.use(session({ secret: process.env.SESSION_SECRET! }));
// state-changing routes ok without csrf()

4. Spoofable client-IP headers with trustProxy unset return 500

When app({ trustProxy }) is not set and a request arrives carrying X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Port, or X-Real-IP, Daloy refuses to dispatch the request and returns a structured 500 problem+json. The rate limiter, audit log, and request-id propagation would otherwise honour the attacker-supplied IP.

The same refusal now covers the platform-specific client-IP headers cf-connecting-ip (Cloudflare), fly-client-ip (Fly.io), and true-client-ip. They are exactly as spoofable as X-Forwarded-*when the app is not actually running behind that platform's proxy, so an unconfigured app refuses them too rather than letting a client forge its source IP through a vendor header the operator never opted into.

ts
// Pick exactly one in production:

// (a) Running behind a trusted reverse proxy (nginx, ALB, Cloudflare):
const app = new App({ env: "production", trustProxy: true });

// (b) Direct-to-process - ignore forwarded headers:
const app = new App({ env: "production", trustProxy: false });

// (c) Disable every boot guard (escape hatch):
const app = new App({ env: "production", secureDefaults: false });

The warning is logged at warn exactly once per process via a latch, so a flood of forged requests does not flood your logs.

5. Route auth: declared but not enforced (shadow security)

A route can declare auth: { scheme, ... }so the generated OpenAPI document advertises it as protected. Previously that declaration was documentation only: if no authentication hook actually ran, the route accepted unauthenticated requests while claiming to be protected, a “shadow security” footgun. Now, in production with secureDefaults on, the App refuses to boot when a route declares auth: but no authentication hook is present in its effective hook chain.

The built-in auth middlewares (bearerAuth, basicAuth, jwk, httpSignatureAuth, clientCertAuth) satisfy the guard automatically. For a custom auth hook, or when authentication is actually enforced by an upstream gateway, wrap the hook with the exported markAuthHook() so the guard can see it.

ts
import { App, bearerAuth, markAuthHook } from "@daloyjs/core";

const app = new App({ env: "production" });

// Built-in middleware satisfies the guard automatically.
app.use(bearerAuth({ validate: (t) => t === process.env.API_TOKEN }));

// A custom auth hook must be marked so the guard recognises it.
app.use(
  markAuthHook({
    beforeHandle: (ctx) => {
      if (!isAuthorized(ctx.request)) {
        return { status: 401, body: { error: "unauthorized" } };
      }
    },
  })
);

app.route({
  method: "GET",
  path: "/me",
  auth: { scheme: "bearer" }, // advertised as protected
  responses: { 200: { description: "ok" } },
  handler: () => ({ status: 200, body: {} }),
});

The exported AUTH_HOOK_MARKER symbol is the marker markAuthHook() stamps, in case you need to check for it yourself. Disable this guard along with the rest via app({ secureDefaults: false }).

6. Unauthenticated mcpRoutes() endpoint

MCP tools are model-controlled and side-effecting, so an unauthenticated MCP endpoint is a high-impact default. In production with secureDefaults on, the App refuses to boot when an mcpRoutes() POST endpoint has no authentication hook in its effective chain. Cover it with an auth middleware, or opt in to a genuinely public server with the new mcpRoutes(path, handler, { public: true }) option (typed as McpRoutesOptions).

ts
import { App, bearerAuth, createMcpHandler, mcpRoutes } from "@daloyjs/core";

const app = new App({ env: "production" });
const mcp = createMcpHandler({ serverInfo, tools });

// (a) Authenticated MCP server - satisfies the guard.
app.use(bearerAuth({ validate: (t) => t === process.env.MCP_TOKEN }));
for (const route of mcpRoutes("/mcp", mcp)) {
  app.route(route);
}

// (b) ...or an intentionally public MCP server.
for (const route of mcpRoutes("/mcp", mcp, { public: true })) {
  app.route(route);
}

Only the POST transport route is checked and stamped; GET (a 405 hint) and OPTIONS (CORS preflight) are left unmarked so preflight stays credential-free. See the MCP docs for the full server setup.

Migration checklist

  • Audit every session({ secret }) call, regenerate any secret shorter than 32 bytes with openssl rand -base64 48.
  • Replace cors({ origin: "*" }) with an explicit allowlist or predicate.
  • Add app.use(csrf(...)) next to app.use(session(...)), or pass app({ csrf: "off" }) for non-browser-facing apps.
  • Pick a trustProxy posture explicitly for every production app. If you relied on cf-connecting-ip, fly-client-ip, or true-client-ip, set trustProxy: true now that those headers are refused too.
  • For every route that declares auth:, confirm a built-in auth middleware covers it, or wrap your custom hook with markAuthHook(...).
  • Add an auth middleware in front of every mcpRoutes() endpoint, or pass mcpRoutes(path, handler, { public: true }) for a deliberately public MCP server.