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.
- 01startupApp boots in productionenv: "production"
- 02guardsRefuse-to-boot checkssecret, cors '*', csrf, auth:, mcp, forwarded IP
- 03misconfigRefuses to start / first request 500loud error during startup
- 04cleanServes trafficall guards satisfied
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.
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.
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.
Non-browser apps (machine-to-machine APIs, webhook receivers behind bearer auth) can acknowledge that CSRF does not apply with app({ csrf: "off" }):
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.
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.
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).
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:
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.
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 withopenssl rand -base64 48. - Replace
cors({ origin: "*" })with an explicit allowlist or predicate. - Add
app.use(csrf(...))next toapp.use(session(...)), or passapp({ csrf: "off" })for non-browser-facing apps. - Pick a
trustProxyposture explicitly for every production app. If you relied oncf-connecting-ip,fly-client-ip, ortrue-client-ip, settrustProxy: truenow 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 withmarkAuthHook(...). - Add an auth middleware in front of every
mcpRoutes()endpoint, or passmcpRoutes(path, handler, { public: true })for a deliberately public MCP server.