Skip to content

Search docs

Jump between documentation pages.

Browse docs

Secure-by-default

Security defaults start enabled. Disabling them requires both secureDefaults: false and acknowledgeInsecureDefaults: 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 new App() arms for you
constructionnew App()no middleware calls required
secureHeaders() auto-appliedHSTS, frame DENY, nosniff, baseline CSP
Cross-origin write guardPOST/PUT/PATCH/DELETE need cors()
Per-route accepts allowlistcontent-type opt-in per route
A fresh App instance ships these defaults armed. Each one has a per-feature opt-out, and secureDefaults: false is the single master escape hatch for migrations.

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.

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

const app = new App();
// secureHeaders() already attached - no app.use(secureHeaders()) needed.

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.

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

const app = new App();
app.use(
  secureHeaders({
    contentSecurityPolicy: "default-src 'self'; script-src 'self' 'nonce-{nonce}'",
    frameOptions: "SAMEORIGIN",
  }),
);
// The framework's default secureHeaders is dropped; your config is the only one active.

Want the headers configured at construction time instead? Pass a secureHeaders object to new App():

ts
const app = new App({
  secureHeaders: { frameOptions: "SAMEORIGIN" },
});

To opt out entirely (e.g. you serve content from a CDN that injects its own headers):

ts
const app = new App({ secureHeaders: false });

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.

Cross-origin write admission
  1. 01ingressCross-origin POST/PUT/PATCH/DELETEOrigin differs from request URL
  2. 02guardcors() allows the origin?matched route policy decides
  3. 03no policyRejected403 application/problem+json
  4. 04allowedReaches your handlerorigin on the cors() allowlist
Without a cors() policy that allows the origin, a cross-origin state-changing request is rejected with 403 before your handler runs. Read-only methods and same-origin requests are never affected.

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.

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

const app = new App();
app.use(cors({ origin: ["https://app.example.com"] }));
// Register this before the routes it should apply to.
// Cross-origin POST from https://app.example.com now passes through to your handler.

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):

ts
const app = new App({ corsCrossOriginGuard: false });

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.

ts
app.post(
  "/legacy/webhook",
  {
    operationId: "legacyWebhook",
    accepts: ["application/x-www-form-urlencoded"],
    request: { body: z.object({ payload: z.string() }) },
    responses: { 200: { description: "ok" } },
  },
  async ({ body }) => ({ status: 200 as const, body: { ok: true } }),
);

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:

ts
const app = new App({ secureDefaults: false });

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:

ts
import {
  cors,
  secureHeaders,
  CORS_HOOK_MARKER,
  CORS_ORIGIN_ALLOW_MARKER,
  SECURE_HEADERS_MARKER,
} from "@daloyjs/core";

export function myCors() {
  const hooks = cors({ origin: ["https://app.example.com"] });
  // already stamped with CORS_HOOK_MARKER and CORS_ORIGIN_ALLOW_MARKER.
  return hooks;
}

export function myCustomHeaders() {
  const hooks = secureHeaders({ frameOptions: "SAMEORIGIN" });
  // already stamped; the auto-installed instance will be dropped when you use() this.
  return hooks;
}

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 / DELETE tests / integrations. Register cors() (recommended) or pass corsCrossOriginGuard: false (if you handle cross-origin admission via csrf({ 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: false as a temporary escape hatch.

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.