Security Best Practices

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, published 2026-09-30), @nestjs/config 12, @nestjs/jwt 12, @nestjs/throttler 6, @nestjs/typeorm 12 with TypeORM 1.1, Node.js 22 and 24, TypeScript 6 (6.0.x), Express 5.2 and Fastify 5, all checked on 2026-10-02. The examples target the Nest 12 ESM template with Vitest and supertest, and the responses shown are what they return. Security is not a feature you add at the end: it is a property that comes from many small decisions, most of which are already mechanisms covered elsewhere in this section. This page does not repeat those mechanisms. It shows how they combine into a layered defence, maps the OWASP API Security Top 10 to the Nest feature that addresses each risk, and covers the topics that belong to no single feature: injection through the data layer, secrets, dependencies, privilege, transport security, testing and incident response. The code belongs to the Bookshelf application that grows across this section, reduced to a small notes API (each note belongs to one user) so that every risk can be demonstrated in a few lines.

Defence in depth

No single control is perfect, so a secure system assumes that any one of them will eventually fail and arranges the others to limit the damage. A request to a Nest application crosses several boundaries, and each one can reject it for a different reason:

flowchart TB net["Network: firewall, TLS termination, reverse proxy"] edge["HTTP edge: security headers, CSRF check, CORS, body size limits"] rate["Throttling: per-client request budget"] authn["Authentication: who is calling (guard)"] authz["Authorization: may they do this (roles, ownership)"] input["Input: whitelist validation and parsing (pipes)"] data["Data layer: parameterised queries, least-privilege database role"] out["Output: serialization drops fields (interceptor)"] ops["Operations: validated secrets, audit log, dependency updates, alerts"] net --> edge --> rate --> authn --> authz --> input --> data --> out ops -.-> net ops -.-> edge ops -.-> authn ops -.-> data

The order mirrors the Nest request lifecycle (see Request Lifecycle): the framework-level hooks that useSecurityHeaders() and enableCsrfProtection() register run before middleware, then guards, interceptors, pipes and the handler. The practical rule is that every layer is written as if the one before it did not exist. A handler that loads a note still checks that the caller owns it, even though a guard already verified the token; a query still uses bound parameters, even though a pipe already validated the string. When an attacker finds a hole in one layer, the next one still has to be defeated.

The remaining sections follow the diagram from the outside in. The authentication and authorization mechanisms themselves have their own pages: Authentication, Authorization and Guards; transport and browser protections are in Security Headers, CORS & CSRF; throttling and encryption are in Rate Limiting & Encryption.

A hardened bootstrap

Nest 12.1 added two methods that turn on the two browser-facing layers without extra packages: app.useSecurityHeaders() (Helmet 8 defaults, same behaviour on Express and Fastify) and app.enableCsrfProtection() (a check of the Sec-Fetch-Site and Origin headers on state-changing requests). Combine them with a global ValidationPipe configured to reject unknown properties:

A single function that applies the global protections
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Env } from './env.schema.js';

export function hardenApp(app: INestApplication): void {
  const config = app.get<ConfigService<Env, true>>(ConfigService);

  // Both calls must come before app.init() / app.listen(), and as early as possible
  app.useSecurityHeaders({
    strictTransportSecurity: { maxAge: 63072000, includeSubDomains: true },
    referrerPolicy: { policy: 'no-referrer' },
  });
  app.enableCsrfProtection({ trustedOrigins: config.get('TRUSTED_ORIGINS') });

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // strip properties the DTO does not declare
      forbidNonWhitelisted: true, // ...and reject the request instead (mass assignment)
      transform: true,
    }),
  );
  app.enableShutdownHooks();
}

Calling the function from main.ts right after NestFactory.create() is enough. A response shows the effect (curl -i against a route without a token):

curl -i http://localhost:3000/notes/1
HTTP/1.1 401 Unauthorized
Strict-Transport-Security: max-age=63072000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer

The headers are present even on the 401, because they are written before routing, and X-Powered-By is gone. A cross-site browser request is refused before any guard or pipe runs:

curl -i -X POST http://localhost:3000/notes \
  -H "Authorization: Bearer $TOKEN" -H "Sec-Fetch-Site: cross-site" \
  -H "Content-Type: application/json" -d '{"title":"x"}'
{ "message": "Cross-origin request detected from Sec-Fetch-Site header", "error": "Forbidden", "statusCode": 403 }

The built-in CSRF check is deliberately not a token scheme: GET, HEAD and OPTIONS are never checked (handlers for them must not change state), WebSocket upgrades are not checked, and non-browser clients such as mobile apps and servers are allowed because they send neither header. An API that authenticates with a bearer token in the Authorization header is not exposed to CSRF in the first place, since browsers do not attach that header on their own; the check matters when you authenticate with cookies (see Cookies & Sessions).

The OWASP API Security Top 10 mapped to NestJS

The OWASP API Security Top 10 (2023 edition, still the current one at the time of writing) lists the ten most common ways HTTP APIs are compromised. Nest does not make an application secure by default, but for each risk there is a specific feature that, used deliberately, closes it:

Table 1. OWASP API Security Top 10 (2023) and the Nest feature that addresses each risk
Risk What goes wrong NestJS feature

API1 Broken object level authorization

A caller changes an identifier and reads or writes another user’s object.

An ownership check in the service or a policy (BOLA: check the object, not just the user); @nestjs/authorization policies (see Authorization). Never trust an ownerId from the body.

API2 Broken authentication

Weak or forgeable credentials, missing token validation, unlimited login attempts.

A global authentication guard with a default-deny @Public() opt-out, token verification with a pinned algorithm and a validated secret, throttling on sign-in routes (see Authentication).

API3 Broken object property level authorization

Excessive data exposure on output and mass assignment on input.

ClassSerializerInterceptor with @Exclude() or schema-based serialization (Excessive data exposure: serialize what you intend); ValidationPipe with whitelist and forbidNonWhitelisted (Mass assignment: whitelist every DTO).

API4 Unrestricted resource consumption

Unbounded requests, payloads, page sizes or expensive operations.

@nestjs/throttler (Resource consumption: budgets per client), bounded @Length() / @Max() DTO rules, take() on queries, upload limits (see File Upload & Streaming).

API5 Broken function level authorization

A regular user reaches an administrative route.

A roles guard reading Reflector.createDecorator() metadata (BFLA: roles on routes, default deny).

API6 Unrestricted access to sensitive business flows

Automated abuse of a legitimate flow (bulk purchases, account creation).

Per-route @Throttle() limits, one-time tokens, and domain rules in services; there is no single decorator for this risk.

API7 Server side request forgery

The server fetches a URL chosen by the caller and reaches internal services.

An allow-list of outbound hosts validated before any HttpService call (SSRF: allow-list outbound targets).

API8 Security misconfiguration

Missing headers, verbose errors, permissive CORS, defaults left on.

useSecurityHeaders(), enableCsrfProtection(), an explicit enableCors() origin list, validated configuration, and exception filters that do not leak stack traces (see Exception Filters & Error Handling).

API9 Improper inventory management

Old API versions and undocumented endpoints stay reachable.

URI versioning with a retirement plan (see Versioning & Global Prefix) and a generated OpenAPI document as the inventory.

API10 Unsafe consumption of APIs

Data from third-party APIs is trusted as if it were validated.

Validate responses of upstream services with the same pipes or schemas you use for requests, and set timeouts on outbound calls (see HTTP Client).

The rest of this section shows runnable examples for the rows that depend on application code rather than on a single switch.

Authentication: a default-deny guard

Authentication is only as strong as its weakest route, so register the guard globally with APP_GUARD and make exceptions explicit. The example verifies a JWT with @nestjs/jwt, pins the algorithm, and takes the signing key from validated configuration (see Secrets management):

A global guard that denies by default
export const Public = Reflector.createDecorator<boolean>();
export const Roles = Reflector.createDecorator<string[]>();

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly jwt: JwtService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    if (this.reflector.getAllAndOverride(Public, [context.getHandler(), context.getClass()])) {
      return true;
    }
    const request = context.switchToHttp().getRequest<AuthedRequest>();
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    if (type !== 'Bearer' || !token) {
      throw new UnauthorizedException();
    }
    try {
      request.user = await this.jwt.verifyAsync<AuthUser>(token);
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }
}

JwtModule.registerAsync({
  global: true,
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    secret: config.getOrThrow<string>('JWT_SECRET'),
    signOptions: { algorithm: 'HS256', expiresIn: '15m' },
    verifyOptions: { algorithms: ['HS256'] },
  }),
}),

The test of this example sends a token signed with a different key and a token whose header declares "alg":"none"; both receive 401. Pinning verifyOptions.algorithms is the part that defeats algorithm-confusion attacks. Short-lived access tokens (15m here), refresh-token rotation, password hashing and sessions are covered by the dedicated authentication page; the book’s hand-written jsonwebtoken flow is the part of its chapter that has been replaced by @nestjs/authentication (pre-1.0) and the long-standing @nestjs/jwt + @nestjs/passport pair.

BOLA: check the object, not just the user

Broken object level authorization is the number one API risk because a valid token proves who the caller is, not which rows they may touch. Check ownership where the object is loaded, and take the owner from the token rather than from the request:

Ownership taken from the token, ownership checked on read
create(user: AuthUser, title: string): Promise<Note> {
  // ownerId comes from the verified token, never from the request body
  return this.notes.save(this.notes.create({ ownerId: user.sub, title }));
}

async findOne(user: AuthUser, id: number): Promise<Note> {
  const note = await this.notes.findOneBy({ id });
  if (!note) {
    throw new NotFoundException();
  }
  if (note.ownerId !== user.sub && !user.roles.includes('admin')) {
    throw new ForbiddenException();
  }
  return note;
}

A second user requesting the first user’s note receives 403, and an administrator receives 200. Returning 404 instead of 403 for foreign objects hides their existence and is a reasonable choice when identifiers are guessable; either way, never skip the check because "the id is a UUID".

BFLA: roles on routes, default deny

Function level authorization decides which routes a role may call. A roles guard registered after the authentication guard reads typed metadata with Reflector:

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride(Roles, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!required) {
      return true;
    }
    const user = context.switchToHttp().getRequest<AuthedRequest>().user;
    if (!user?.roles.some((role) => required.includes(role))) {
      throw new ForbiddenException();
    }
    return true;
  }
}

@Delete(':id')
@Roles(['admin'])
@HttpCode(204)
remove(@Param('id', ParseIntPipe) id: number) {
  return this.notes.remove(id);
}

The destructive route answers 401 without a token, 403 for a regular user and 204 for an administrator, which is the sequence the end-to-end test asserts. The book registers roles with @SetMetadata('roles', …​) and reads them with Reflector.get(); that still works, and Reflector.createDecorator() is the typed form the documentation now prefers (see Guards).

Mass assignment: whitelist every DTO

If a handler copies the request body into an entity, a caller can set fields they should not, such as ownerId or isAdmin. The whitelist option of ValidationPipe removes properties without a validation decorator, and forbidNonWhitelisted turns that silent removal into an error, which also makes probing visible in logs:

export class CreateNoteDto {
  @IsString()
  @Length(1, 120)
  title: string;
}
curl -X POST http://localhost:3000/notes -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"title":"plan","ownerId":"bob"}'
{ "message": ["property ownerId should not exist"], "error": "Bad Request", "statusCode": 400 }

Every property of a DTO needs at least one class-validator decorator for whitelist to keep it, and nested objects need @ValidateNested() plus @Type(). See Pipes & Validation for the options and for the Standard Schema (Zod) alternative that validates the same way.

Excessive data exposure: serialize what you intend

The output side of API3 is returning a whole entity. Mark internal fields with @Exclude() and apply ClassSerializerInterceptor:

@Entity()
export class Note {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  ownerId: string;

  @Column()
  title: string;

  // Never leaves the server: ClassSerializerInterceptor drops it from responses
  @Exclude()
  @Column({ default: '' })
  moderationNotes: string;
}

@Controller('notes')
@UseInterceptors(ClassSerializerInterceptor)
export class NotesController { /* ... */ }

A note with moderationNotes: 'flagged' is returned as:

{ "id": 2, "ownerId": "alice", "title": "secret plan" }

The interceptor only works on class instances: a handler that returns a plain object from a raw query bypasses @Exclude(). Prefer explicit response DTOs for anything that crosses the trust boundary, and see Serialization for the schema-based alternative.

Resource consumption: budgets per client

ThrottlerGuard, registered as a global guard before the authentication guard, rejects excess traffic cheaply, and @Throttle() tightens the limit on an expensive or abuse-prone route:

ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),

@Public()
@Throttle({ default: { limit: 3, ttl: 60_000 } })
@Post('login-attempt')
@HttpCode(401)
attempt() {
  return { ok: false };
}

Four calls in a row produced the status codes 401, 401, 401, 429. Behind a reverse proxy the throttler sees the proxy’s address unless you configure trust proxy (TLS termination), so every client would share one budget. The throttler page covers storage in Redis for several instances, WebSockets and GraphQL.

SSRF: allow-list outbound targets

When an endpoint fetches a URL supplied by the caller (an import, a webhook registration, an avatar), the server can be steered to internal addresses such as a cloud metadata service. Validate the destination against an allow-list read from configuration before making the call:

@Injectable()
export class OutboundPolicy {
  private readonly allowed: Set<string>;

  constructor(config: ConfigService<Env, true>) {
    this.allowed = new Set(config.get('OUTBOUND_HOSTS'));
  }

  assertAllowed(rawUrl: string): URL {
    let url: URL;
    try {
      url = new URL(rawUrl);
    } catch {
      throw new BadRequestException('Invalid URL');
    }
    if (url.protocol !== 'https:' || !this.allowed.has(url.hostname)) {
      throw new BadRequestException('Host not allowed');
    }
    return url;
  }
}

With OUTBOUND_HOSTS set to one partner host, https://api.partner.example/feed is accepted, while http://169.254.169.254/latest/meta-data and https://api.partner.example.evil.test/ are both answered with 400. An exact-match set is deliberate: suffix checks such as endsWith are the usual bypass. A host allow-list does not by itself stop DNS rebinding or redirects to internal addresses, so for high-risk integrations also resolve the name, reject private and link-local ranges, disable automatic redirects and run the outbound traffic through an egress proxy (see HTTP Client).

Injection prevention with ORMs

SQL injection happens when user input becomes part of the SQL text instead of a value bound to it. An ORM prevents it for the queries it builds, because find(), findOneBy() and the query builder send values as bound parameters. The protection disappears the moment you concatenate strings into SQL, which is easiest to do in raw queries and in the string forms of where(). The three variants side by side:

// Safe: the value travels as a bound parameter, never as part of the SQL text
searchSafe(user: AuthUser, term: string): Promise<Note[]> {
  return this.notes
    .createQueryBuilder('note')
    .where('note.ownerId = :owner', { owner: user.sub })
    .andWhere('note.title LIKE :term', { term: `%${term}%` })
    .take(50)
    .getMany();
}

// Vulnerable on purpose: user input concatenated into the SQL text
searchUnsafe(term: string): Promise<Note[]> {
  return this.dataSource.query(`SELECT * FROM note WHERE title LIKE '%${term}%'`);
}

// Safe raw SQL: placeholders plus a parameter array
searchRawSafe(term: string): Promise<Note[]> {
  return this.dataSource.query('SELECT * FROM note WHERE title LIKE ?', [`%${term}%`]);
}

Source: NestJS: TypeORM

With a second user’s note in the database, the search term zzz' OR 1=1 -- returns every row from the concatenated query (all three rows) and none from the two parameterised variants. The placeholder syntax depends on the driver (? for SQLite and MySQL, $1 for PostgreSQL); the principle does not. Four habits keep the data layer safe:

  • Never build SQL, ORDER BY columns or LIMIT values from raw input. Identifiers cannot be bound as parameters, so map user choices to a fixed set (an enum or a lookup table) before using them.

  • Validate input at the boundary with a whitelist ValidationPipe, so a search term is a short string and an id is an integer (ParseIntPipe). Validation is a second layer, not a replacement for parameters.

  • Be careful with objects: passing an unvalidated request body straight to find({ where: body }) lets a caller choose operators and columns. With MongoDB, the equivalent risk is operator injection ({ "$ne": null }), which a DTO with @IsString() rejects (see MongoDB with Mongoose).

  • The same holds for every ORM Nest supports (Prisma’s $queryRaw tagged template binds values; $queryRawUnsafe does not). See Data Overview & TypeORM and Prisma, Drizzle, MikroORM & Sequelize.

Injection is not only SQL. Cross-site scripting is prevented by escaping output in the template engine or client framework and by the Content-Security-Policy header; useSecurityHeaders() sends Helmet’s default policy, which you tighten for your front end (see Security Headers, CORS & CSRF). Never pass user input to child_process.exec(), eval() or new Function(); use execFile() with an argument array when a subprocess is unavoidable.

Secrets management

A secret in source control is a secret that has leaked. The rule is simple: secrets live in the environment (or in files mounted by the platform), are read through ConfigService, and are validated at startup so a missing or weak value stops the application instead of running with a default. Declare the schema once, with no default for required secrets:

Environment schema with Zod (Standard Schema)
import { z } from 'zod';

export const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  // At least 256 bits of hex (openssl rand -hex 32 gives 64 characters)
  JWT_SECRET: z.string().min(32),
  // Comma-separated list of browser origins allowed to call state-changing routes
  TRUSTED_ORIGINS: z
    .string()
    .default('')
    .transform((value) => value.split(',').map((origin) => origin.trim()).filter(Boolean)),
});

export type Env = z.infer<typeof envSchema>;

ConfigModule.forRoot({ isGlobal: true, validationSchema: envSchema }),

Starting the module with JWT_SECRET shorter than 32 characters fails during bootstrap with an error naming the variable, which is the behaviour the test asserts. Generate values with openssl rand -hex 32 and inject them per environment:

export JWT_SECRET="$(openssl rand -hex 32)"
node dist/main

Habits that go with the schema:

  • Keep .env files out of Git (.gitignore) and out of container images (.dockerignore); commit an .env.example with names and no values. In containers and CI, inject real environment variables or mounted secret files from the platform’s secret store instead of shipping a file (see Configuration for the lookup order and registerAs).

  • Read secrets through ConfigService.getOrThrow() rather than process.env scattered through the code, so a missing value fails loudly and tests can substitute a value.

  • Scan the repository for committed credentials (for example with detect-secrets or a pre-commit hook) and rotate any secret that has ever been committed, even if the commit was later reverted.

  • Never log secrets or whole request bodies; redact Authorization and cookie headers in access logs (see Logging).

  • Rotate on a schedule and on every suspected exposure; support two valid signing keys for a transition period if tokens are long-lived.

Dependencies and npm audit

A Nest application typically installs hundreds of transitive packages, and most vulnerabilities reach production through them rather than through your code. Audit regularly and in CI:

npm audit                          # report known vulnerabilities in the dependency tree
npm audit --omit=dev --audit-level=high   # production dependencies only, fail on high severity
npm audit fix                      # apply compatible fixes; review the diff
npm audit signatures               # verify registry signatures and provenance
npm ci                             # reproducible install from package-lock.json in CI and images

Source: npm: npm-audit

For a fresh Nest 12 project on 2026-10-02, npm audit --omit=dev --audit-level=high reports found 0 vulnerabilities. Treat that as a point-in-time answer: advisories appear daily. Run the audit on every pull request and on a schedule, enable an update bot (Dependabot or Renovate) for pull requests that bump patched versions, commit the lock file, keep Node.js on a supported LTS line, and prefer few, well-maintained dependencies. The @nestjs/* packages are versioned together, so security fixes arrive as patch releases of the line you run (v12.x, with v11.2.x still maintained); upgrade the whole set at once. Also add the container image to the review: see Image Best Practices & Security.

Least privilege

Give every actor (user, service, process, database role) only the access it needs, so a compromise of one is contained.

  • Users: default-deny guards and narrow roles (Authentication: a default-deny guard, BFLA: roles on routes, default deny). Prefer permissions over a few broad roles when the rules get complicated (see Authorization).

  • Database: the application connects with a role that can read and write its own tables, not one that owns them or can alter the schema. Run migrations with a separate, more privileged role. In PostgreSQL:

    CREATE ROLE bookshelf_app NOLOGIN;  -- add LOGIN and a password from your secret store in a real deployment
    GRANT SELECT, INSERT, UPDATE ON note TO bookshelf_app;
    GRANT USAGE ON SEQUENCE note_id_seq TO bookshelf_app;

    On PostgreSQL, a session using that role can insert rows, while DELETE FROM note fails with permission denied for table note and DROP TABLE note with must be owner of table note. A successful SQL injection is then limited to what the role may do.

  • Process: run the container as a non-root user, with a read-only root filesystem where possible, and without the Docker socket or host mounts (see Image Best Practices & Security).

  • Credentials: give each service its own token or key with the minimum scope, so a leak can be revoked without an outage. Do not reuse the JWT signing secret for anything else.

  • Network: the database and cache are not reachable from the internet; only the reverse proxy is.

TLS termination

All traffic must use HTTPS, including internal hops that carry credentials. There are two usual shapes. Most deployments terminate TLS at a reverse proxy or load balancer (nginx, a cloud load balancer, a Kubernetes ingress) and run Nest over plain HTTP on a private network. Then the application must be told to trust the proxy, otherwise request.protocol is always http and request.ip is the proxy’s address (which also breaks per-client throttling):

const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy', 'loopback'); // trust only the proxy hop(s) you operate

A request carrying X-Forwarded-Proto: https and X-Forwarded-For: 203.0.113.7 produces {"protocol":"http","secure":false,"ip":"127.0.0.1"} without the setting and {"protocol":"https","secure":true,"ip":"203.0.113.7"} with it. Trust only the addresses you operate: trusting everything lets any client forge X-Forwarded-For. With Fastify, pass the trustProxy option to FastifyAdapter.

The second shape is to terminate TLS in Node.js with httpsOptions, which is simple but leaves certificate renewal and cipher policy to you. Read the key and certificate from files whose paths come from the environment:

import { readFileSync } from 'node:fs';

export function httpsOptionsFromEnv(env: NodeJS.ProcessEnv): { key: Buffer; cert: Buffer } | undefined {
  const { TLS_KEY_FILE, TLS_CERT_FILE } = env;
  if (!TLS_KEY_FILE || !TLS_CERT_FILE) {
    return undefined; // plain HTTP behind a TLS-terminating proxy
  }
  return { key: readFileSync(TLS_KEY_FILE), cert: readFileSync(TLS_CERT_FILE) };
}

const app = await NestFactory.create<NestExpressApplication>(AppModule, {
  httpsOptions: httpsOptionsFromEnv(process.env),
});

With a self-signed development certificate generated by openssl req -x509, /whoami is served over HTTPS and answers 200. On the Fastify adapter use new FastifyAdapter({ https: httpsOptions }). Whichever shape you choose, send Strict-Transport-Security (the strictTransportSecurity option of useSecurityHeaders(), shown in A hardened bootstrap) so browsers refuse plain HTTP afterwards, redirect HTTP to HTTPS at the proxy, and allow only TLS 1.2 and 1.3. HSTS also reduces the weakness of the built-in CSRF check on very old browsers. Man-in-the-middle attacks are what TLS defends against; the other half is validating certificates on outbound calls, so never disable verification (rejectUnauthorized: false, or NODE_TLS_REJECT_UNAUTHORIZED=0, which the test above sets only for its own self-signed certificate).

Security testing

Security properties are testable, and the examples on this page are the proof: each risk has an end-to-end test that fails if the protection is removed. Write tests for the negative cases, since a feature test only shows that the legitimate caller succeeds:

it('enforces object-level and function-level authorization', async () => {
  const note = await repo.save(repo.create({ ownerId: 'alice', title: 'mine' }));
  const url = `/notes/${note.id}`;
  expect((await request(app.getHttpServer()).get(url).set('Authorization', `Bearer ${bob}`)).status).toBe(403);
  expect((await request(app.getHttpServer()).get(url).set('Authorization', `Bearer ${admin}`)).status).toBe(200);
  expect((await request(app.getHttpServer()).delete(url)).status).toBe(401);
  expect((await request(app.getHttpServer()).delete(url).set('Authorization', `Bearer ${alice}`)).status).toBe(403);
  expect((await request(app.getHttpServer()).delete(url).set('Authorization', `Bearer ${admin}`)).status).toBe(204);
});

The tokens come from the same JwtService the application uses, with a secret generated for the test run (randomBytes(32).toString('hex')), so the test suite never needs a real credential. Build the pyramid in layers:

  • Unit and end-to-end tests for guards, ownership rules, whitelist validation, serialization and throttling, as above. Run hardenApp() in the test application, otherwise the tests exercise a different pipeline from production.

  • Static and dependency checks in CI: npm audit, a secret scanner, a linter with security rules, and an image scanner on the built container.

  • Dynamic scanning: run an automated scanner such as OWASP ZAP against a staging deployment, which finds missing headers, reflected input and verbose errors. Fuzz DTO boundaries (empty, huge, wrong-typed and nested values) to confirm that validation answers 400, never 500.

  • Penetration testing: have an independent person attack the deployed system periodically and after major changes. Automated tools find known patterns; people find broken business logic (API6), chained weaknesses and authorization gaps between services.

Incident response

Preparation decides how an incident goes. You cannot investigate what you did not record, so log security-relevant events (authentication failures, authorization failures, validation floods, throttle hits, privilege changes) as structured entries that exclude credentials. An exception filter scoped to the two authorization exceptions is enough to start:

@Catch(UnauthorizedException, ForbiddenException)
export class SecurityEventsFilter implements ExceptionFilter {
  private readonly logger = new Logger('SecurityEvents');

  constructor(private readonly adapterHost: HttpAdapterHost) {}

  catch(exception: UnauthorizedException | ForbiddenException, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request & { user?: { sub: string } }>();
    const status = exception.getStatus();
    this.logger.warn(
      JSON.stringify({
        event: status === 401 ? 'authn_failure' : 'authz_failure',
        method: request.method,
        path: request.path,
        ip: request.ip,
        subject: request.user?.sub ?? null,
      }),
    );
    this.adapterHost.httpAdapter.reply(ctx.getResponse(), exception.getResponse(), status);
  }
}

Registered with APP_FILTER, the filter logs {"event":"authz_failure","method":"DELETE","path":"/notes/1","ip":"127.0.0.1","subject":"bob"} for the refused delete. Ship these lines to central log storage and alert on spikes. When something does go wrong, work through a fixed sequence:

  1. Detect and triage: confirm the alert is real, classify its scope (which data, which accounts) and open an incident record with a timeline.

  2. Contain: revoke the affected tokens or sessions, rotate the exposed secrets (this is where having one validated place for secrets pays off), block the abusing addresses at the proxy, and disable the vulnerable route with a deployment rather than waiting for a complete fix.

  3. Eradicate and recover: patch the root cause, rebuild images from a clean base, restore data from known-good backups if it was altered, and redeploy.

  4. Notify: involve security and legal contacts early. Regulations such as GDPR set deadlines for notifying authorities and affected users after a personal-data breach.

  5. Learn: write a blameless post-mortem, add a regression test for the failing control (the pattern used throughout this page), and update the runbook.

Practise the containment steps before you need them: a secret that cannot be rotated without downtime is a design problem to fix in calm conditions.

Where the book is outdated

The reference book (see the section bibliography) devotes its chapter 18 to security best practices. Its concepts (defence in depth, least privilege, auditing dependencies, incident response) are timeless, but several implementation details have moved on:

  • Its encryption example uses an unauthenticated aes-256-ctr cipher; modern code uses authenticated encryption such as aes-256-gcm (see Rate Limiting & Encryption).

  • It adds Helmet and CSRF middleware packages by hand; since Nest 12.1 the framework offers useSecurityHeaders() and enableCsrfProtection() built in, and the old cookie-and-token CSRF recipes are needed only for very old browsers.

  • It checks roles with @SetMetadata() and Reflector.get(); Reflector.createDecorator() with getAllAndOverride() is the current typed form.

  • Its authentication walkthrough is a hand-rolled JWT flow; the documentation now centres on @nestjs/authentication and @nestjs/authorization, both still pre-1.0.

  • Its OWASP discussion refers to the older Top 10 lists; the API-specific 2023 list used here is the current reference for HTTP APIs.

  • Version-specific statements (Node.js, Nest and dependency versions, ESM and CommonJS import style) predate Nest 12; examples here use .js import extensions of the ESM template.

What changed recently

  • Nest 12.1 (2026-09-23) added built-in security headers with Helmet 8 defaults and built-in CSRF protection based on Fetch Metadata. Both throw on invalid options when called, so a misspelled option fails at startup. Helmet itself (and @fastify/helmet on Fastify) remains supported if you prefer it.

  • The 2026 documentation restructure moved the security material into a Security chapter (authentication, authorization, encryption and hashing, security headers, CORS, CSRF protection, rate limiting).

  • @nestjs/config validation now accepts any Standard Schema library (Zod, Valibot, ArkType); Joi must be v18 or later.

  • @nestjs/authentication and @nestjs/authorization are new first-party packages, pre-1.0 (0.0.x) at the time of writing, so pin them and expect breaking changes; @nestjs/jwt and @nestjs/passport are the established alternative.

  • v12 core packages ship as ESM, which affects how security libraries are imported and mocked in tests.