Skip to content

Search docs

Jump between documentation pages.

Browse docs

Boot guards

Boot guards stop the app when they find unsafe configuration, including credentialed wildcard CORS, weak session secrets, and missing proxy trust settings.

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.post("/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.get(
  "/me",
  {
    auth: { scheme: "bearer" }, // advertised as protected
    responses: { 200: { description: "ok" } },
  },
  () => ({ 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.

7. responseCache() mounted ahead of tenancy()

responseCache() folds the resolved tenant into its cache key automatically, which is what keeps one tenant's cached response from being served to another (CWE-524). That only works if the tenant is already in ctx.state when the key is built — and both middlewares resolve in beforeHandle, in registration order. Mounted before tenancy(), the cache would key every tenant's response identically and leak silently, behind a perfectly ordinary-looking x-cache: HIT.

In production with secureDefaults on, that ordering refuses to boot. The fix is registration order, not configuration:

ts
import { App, responseCache, tenancy, tenantFromSubdomain } from "@daloyjs/core";

// (a) WRONG - refuses to boot: the cache reads state before tenancy writes it.
const bad = new App({ env: "production" });
bad.use(responseCache({ ttlSeconds: 30 }));
bad.use(tenancy({ resolve: tenantFromSubdomain({ baseDomain: "example.com" }) }));

// (b) RIGHT - tenancy first, so ctx.state.tenant exists when the key is built.
const good = new App({ env: "production" });
good.use(tenancy({ resolve: tenantFromSubdomain({ baseDomain: "example.com" }) }));
good.use(responseCache({ ttlSeconds: 30 }));

// (c) Also right - tenancy as a global hook always runs first.
const alsoGood = new App({
  env: "production",
  hooks: tenancy({ resolve: tenantFromSubdomain({ baseDomain: "example.com" }) }),
});
alsoGood.use(responseCache({ ttlSeconds: 30 }));

See cache key and cross-principal isolation for the full keying model, including the principal option for cookie-authenticated routes.

8. responseCache() / idempotency() mounted ahead of rateLimit()

A cache hit and an idempotent replay are both returned from beforeHandle, and returning a response there ends the hook chain. rateLimit() and loginThrottle() enforce from that same phase, so a limiter mounted behind either one never counts the requests it serves. Measured before this guard existed: rateLimit({ max: 2 }) admitted six of six requests behind a cache and behind a replay. The declared budget becomes unlimited for precisely the repeat traffic the limit was written for, and nothing in the response says so.

This is the same hazard as guard 7, one phase later. The five network-identity gates (geoBlock, ipRestriction, botGuard, autoBan, ipReputation) were made immune by moving them to preBody, which always runs first. rateLimit() cannot follow them there, because its keyGenerator is caller-supplied and may read ctx.state that session() or an auth layer populates later. So the unsafe order is refused instead of silently reordered.

ts
import { App, rateLimit, responseCache, idempotency } from "@daloyjs/core";

// (a) WRONG - refuses to boot: cache hits never reach the limiter.
const bad = new App({ env: "production" });
bad.use(responseCache({ ttlSeconds: 30 }));
bad.use(rateLimit({ windowMs: 60_000, max: 100 }));

// (b) RIGHT - every request is counted before a stored response can answer it.
const good = new App({ env: "production" });
good.use(rateLimit({ windowMs: 60_000, max: 100 }));
good.use(responseCache({ ttlSeconds: 30 }));

// (c) Also right - the limiter as a global hook always runs first.
const alsoGood = new App({
  env: "production",
  hooks: rateLimit({ windowMs: 60_000, max: 100 }),
});
alsoGood.use(idempotency({ ttlSeconds: 86_400 }));

Ordering the limiter first does mean cache hits and replays spend budget. That is the intended reading of a rate limit: the cap is on what a caller may ask for, not on what happened to be expensive to produce.

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.