Security Headers and CORS
Set browser protections with app.useSecurityHeaders(), then name the origins allowed to read your responses instead of using a wildcard.
app.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
// 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.
// 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.
// 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.
// 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