Secure-by-default
Security defaults start enabled. Disabling them requires bothsecureDefaults: falseandacknowledgeInsecureDefaults: true, and DaloyJS records the choice at startup.
Daloy is the first release in the “secure-by-default” series. It flips secure headers and cross-origin write protection on by default, adds a per-route content type opt-in, and keeps a single master escape hatch (secureDefaults: false) plus per-feature opt-outs for the rare cases where you genuinely need the old behavior.
What flipped
1. secureHeaders() is now auto-applied
Every new App() instance ships secureHeaders() with the same sensible defaults the middleware has always had: HSTS, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, a strict Referrer-Policy, and a baseline CSP. No code change required.
If you call app.use(secureHeaders(...)) with your own configuration, the auto-installed instance is automatically removed so your overrides win instead of being silently shadowed by the framework's defaults.
Want the headers configured at construction time instead? Pass a secureHeaders object to new App():
To opt out entirely (e.g. you serve content from a CDN that injects its own headers):
2. Cross-origin POST / PUT / PATCH / DELETE require cors()
State-changing requests carrying an Origin header from a different origin than the request URL are now rejected with 403 problem+json unless the matched route has a cors() policy that allows that origin. Read-only methods (GET, HEAD, OPTIONS), same-origin requests, and requests without an Origin header pass through unchanged. Opaque origins, including Origin: null from sandboxed frames, are rejected unless a registered CORS policy allows them. Only allow the literal null origin when the application genuinely requires it; it does not identify a particular trusted site. Keep CSRF protection on cookie-authenticated writes.
- 01ingressCross-origin POST/PUT/PATCH/DELETEOrigin differs from request URL
- 02guardcors() allows the origin?matched route policy decides
- 03no policyRejected403 application/problem+json
- 04allowedReaches your handlerorigin on the cors() allowlist
CORS headers on early rejections
From 1.5.4, a route's cors() policy is applied before any hook runs, so responses that end the request early carry it too. That covers an auth 401/403 from jwk() or bearerAuth() (which validate in preBody) and a 413/415/422 body error. Before 1.5.4 those responses had no Access-Control-Allow-Origin, so a browser reported a generic "CORS error" and a portal could not tell an expired token from a CORS fault, or refresh the token.
The order of app.use(cors(...)) and your auth middleware no longer matters, and preflight OPTIONS requests still need no credentials. From 1.5.5, an app-level policy (app.use(cors(...)) or new App({ hooks })) also covers the few rejections that happen before a route is matched: a Host outside allowedHosts (400), a header flood (431), and the production 500 for an unconfigured proxy. A cors() set only on a route can't apply to those, because no route has matched yet, so register it app-wide if your browser clients need to read them. A disallowed origin still gets no Access-Control-Allow-Origin, only Vary: Origin. A cors() wrapped in except() with path patterns stays off the exempted paths; with a function predicate, it is applied when its beforeHandle runs, as before.
Per-route opt-in works too, register the cors() hook on the specific routes that need it via route({ hooks: cors({...}) }).
With credentials: true, cors() refuses at construction, in every environment, an origin of "*". From 1.5.4 it also refuses a predicate or list that allows every origin (it probes the policy with a canary origin) or that allows "null", the origin sandboxed iframes and file: pages send. List the origins you trust, or write a predicate that actually checks them.
To disable the guard entirely (you handle cross-origin admission another way, e.g. via csrf() with the fetch-metadata strategy):
3. Per-route accepts field
New route({ accepts: [...] }) field overrides the global allowedContentTypes allowlist for a single route. The default allowlist already covers application/json, application/x-www-form-urlencoded, and multipart/form-data, so use accepts to restrict a route to a subset (the example below accepts only form-encoded and rejects JSON with 415) or to accept a type outside that set (e.g. application/xml) without touching the global allowlist.
The master escape hatch
If you need to adopt Daloy without changing an existing application's behavior in the same deployment, pass secureDefaults: false as a temporary migration hatch:
This is intentionally one-shot: there is no per-feature granular master flag because the per-feature opt-outs already exist (secureHeaders: false, corsCrossOriginGuard: false). Use secureDefaults: false as a time-boxed migration hatch, not a permanent posture.
Detection markers (advanced)
The framework detects secureHeaders() and cors() registration via two exported symbols. If you wrap these middleware in your own helpers, stamp the marker on your returned hooks to get the same behavior:
Migration checklist
- Audit any custom
secureHeaders()call sites. Behavior is the same, the auto-installed instance is automatically replaced when you register your own. - Audit any cross-origin
POST/PUT/PATCH/DELETEtests / integrations. Registercors()(recommended) or passcorsCrossOriginGuard: false(if you handle cross-origin admission viacsrf({ strategy: 'fetch-metadata' }), for example). - For legacy form-encoded routes, add
accepts: ["application/x-www-form-urlencoded"]on the route definition. - If you must ship the upgrade with zero behavior change while you triage, set
secureDefaults: falseas a temporary escape hatch.
Related secure defaults
Daloy's wider secure-default posture also covers CSP nonces, per-content-type body caps, development response-schema validation, conditional /openapi.json exposure in production, clickjacking defenses, and trailing-slash canonicalization. Use the focused security pages for configuration details and scoped opt-outs.