zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

Security HeadersSupported

policy.securityHeaders sets hardening headers on every response the router produces, including error responses. It is a plain record of header name to value, so nothing is defaulted behind your back — the headers you write are the headers you get.

Configure the policy#

import { createRouter, type HttpPolicy } from '@zmdb/web';

const policy: HttpPolicy = {
  securityHeaders: {
    'x-content-type-options': 'nosniff',
    'x-frame-options': 'DENY',
    'referrer-policy': 'no-referrer',
    'strict-transport-security': 'max-age=31536000; includeSubDomains',
    'content-security-policy': "default-src 'self'",
  },
};

const router = createRouter({ policy });

createApp takes the same policy option, and CORS lives in the same object — see CORS. The two are independent: a policy may configure either, or both.

The policy wins over the handler#

A configured header overrides whatever a handler set. This is the opposite of a merge-behind wrapper, and it is deliberate: a hardening header that any route can weaken by accident is not a policy.

@Controller('/report')
class ReportController {
  @Get()
  get() {
    // The policy above sends DENY. This SAMEORIGIN does not survive.
    return text('...', { headers: { 'x-frame-options': 'SAMEORIGIN' } });
  }
}

When a route genuinely needs to own a header, set that entry to false instead of removing it. A false entry is not sent by the policy, so the handler's value survives — and the key stays in the record as a record of the decision:

import type { HttpPolicy } from '@zmdb/web';

const policy: HttpPolicy = {
  securityHeaders: {
    'x-content-type-options': 'nosniff',
    // The embed route sets its own frame policy; see ReportController.
    'x-frame-options': false,
  },
};

Every header the policy does not mention is left alone in either direction.

Invalid headers fail at startup#

A malformed header name or a value containing a newline throws from createRouter, not on the request that would have carried it:

import { createRouter } from '@zmdb/web';

// 'bad name' is not a valid header name.
createRouter({ policy: { securityHeaders: { 'bad name': 'value' } } }); // throws

// The value carries a newline, which is how header injection is attempted.
createRouter({ policy: { securityHeaders: { 'content-security-policy': "default-src 'self'\nx: y" } } }); // throws

Header injection through a configured value is therefore not reachable, and a typo is a boot failure rather than a header that silently never appears.

What to set#

The five headers in the first example are a reasonable baseline for a JSON API. Two of them need thought before production:

includeSubDomains is not reversible within the cached period.

closes off the case where a response is somehow rendered as a document.

x-content-type-options: nosniff, x-frame-options: DENY and referrer-policy: no-referrer are safe to set unconditionally on an API.

Design notes#