Security Headers and CORS

Set browser protections with app.useSecurityHeaders(), then name the origins allowed to read your responses instead of using a wildcard.

Syntaxapp.useSecurityHeaders(options?); app.enableCors(options?)

A browser only switches on a protection if the response asks for it. Security headers are that request. Content-Security-Policy says where scripts may come from, Strict-Transport-Security makes later visits use HTTPS, X-Content-Type-Options: nosniff stops the browser guessing a response's type. Since NestJS 12.1, app.useSecurityHeaders() sends them with the same defaults as Helmet 8, identically on Express and Fastify, with no extra package.

Where it runs

It is not a guard, an interceptor or a pipe. It registers a framework-level middleware (Express) or an onRequest hook (Fastify) at the point you call it, so it runs before routing, before guards and before handlers. Every response carries the headers: route responses, 404s, errors. Call it right after NestFactory.create(); calling it after app.init() or app.listen(), or twice, throws.

CORS is a different mechanism

CORS does not protect your API. It tells a browser whether JavaScript on another origin may read a response. app.enableCors() configures the Express cors package, or @fastify/cors on Fastify. origin: '*' lets any page on the internet read any response, which is fine for a public read-only API and wrong for anything behind a session. A non-browser client such as curl ignores CORS entirely, so it is never an authorisation check.

What trips people up

The default CSP is script-src 'self', so a CDN script stops loading the day you enable headers. On Fastify, enableCors() with no options advertises only GET,HEAD,POST, so a cross-origin DELETE fails at the preflight, while the same code works on Express.

Example

Example · typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Call before init/listen. Calling it afterwards, or twice, throws.
  app.useSecurityHeaders({
    // maxAge is in seconds. 31536000 is the default (365 days).
    strictTransportSecurity: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
    // The default is 'no-referrer'. This one still sends the bare origin
    // to other sites, which most analytics tools expect.
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    contentSecurityPolicy: {
      directives: {
        // A directive you set REPLACES the default of the same name,
        // so 'self' has to be repeated here or it is lost.
        scriptSrc: ["'self'", 'https://cdn.example.com'],
        connectSrc: ["'self'", 'wss://realtime.example.com'],
      },
    },
    // false leaves a header out entirely; true sends its default value.
    crossOriginEmbedderPolicy: false,
  });

  // An allowlist, not a wildcard: only these two origins may read responses.
  app.enableCors({
    origin: ['https://app.example.com', 'https://admin.example.com'],
    // Needed for cross-origin requests that carry cookies or an
    // Authorization header. Incompatible with origin: '*'.
    credentials: true,
    // @fastify/cors defaults to GET,HEAD,POST only, so list the rest.
    methods: ['GET', 'HEAD', 'POST', 'PATCH', 'PUT', 'DELETE'],
    maxAge: 86400,
  });

  await app.listen(process.env.PORT ?? 3000);
}

await bootstrap();

When to use it

  • Turn on browser protections for an existing API without adding a dependency, after NestJS 12.1.
  • Let a single-page app on app.example.com call an API on api.example.com with session cookies.
  • Allow one route to serve an image that other sites may embed, while the rest of the app stays same-origin.
  • Find out why a CDN script stopped loading the day security headers were switched on.

More examples

The Helmet longhand

app.useSecurityHeaders() and app.use(helmet()) produce the same headers with the same defaults; the first needs no package and also works on Fastify, where Helmet is a plugin registered with app.register(helmet) from @fastify/helmet rather than a middleware. Reach for Helmet directly when the project already uses it, or when you need a CSP directive whose value is a function, which Helmet supports for per-request nonces and useSecurityHeaders() does not. Recognise both forms: the option names are identical apart from the legacy aliases above.

Example · typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import helmet from 'helmet'; // npm i helmet
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Same defaults as app.useSecurityHeaders(), one dependency later.
  // Register it before any other app.use() call: the platform applies
  // middleware in registration order, so Helmet added after a route
  // does not cover that route.
  app.use(
    helmet({
      // Helmet's legacy option names. useSecurityHeaders() rejects these
      // and names the replacement:
      //   hsts        -> strictTransportSecurity
      //   frameguard  -> xFrameOptions
      //   noSniff     -> xContentTypeOptions
      //   xssFilter   -> xXssProtection
      hsts: { maxAge: 31536000, includeSubDomains: true },
      frameguard: { action: 'deny' },
    }),
  );

  await app.listen(process.env.PORT ?? 3000);
}

await bootstrap();

Report-only first, then enforce

Enabling a CSP on an existing app usually breaks something, because the default policy allows scripts only from your own origin. Report-only mode lets you collect the violations before anything stops working. The example also shows the two value conventions: camelCase and kebab-case directive names are both accepted, a string list replaces the default of that name, true sends a directive with no value, and null removes one.

Example · typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

const isProduction = process.env.NODE_ENV === 'production';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useSecurityHeaders({
    contentSecurityPolicy: {
      // Sends Content-Security-Policy-Report-Only instead: the browser
      // reports violations and blocks nothing. Turn this off once the
      // reports are empty.
      reportOnly: !isProduction,
      directives: {
        // Swagger UI's own scripts come from your app, so they pass the
        // default policy. Scripts added with the customJs option do not.
        scriptSrc: ["'self'", 'https://cdn.example.com'],
        // 'report-to' needs a Reporting-Endpoints header; 'report-uri' is
        // the older form that browsers still accept.
        'report-uri': ['https://csp.example.com/report'],
        // null removes a directive, including a default one. Drop this
        // one when Swagger UI is served over plain HTTP on staging.
        upgradeInsecureRequests: isProduction ? true : null,
      },
    },
  });

  await app.listen(process.env.PORT ?? 3000);
}

await bootstrap();

An allowlist that comes from configuration

Reach for the callback when the allowlist differs per environment, or grows at runtime from a tenants table. Returning false does not fail the request: the response simply arrives without an Access-Control-Allow-Origin header, and the browser hides it from the calling page. The comment about regexes is the mistake worth remembering - a suffix match on a host name is how an attacker-owned domain ends up allowed.

Example · typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // CORS_ORIGINS="https://app.example.com,https://staging.example.com"
  const allowed = new Set(
    (process.env.CORS_ORIGINS ?? '').split(',').filter(Boolean),
  );

  app.enableCors({
    // The callback form of the cors package's origin option. It runs per
    // request, so the decision can depend on the Origin header.
    origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
      // No Origin header: a same-origin request, curl, or a mobile app.
      // There is nothing for the browser to allow, so let it through.
      if (!origin) {
        return callback(null, true);
      }
      // Exact string match. Never build this from a substring or a regex
      // such as /example\.com$/ - that also matches evil-example.com.
      callback(null, allowed.has(origin));
    },
    credentials: true,
    methods: ['GET', 'HEAD', 'POST', 'PATCH', 'PUT', 'DELETE'],
  });

  await app.listen(process.env.PORT ?? 3000);
}

await bootstrap();

Discussion

  • Be the first to comment on this lesson.