# 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.

**Diagram: What new App() arms for you**

- **new App()** (source, construction) - no middleware calls required
- **secureHeaders() auto-applied** - HSTS, frame DENY, nosniff, baseline CSP
- **Cross-origin write guard** - POST/PUT/PATCH/DELETE need cors()
- **Per-route accepts allowlist** - content-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 (or with `Origin: null` from a sandboxed iframe) pass through unchanged.

**Diagram: Cross-origin write admission**

1. **Cross-origin POST/PUT/PATCH/DELETE** (ingress) - Origin differs from request URL
2. **cors() allows the origin?** (guard) - matched route policy decides
3. **Rejected** (no policy) - 403 application/problem+json
4. **Reaches your handler** (allowed) - origin 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.

```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({...}) })`.

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.

## 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.

---

Source: https://daloyjs.dev/docs/security/secure-defaults