Security Headers, CORS & CSRF

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), Node.js 20.19+ or 22.12+, TypeScript 6, Express 5 and Fastify 5, and was checked on 2026-10-02. The built-in methods come from @nestjs/common and @nestjs/core 12.1; the third-party packages used alongside them are helmet 8, @fastify/helmet, cookie-parser 1.4, csrf-csrf, @fastify/cookie 11 and @fastify/csrf-protection. Every snippet builds with nest build and tsc --noEmit on NestJS 12.1.2, and works on both the Express and the Fastify adapter; the headers and status codes shown are what the snippets return.

A browser enforces a set of rules that an HTTP server can switch on or relax with response headers: which scripts a page may run, whether it may be framed, which other origins may call its API, and whether a request that another site triggered is accepted. They are three separate mechanisms with one thing in common: they protect the browser’s user, and a client that is not a browser ignores them. This page covers all three in NestJS. Before 12.1 each one meant a third-party package; 12.1 added app.useSecurityHeaders() and app.enableCsrfProtection() to the application object, with identical behaviour on Express and Fastify. For the theory of CORS itself, see CORS. For cookies, which CSRF attacks abuse, see Cookies & Sessions.

The scenario is the Bookshelf API of the other pages, a books controller with GET, POST, PUT and DELETE handlers and a webhooks/payments endpoint that a payment provider calls.

The book controller used by the examples (books.controller.ts, abridged)
@Controller('books')
export class BooksController {
  @Get()
  findAll() {
    return [{ id: 1, title: 'Dune' }];
  }

  @Post()
  create(@Body() body: { title: string }) {
    return { id: 2, title: body.title };
  }

  @Delete(':id')
  @HttpCode(204)
  remove(@Param('id') _id: string) {}
}

Where the three mechanisms sit

All three are registered at the application level, before routing, so they apply to every response, including 404 pages and error responses:

Mechanism Built into Nest Third-party alternative

Security headers

app.useSecurityHeaders() (12.1, no extra package)

helmet (Express), @fastify/helmet (Fastify)

CORS

app.enableCors() or the cors create option, backed by cors (Express) or @fastify/cors (Fastify)

the same packages used directly

CSRF

app.enableCsrfProtection() (12.1, origin based, no extra package)

csrf-csrf (Express), @fastify/csrf-protection (Fastify), token based

Because they run in a framework-level middleware (Express) or onRequest hook (Fastify), they run before Nest middleware, body parsing, guards, interceptors and pipes. The consequence is that a controller-scoped exception filter, guard or interceptor never sees a request that these layers reject. The order of the rest of the pipeline is in Request Lifecycle and Middleware.

Security headers with useSecurityHeaders()

app.useSecurityHeaders() sets the headers that Helmet 8 sets by default, with the same values, and removes X-Powered-By. It needs no package and behaves the same on both adapters.

Enabling the default headers (setup.ts)
export function defaultHeaders(app: INestApplication) {
  app.useSecurityHeaders();
}

Requesting GET /books against the Express and the Fastify application returned the same headers, apart from the adapter-specific etag:

content-security-policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin
origin-agent-cluster: ?1
referrer-policy: no-referrer
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-dns-prefetch-control: off
x-download-options: noopen
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-xss-protection: 0

Cross-Origin-Embedder-Policy is not sent by default. A request to a route that does not exist (GET /nope) returned 404 with the same headers, and X-Powered-By was absent.

Rules to remember

  • Call it before app.init() or app.listen(). Calling it afterwards, or calling it twice, throws app.useSecurityHeaders() must be called before app.init() / app.listen().

  • Options are validated immediately, so a bad configuration fails at startup rather than at the first request.

  • The headers are written by a hook registered at the point of the call. Middleware that you registered with app.use() earlier runs first, and a response such a middleware sends itself carries no headers. Call it right after NestFactory.create().

  • useSecurityHeaders() and enableCsrfProtection() share one request hook, so they can be called in either order and the headers are always written before the CSRF check, which means a rejected request carries them too.

Configuring individual headers

Each option controls one header: false leaves it out, true sends its default, and an object configures it. The option names are not Helmet’s legacy ones.

Tuning headers and the Content-Security-Policy (setup.ts)
export function tunedHeaders(app: INestApplication) {
  app.useSecurityHeaders({
    xFrameOptions: { action: 'deny' },
    strictTransportSecurity: { maxAge: 63072000, preload: true },
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    contentSecurityPolicy: {
      directives: {
        scriptSrc: ["'self'", 'https://cdn.example.com'],
        connectSrc: ["'self'", 'wss://realtime.example.com'],
        upgradeInsecureRequests: null,
      },
    },
  });
}

The response headers are:

content-security-policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self' https://cdn.example.com;script-src-attr 'none';style-src 'self' https: 'unsafe-inline';connect-src 'self' wss://realtime.example.com
strict-transport-security: max-age=63072000; includeSubDomains; preload
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin

The header options and their object forms are listed in this table:

Option Properties

contentSecurityPolicy

directives, useDefaults (default true), reportOnly (default false)

crossOriginEmbedderPolicy

policy: 'require-corp', 'credentialless', 'unsafe-none'; not sent by default

crossOriginOpenerPolicy

policy: 'same-origin' (default), 'same-origin-allow-popups', 'noopener-allow-popups', 'unsafe-none'

crossOriginResourcePolicy

policy: 'same-origin' (default), 'same-site', 'cross-origin'

referrerPolicy

policy: one policy or a list read as a fallback chain (default 'no-referrer')

strictTransportSecurity

maxAge in seconds (default 31536000), includeSubDomains (default true), preload (default false)

xDnsPrefetchControl

allow (default false)

xFrameOptions

action: 'sameorigin' (default) or 'deny'

xPermittedCrossDomainPolicies

permittedPolicies: 'none' (default), 'master-only', 'by-content-type', 'all'

originAgentCluster, xContentTypeOptions, xDownloadOptions, xPoweredBy, xXssProtection

boolean only

Unknown options and invalid values throw at startup. Passing Helmet’s legacy name hsts produced:

Invalid security headers configuration: unknown option "hsts" (helmet's legacy name, use "strictTransportSecurity").

The renamed options are dnsPrefetchControl to xDnsPrefetchControl, frameguard to xFrameOptions, hidePoweredBy to xPoweredBy, hsts to strictTransportSecurity, ieNoOpen to xDownloadOptions, noSniff to xContentTypeOptions, permittedCrossDomainPolicies to xPermittedCrossDomainPolicies and xssFilter to xXssProtection.

Overriding a header on one route

A handler can override a header that the application sets, for example to let other sites embed an image, with @Header() (see Controllers & Routing).

Relaxing Cross-Origin-Resource-Policy for one route (books.controller.ts)
@Get('covers/:id')
@Header('Cross-Origin-Resource-Policy', 'cross-origin')
findCover(@Param('id') id: string) {
  return { cover: id };
}

Both adapters returned cross-origin-resource-policy: cross-origin for GET /books/covers/1, while every other route kept same-origin. On Fastify, reply.header() overrides as well, but reply.removeHeader() cannot remove a header set by useSecurityHeaders().

Content-Security-Policy

The default policy is Helmet 8’s, sent on one line. The rules for directives:

  • names are camelCase (scriptSrc) or kebab-case ('script-src');

  • a directive you set replaces the default directive of the same name, so repeat 'self' if you want to keep it, while directives you do not mention stay in the policy;

  • null or false removes a directive, including a default one (upgradeInsecureRequests: null above);

  • keywords, nonces and hashes must be single-quoted. An unquoted self is rejected at startup with contains "self": keywords, nonces and hashes must be single-quoted (e.g. "'self'");

  • values may contain only printable ASCII and neither ; nor ,, so a value cannot smuggle in another directive;

  • useDefaults: false sends only your directives, which then need a defaultSrc (or defaultSrc: null on purpose).

A policy is easy to get wrong in a way that breaks pages. Set reportOnly to true first: Nest then sends Content-Security-Policy-Report-Only, browsers report violations instead of blocking them, and no Content-Security-Policy header is sent.

Trying a policy without enforcing it (setup.ts)
export function reportOnlyCsp(app: INestApplication) {
  app.useSecurityHeaders({ contentSecurityPolicy: { reportOnly: true } });
}

A test shows content-security-policy absent and content-security-policy-report-only carrying the default policy. Add a report-to or report-uri directive to receive the reports. To leave the header out entirely, set contentSecurityPolicy to false.

Swagger UI and GraphiQL

The policy applies to every response, including the HTML pages of API tools, and that is where it bites:

  • Swagger UI from @nestjs/swagger serves its scripts and stylesheets from your own application, so it works with the default policy. Scripts added with the customJs option need their host in scriptSrc, and inline code from customJsStr needs 'unsafe-inline'. The default upgrade-insecure-requests makes browsers fetch assets over HTTPS, so Swagger UI served over plain HTTP outside local development, for example on a staging server reached by IP address, fails to load. Serve it over HTTPS or set upgradeInsecureRequests: null.

  • GraphiQL, as served by the Apollo driver, loads React and GraphiQL from unpkg.com and starts with an inline script. It is enabled by default only when NODE_ENV is not production, so relax the policy only there.

  • Apollo Sandbox (the ApolloServerPluginLandingPageLocalDefault() landing page) loads scripts, images and a manifest from Apollo’s CDN and embeds the sandbox in a frame, which needs scriptSrc, imgSrc, manifestSrc and frameSrc entries for those hosts.

Relaxing the policy for GraphiQL outside production (setup.ts)
export function developmentCsp(app: INestApplication, isProduction: boolean) {
  app.useSecurityHeaders({
    contentSecurityPolicy: isProduction
      ? true
      : {
          directives: {
            scriptSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
          },
        },
  });
}

With isProduction false the resulting policy is script-src 'self' 'unsafe-inline' https://unpkg.com and every other directive stays at its default. 'unsafe-inline' in script-src gives up most of the protection CSP offers against cross-site scripting for every page the application serves, so keep it out of production. The OpenAPI and GraphQL setup itself is covered on their own pages.

Using Helmet directly

Helmet remains valid, and it is what every application on NestJS 11 or earlier uses. Since the option names of useSecurityHeaders() are Helmet’s, a Helmet configuration mostly carries over when you switch, with two exceptions: the legacy names above are rejected, and CSP directive values must be strings, lists of strings, booleans or null, so the functions Helmet accepts to generate per-request nonces are not supported by the built-in method. If you need nonces, keep Helmet.

Register Helmet before other app.use() calls or setup functions that may call it, since the platform applies middleware in registration order and Helmet registered after a route does not cover that route.

Helmet on Express and @fastify/helmet on Fastify (helmet-direct.ts)
import { NestExpressApplication } from '@nestjs/platform-express';
import { NestFastifyApplication } from '@nestjs/platform-fastify';
import helmet from 'helmet';
import fastifyHelmet from '@fastify/helmet';

export function expressHelmet(app: NestExpressApplication) {
  app.use(helmet());
}

export async function fastifyHelmetPlugin(app: NestFastifyApplication) {
  await app.register(fastifyHelmet);
}
npm i helmet            # Express
npm i @fastify/helmet   # Fastify

On Fastify, @fastify/helmet is a Fastify plugin, not a middleware, so it is registered with app.register(), which returns a promise. With both packages the headers match the built-in defaults (same content-security-policy and strict-transport-security).

CORS

CORS lets a page on one origin call an API on another. A browser sends a preflight OPTIONS request with Access-Control-Request-Method before a non-simple request, and the server answers with Access-Control-Allow-* headers that tell the browser whether to proceed. Enabling CORS does not stop a non-browser client from calling the API; see CORS for the model.

Nest delegates to the Express cors package or to @fastify/cors, depending on the adapter. Two equivalent ways to turn it on:

const app = await NestFactory.create(AppModule);
app.enableCors();

// or, through the create() options
const app = await NestFactory.create(AppModule, { cors: true });

enableCors() and the cors option take either the configuration object of the underlying package or a callback that computes it per request.

CORS with origins read from configuration (setup.ts)
export function configureCors(app: INestApplication) {
  const config = app.get(ConfigService);
  const origins = (config.get<string>('CORS_ORIGINS') ?? '')
    .split(',')
    .map((origin) => origin.trim())
    .filter(Boolean);

  app.enableCors({
    origin: origins,
    methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
    credentials: true,
    maxAge: 600,
  });
}

With CORS_ORIGINS set to two origins, a preflight OPTIONS /books/1 from the first one with Access-Control-Request-Method: PUT was answered like this on both adapters:

204 No Content
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET,HEAD,PUT,PATCH,POST,DELETE
access-control-allow-credentials: true
access-control-max-age: 600
vary: Origin, Access-Control-Request-Headers

(Fastify separates the methods with , `.) A request from an origin that is not in the list received no `access-control-allow-origin header, so the browser blocks the response. The application still ran the handler and returned 200, which is a reminder that CORS is enforced by the browser, not by the server.

The adapters do not share defaults

enableCors() with no options is not the same on both platforms. The same preflight for PUT against app.enableCors() returned:

Express (cors) Fastify (@fastify/cors)

Access-Control-Allow-Origin

*

*

Access-Control-Allow-Methods

GET,HEAD,PUT,PATCH,POST,DELETE

GET,HEAD,POST

The same applied with { cors: true }. On Fastify, a cross-origin PUT, PATCH or DELETE is therefore rejected at the preflight unless you list the method, while the same code works on Express. Always set methods explicitly when your API is called from another origin with a method outside the safelist; both packages read it.

Pitfalls

  • origin: '*' (the default) cannot be combined with credentials: true by browsers; list the origins instead.

  • An allow-list read from configuration should hold exact origins (https://app.example.com), not host names.

  • CORS and CSRF are independent: origins allowed by CORS are not trusted by the CSRF protection below.

  • CORS configured through NestFactory.create(…​, { cors }) is registered after enableCsrfProtection(), so a CSRF 403 does not carry CORS headers and a cross-origin client sees an opaque failure rather than the 403 body.

CSRF protection

Cross-site request forgery makes a victim’s browser send a state-changing request to your application from another site; the browser attaches the victim’s cookies, and the server cannot tell the request from a deliberate one. NestJS 12.1 ships protection that needs no token, cookie, session or client change. It relies on headers that browsers send themselves.

How the built-in check decides

app.enableCsrfProtection() rejects state-changing requests that the browser reports as cross-origin, following the algorithm of Go 1.25’s CrossOriginProtection:

  1. GET, HEAD and OPTIONS are always allowed.

  2. If Sec-Fetch-Site is present, same-origin and none (a navigation the user started) are allowed; every other value, including same-site, is rejected.

  3. If neither Sec-Fetch-Site nor Origin is present, the request is allowed: it does not come from a browser.

  4. Otherwise the host of Origin must match the request’s Host header (:authority on HTTP/2), ignoring case and the default port. X-Forwarded-Host is ignored.

A request that would be rejected is still allowed when it matches an excluded route or when its Origin is a trusted origin.

Origin-based protection with a trusted origin and one excluded route (setup.ts)
export function configureCsrf(app: INestApplication) {
  const config = app.get(ConfigService);
  const trusted = (config.get<string>('TRUSTED_ORIGINS') ?? '')
    .split(',')
    .map((origin) => origin.trim())
    .filter(Boolean);

  app.setGlobalPrefix('api');
  app.useSecurityHeaders();
  app.enableCsrfProtection({
    trustedOrigins: trusted,
    exclude: [{ path: 'webhooks/payments', method: RequestMethod.POST }],
  });
}

With TRUSTED_ORIGINS=https://admin.example.com, the results were identical on Express and Fastify:

Request Status

POST /api/books, no Sec-Fetch-Site and no Origin (curl, server to server)

201

POST /api/books with Sec-Fetch-Site: same-origin

201

POST /api/books with Sec-Fetch-Site: cross-site and Origin of another site

403

POST /api/books with Sec-Fetch-Site: same-site

403

POST /api/books with Sec-Fetch-Site: same-site and Origin: https://admin.example.com (trusted)

201

POST /api/books, no Sec-Fetch-Site, Origin not matching Host (old browser)

403

POST /api/books, no Sec-Fetch-Site, Origin matching Host

201

POST /api/webhooks/payments with Sec-Fetch-Site: cross-site (excluded)

200

DELETE /api/books/1 with Sec-Fetch-Site: cross-site

403

GET /api/books with Sec-Fetch-Site: cross-site

200

POST /api/nothing with Sec-Fetch-Site: cross-site (no such route)

403

The rejection is a ForbiddenException:

{
  "message": "Cross-origin request detected from Sec-Fetch-Site header",
  "error": "Forbidden",
  "statusCode": 403
}

and without Sec-Fetch-Site the message reads Cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match Host. The x-frame-options header and the rest were present on the 403, because the security headers are written first. Global exception filters apply to the rejection (see Exception Filters & Error Handling); controller-scoped ones do not, because routing has not run.

Options, trusted origins and exclusions

The options object accepts only trustedOrigins and exclude; anything else throws at startup. Passing the misspelled trustedOrigin produced:

Invalid CSRF protection options: unknown option "trustedOrigin" (expected "trustedOrigins", "exclude").
  • trustedOrigins lists origins that may send cross-origin, state-changing requests, such as an admin panel on a sibling subdomain. Each entry is a serialized origin scheme://host[:port]: no path (not even /), query, fragment, credentials or wildcards. Entries are normalized like the Origin header (lower case, default port dropped), and a request is exempt only when its Origin equals one entry exactly. A frontend allowed by CORS is not trusted automatically; if it sends state-changing requests with credentials, list it here too.

  • exclude lists routes that skip the check, as a path (all methods) or a RouteInfo object with path, method and optional version, declared without the global prefix. Use it for a sender that sets a foreign Origin, such as a webhook, or an identity provider that returns the user with a cross-site form POST (SAML, or OAuth’s form_post mode). Exclusions are resolved at initialization, adding the global prefix (see Versioning & Global Prefix) and, for URI versioning, the version segment of the exclusion’s version; the default version is not applied. Matching is exact and fails closed: it is case-sensitive, a trailing slash does not match unless you write webhooks/payments{/}, and paths containing //, . or .. segments, ;, #, \ or encoded ., /, \ never match. On Fastify, exclusions are matched before rewriteUrl runs.

  • exclude can also be a predicate that receives the raw platform request and returns true to skip the check. None of the matching rules apply to it, so a predicate that tests request.url.startsWith('/public/') also skips /public/../account. Prefer a route list.

A webhook endpoint should still authenticate the sender, typically with a signature; see Webhooks. Excluding it from CSRF only removes an origin check that would reject a legitimate sender.

Limitations

  • It is not a token scheme: it relies on Sec-Fetch-Site, which browsers send only to secure origins (HTTPS or localhost), or on Origin.

  • GET, HEAD and OPTIONS are never checked, so those handlers must not change state.

  • The Origin/Host fallback does not know the scheme, so for browsers that send no Sec-Fetch-Site a request from http://example.com to https://example.com is allowed. The HSTS header that useSecurityHeaders() sends by default mitigates this.

  • Behind a proxy that rewrites Host, browsers without Sec-Fetch-Site hit the fallback and get 403. Preserve Host at the proxy, or add your public origin to trustedOrigins.

  • WebSocket upgrade requests are not checked. If a gateway authenticates with cookies, validate the handshake’s Origin yourself.

  • Non-browser clients (mobile apps, CLIs, other servers) carry neither header and pass, which is correct: CSRF abuses credentials that a browser attaches automatically. Evergreen browsers have sent Sec-Fetch-Site since 2023; browsers that send neither header look like non-browser clients and are allowed.

Token-based CSRF protection

When you must protect users of browsers that send neither header, or you want defence in depth, add a token scheme: the server hands the client a token, stores a secret in a cookie or session, and rejects state-changing requests that do not send the matching token, typically in a header. It needs client changes and a platform-specific package. The secret comes from configuration, never from source code (generate one with openssl rand -hex 32).

Express: csrf-csrf

csrf-csrf implements the double-submit-cookie pattern. Its doubleCsrf() requires getSecret and getSessionIdentifier; it needs cookie-parser or a session middleware registered first.

Double-submit CSRF protection on Express (token-express.ts)
import cookieParser from 'cookie-parser';
import { doubleCsrf } from 'csrf-csrf';
import type { Request } from 'express';

export function useDoubleSubmitCsrf(app: NestExpressApplication) {
  const config = app.get(ConfigService);
  const secret = config.getOrThrow<string>('CSRF_SECRET');

  const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
    getSecret: () => secret,
    // Bind the token to whatever identifies the visitor, here a session cookie.
    getSessionIdentifier: (req: Request) => req.cookies?.['sid'] ?? '',
    cookieName: 'csrf',
    cookieOptions: { sameSite: 'lax', secure: false, httpOnly: true },
    getCsrfTokenFromRequest: (req: Request) => req.headers['x-csrf-token'],
  });

  app.use(cookieParser());
  app.use('/csrf-token', (req: Request, res: any) => {
    res.json({ token: generateCsrfToken(req, res) });
  });
  app.use(doubleCsrfProtection);
}

secure: false is only for the local test; in production keep the default (secure: true) behind HTTPS. A client first calls GET /csrf-token, which sets the csrf cookie and returns the token, then sends it as x-csrf-token. In the test, a POST /books without the token returned 403 with {"statusCode":403,"message":"invalid csrf token"}, GET /csrf-token returned 200 with a string token and a csrf cookie, and the POST with the header returned 201.

curl -c jar -b jar http://localhost:3000/csrf-token
curl -b jar -H 'x-csrf-token: <token>' -H 'content-type: application/json' \
  -d '{"title":"Dune"}' http://localhost:3000/books

Fastify: @fastify/csrf-protection

The plugin needs a storage plugin registered first (@fastify/cookie, @fastify/session or @fastify/secure-session) and adds reply.generateCsrf() and a csrfProtection hook function. Its hook checks every method it is attached to, so wrap it to let the safe methods through.

Token protection on Fastify (token-fastify.ts)
import fastifyCookie from '@fastify/cookie';
import fastifyCsrf from '@fastify/csrf-protection';

export async function useFastifyCsrf(app: NestFastifyApplication) {
  const config = app.get(ConfigService);

  await app.register(fastifyCookie, {
    secret: config.getOrThrow<string>('COOKIE_SECRET'),
  });
  await app.register(fastifyCsrf, {
    cookieOpts: { signed: true, httpOnly: true, sameSite: 'lax' },
    getToken: (req) => req.headers['x-csrf-token'] as string | undefined,
  });

  const fastify = app.getHttpAdapter().getInstance();
  fastify.get('/csrf-token', async (_req, reply) => ({
    token: reply.generateCsrf(),
  }));
  fastify.addHook('onRequest', (req, reply, done) => {
    if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
      done();
      return;
    }
    fastify.csrfProtection(req, reply, done);
  });
}

A test shows the same sequence: POST without a token returns 403 (Missing csrf secret), GET /csrf-token sets a _csrf cookie and returns a token, and the POST carrying the header returns 201. Attaching csrfProtection to every request without the wrapper also rejects GET /csrf-token with 403, which is the pitfall the wrapper avoids.

Choosing, and combining

  • Browser-facing application with cookie authentication: enable useSecurityHeaders() and enableCsrfProtection(), set cookies SameSite=Lax or Strict and HttpOnly (see Cookies & Sessions), and add a token scheme only if old browsers matter.

  • API consumed with Authorization: Bearer headers by a single-page application: CSRF is not the threat, since the browser does not attach that header by itself, but CSP, CORS and the other headers still apply.

  • Server-to-server API: none of these matter; authenticate and rate-limit instead (see Rate Limiting & Encryption).

  • Where each fits in a wider review, see Security Best Practices.

Express and Fastify differences

Topic Express Fastify

Built-in useSecurityHeaders() / enableCsrfProtection()

middleware

onRequest hook; same behaviour

Helmet package

helmet, app.use(helmet())

@fastify/helmet, await app.register(…​)

Default CORS methods

GET,HEAD,PUT,PATCH,POST,DELETE

GET,HEAD,POST

Access-Control-Allow-Methods separator

,

, ` when `methods is set

Token CSRF package

csrf-csrf (+ cookie-parser or session)

@fastify/csrf-protection (+ storage plugin)

Removing a header set by useSecurityHeaders() from a route

not covered by the documentation

reply.removeHeader() cannot remove it; override with reply.header() instead

CSRF exclusions

matched against the request URL

matched before rewriteUrl; ignoreTrailingSlash and caseSensitive: false variants are not excluded

See Fastify & HTTP Platforms for the other adapter differences.

Where the book is outdated

Books written for NestJS 9 to 11 describe security as "install helmet, call app.use(helmet()), call app.enableCors(), add `csurf`". Today:

  • csurf is deprecated and unmaintained, and the current Nest documentation shows other options. The built-in origin-based protection is the first choice, and csrf-csrf (Express) or @fastify/csrf-protection (Fastify) are the token alternatives.

  • app.use(helmet()) is no longer the only way: useSecurityHeaders() needs no package, but does not support nonce functions in CSP directives.

  • Snippets that call app.enableCors() and assume the same methods on both platforms are wrong on Fastify.

  • Examples that hardcode a CSRF or cookie secret in main.ts should read it from ConfigService.

What changed recently

  • 12.1.0 (2026-09-23): app.useSecurityHeaders() and app.enableCsrfProtection() were added, together with adapter-agnostic cookies. Both exist in the 12.1.2 packages (their signatures are in @nestjs/common’s `NestApplication interface), and the examples above ran against 12.1.2.

  • On NestJS 12.0 and earlier, use Helmet and a token-based CSRF package; the methods above do not exist there.

  • The CSP default is Helmet 8’s, and the CORS adapter defaults come from cors and @fastify/cors; a major version of either package can change them, so set methods and the CSP directives you rely on explicitly.

Pitfalls

  • Calling the methods after app.init() or app.listen(), or twice, throws.

  • Registering app.use() middleware before useSecurityHeaders() or enableCsrfProtection() lets that middleware answer without headers and without the CSRF check.

  • Putting 'unsafe-inline' or a CDN host in a production CSP to make Swagger UI or GraphiQL work.

  • Relying on HSTS from a plain-HTTP deployment: browsers ignore it there. Terminate TLS in front of the application and keep strictTransportSecurity on.

  • Treating CORS as access control: it only instructs browsers.

  • Checking CSRF on a GET that changes state: safe methods are never checked.

  • Excluding a CSRF route with a predicate on request.url.

  • Hardcoding the CSRF or cookie secret.