Boot guards
Boot guards stop the app when they find unsafe configuration they know about, such as credentialed wildcard CORS or weak session secrets. They catch known misconfigurations; they do not prove an app is secure.
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 (that one is a per-request 500, not a boot refusal).
The guards on this page are gated on the resolved environment being production (sources: app({ env: "production" }), then app({ production: true }), then NODE_ENV === "production" exactly) so dev and CI workflows keep working with sample secrets and ad-hoc headers. A few related checks throw in every environment; they are listed under Checks that apply in every environment. The single master escape hatch app({ secureDefaults: false }) disables every boot guard at once.
When the guards run
The route-table guards (sections 3 and 5 to 9) need every route registered before they can judge the app, so they run in three places:
- At adapter startup.
serve()(Node, Bun, Deno),toFetchHandler()(Cloudflare, Vercel),toWebHandler(),toLambdaHandler()and the Fastly helpers callapp.assertSecureConfig()before serving. In production a violation throws there, so the process or the cold start fails instead of live traffic. Cloudflare validates Worker startup on deploy, so there the deploy itself fails. - On the first request, as before. This still covers routes registered after the adapter was created.
- In CI, with
daloy doctor, which reports each violation as an error finding (bootGuard.shadow-auth,bootGuard.session-without-csrf, ...) and exits1.
You can also call it yourself, for example in a test that checks your production configuration from a dev machine:
No environment signal. With no env, no production and no NODE_ENV (the default on Cloudflare Workers and Deno Deploy), DaloyJS cannot tell a deploy from local development, so it does not refuse. It logs each would-be refusal once instead, as a secure_defaults.env_indeterminate warning that names the guard and the route. Set app({ env: "production" }) to enforce them, or app({ env: "development" }) to silence the warning.
Unrecognized NODE_ENV (1.5.4). A NODE_ENV other than development, test or production (for example staging or prod) is treated the same way: DaloyJS logs a one-time warning naming the value, and each would-be production refusal is logged instead of enforced. If staging should behave like production, set app({ env: "production" }) there.
Your CI. Because most guards need a production signal, a test run with NODE_ENV=test does not trip them. Run daloy doctor, or call app.assertSecureConfig({ production: true }) in a test, to check the production configuration in CI. In 1.5.4, several daloy doctor checks that read option names that do not exist (and so never fired) were fixed to read the real options.
- 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. origin: "*" together with credentials: true throws in every environment, and from 1.5.4 so does credentials: true with a predicate or list that allows every origin or allows "null" (see below).
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({ behindProxy }) (or the older 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.
Don't pick (c), trustProxy, or secureDefaults: false just to make the 500 go away. On serverless and edge platforms there is no TCP peer to fall back on, so with any of those rateLimit() cannot tell clients apart and puts every caller in one shared bucket: one client can exhaust it for everyone. In production DaloyJS logs a rate-limit.shared-bucket warning the first time that happens.
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. Like guard 5, the boot check sees that an auth hook is present; from 1.5.4 the request-time check also refuses the call in production when that hook did not run. 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.
9. Unauthenticated a2aRoutes() endpoint
An A2A agent endpoint runs your onMessage handler for whichever peer agent calls it, so it gets the same rule as MCP. In production with secureDefaults on, the App refuses to boot when the a2aRoutes() JSON-RPC POST route has no authentication hook in its effective chain. The public Agent Card route is never checked: discovery has to work before a peer has credentials. Like guard 5, the boot check sees that an auth hook is present; from 1.5.4 the request-time check also refuses the call in production when that hook did not run. In production, registration also throws if the card advertises a non-HTTPS endpoint on a non-loopback host.
See the A2A docs for the full agent setup.
10. requireAuth: every route needs auth or public: true
Routes are public unless something authenticates them, which is easy to forget for one route among many. app({ requireAuth: true }) flips that: registering a route with no authentication hook in its effective chain throws, unless the route says public: true. It is opt-in and, because you asked for it, enforced in every environment.
Hooks apply to routes registered after them, so register auth before the routes it should cover. A route registered first is refused even if you add app.use(auth) later, which is the point: that route really would be unauthenticated. Framework routes that are public by design are already marked: docs, OpenAPI and AsyncAPI documents, health and metrics probes (which have their own token), the CSP report endpoint, the MCP and A2A GET / OPTIONS routes, and the A2A Agent Card. mcpRoutes() and a2aRoutes() with { public: true } mark their JSON-RPC route public too.
11. allowedHosts: DNS rebinding
The cross-origin guard rejects a state-changing request whose Origin differs from the request's own origin. A DNS-rebinding page defeats that comparison: the attacker's name first resolves to their server, then to yours, so the browser sends Origin and Host that are both the attacker's name and match. That matters most for a server a victim's browser can reach but the internet cannot, such as a local dev server, an internal service, or a local agent tool.
allowedHosts refuses any request whose Host is not listed, with 400, before routing:
Ports are ignored and matching is case-insensitive. "*", an empty list, a scheme or a port in an entry are refused at construction. When allowedHosts is unset, serve() on Node, Bun and Deno applies a development default (localhost, *.localhost and IP literals) when the environment is positively development, which daloy dev sets. Production and unknown environments accept any host unless you set the list, and daloy doctor warns about that in production. Platforms such as Vercel route by Host and never deliver a rebound name to your function, so there the list is defense in depth.
12. JWT verification without an audience (1.5.4)
In production, jwk() and createJwtVerifier() refuse to construct without an audience. Without it, any token your identity provider issues for another API is accepted by this one. Pass allowAnyAudience: true only when the issuer mints tokens for this service alone, such as a single-tenant private identity provider.
Checks that apply in every environment
These are construction-time refusals that do not wait for a production signal, because there is no development use for the configuration:
cors({ origin: "*", credentials: true }). From 1.5.4 alsocredentials: truewith an origin predicate or list that allows every origin (detected by probing a canary origin) or allows"null".- From 1.5.4, numeric options must be finite non-negative integers:
bodyLimitBytes,requestTimeoutMs,maxHeaderCount,jsonMaxKeysandjsonMaxDepth.NaN,Infinity, negatives, non-integers and strings throw; an explicitundefineduses the default. The usual trigger isNumber(process.env.UNSET), which isNaNand used to disable the limit silently.rateLimit()likewise refuses awindowMsthat is not a positive integer or amaxthat is not a non-negative integer (max: 0still refuses every request). - From 1.5.4,
behindProxy: { cidrs: [...] }and the guards'trustedProxiesrefuse a/0range (0.0.0.0/0,::/0), which would trust every peer. - From 1.5.4, a client-identity guard (
rateLimit,loginThrottle,autoBan,ipRestriction,geoBlock,ipReputation,botGuard,concurrencyLimit) withtrustProxyHeaders: trueortrustedHopsis refused on an App withbehindProxy: "none": the two contradict each other, and the guard's setting would let any client pick its own IP. Drop the guard's option (it follows the App'sbehindProxy) or declare the real topology.trustedProxies, which checks the peer address, is still allowed. - From 1.5.4,
serve(app, { trustProxy: true })is refused when the App saysbehindProxy: "none", andconnectionTimeoutMsmust be a finite non-negative integer (0still disables the socket timeouts, with a warning in production). - From 1.5.4,
csrf()refusesignoreMethodsthat includePOST,PUT,PATCHorDELETE, and anallowedOriginspredicate that accepts a test origin no real allowlist would. - From 1.5.4, JWT
clockSkewSecondsabove 300 is refused (a larger skew keeps expired tokens valid), andhealthcheck()/readinesscheck()/metrics()tokens must be at least 16 characters. - From 1.5.4,
secureHeaders()treatsframe-ancestors *or a bare scheme (https:) as no clickjacking protection, so turning offframeOptionswith such a CSP is refused like a missing directive.
From 1.5.4, production also logs a one-time warning for configurations that are allowed but risky: session() on its in-memory store (session.memory_store_in_production), mounted API docs (docs.public_in_production), secure defaults switched off one by one (secure_defaults.partially_disabled), and routes whose 2xx response has no body schema (security.response.bodySchemaMissing, previously development-only).
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
behindProxyposture explicitly for every production app:{ hops: 1 }on Vercel, Cloudflare Workers and similar platforms. If you relied oncf-connecting-ip,fly-client-ip, ortrue-client-ip, declare the proxy withbehindProxynow 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. - 1.5.4: give every
jwk()/createJwtVerifier()anaudience(orallowAnyAudience: truefor a single-tenant private provider), parse numeric env vars with a fallback instead of a bareNumber(...), and setenv: "production"explicitly whereNODE_ENVis something likestaging.