Skip to content

Security Best Practices

Aman Kumar Singh9 min read
Part 38 of 40From the NestJS Production Guide series
Security Best Practices — article by Aman Kumar Singh

Last time, we covered Integration and E2E Testing: making sure the modules we'd built actually work together, not just in isolation. Tests catch broken behavior. They don't catch a missing authorization check, a header that leaks your framework version, or a dependency with a known CVE sitting in node_modules. That's a different kind of correctness, and it's the subject of this one.

This is part of the NestJS Production Guide series, and security sits at the end of the foundation phase for a reason. Every earlier article assumed a request that behaves. This one is about the requests that don't.

I won't cover every OWASP category here; that's a checklist, not a guide, and checklists age badly. Instead I'll walk through the decisions that actually determine whether a NestJS API holds up under real traffic: headers and CORS, how validation and authorization interact, secrets management, and dependency hygiene. Rate limiting got its own article already, so I'll only touch it where it overlaps.

Locking down HTTP headers and CORS

The default Express or Fastify response in a fresh NestJS app sends more information than it should: an X-Powered-By header announcing the framework, no Content-Security-Policy, and permissive defaults that assume you'll configure things yourself. Most of this is a single dependency away from being fixed.

npm install helmet
// src/main.ts
import { NestFactory } from '@nestjs/core';
import helmet from 'helmet';
import { AppModule } from './app.module';

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

  app.use(
    helmet({
      contentSecurityPolicy: process.env.NODE_ENV === 'production',
    }),
  );

  await app.listen(3000);
}
bootstrap();

helmet sets a batch of headers that close off cheap, well-known attack surface: it strips X-Powered-By, adds X-Content-Type-Options: nosniff to stop MIME sniffing, and sets a reasonable Strict-Transport-Security policy. None of this is exotic, and none of it prevents anything dramatic on its own. It removes low-effort reconnaissance and a few classes of attack that rely on a browser guessing content types incorrectly.

CORS deserves more thought, because it's the one control that directly decides who can call your API from a browser at all.

// src/main.ts
app.enableCors({
  origin: (process.env.CORS_ALLOWED_ORIGINS ?? '').split(','),
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
});

The tempting shortcut is origin: '*', and it's fine for a fully public, read-only API with no cookies involved. It becomes a mistake the moment credentials: true is in play, since a wildcard origin combined with credentials effectively lets any website make authenticated requests on a logged-in user's behalf. Keep the allow-list explicit and driven by an environment variable, so staging and production can differ without a code change.

Validation and sanitization at the boundary

Every article in this series has used DTOs with class-validator decorators, and that habit does more security work than it gets credit for. Input validation is the cheapest layer of defense against unexpected types, unexpected object shapes, and plain application bugs from data that doesn't match what the handler assumed.

// src/main.ts
import { ValidationPipe } from '@nestjs/common';

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

whitelist: true strips any property not declared on the DTO, and forbidNonWhitelisted: true rejects the request outright if one shows up. Without this pairing, a client can send extra fields that never appear in your DTO, and if that DTO gets spread into a database update later in the code, those fields can silently override values they were never meant to touch. That's the exact shape of a mass-assignment vulnerability, and it's a one-line fix.

// src/modules/users/dto/update-user.dto.ts
import { IsString, IsOptional, MaxLength } from 'class-validator';

export class UpdateUserDto {
  @IsString()
  @MaxLength(120)
  @IsOptional()
  name?: string;

  @IsString()
  @MaxLength(500)
  @IsOptional()
  bio?: string;

  // no "role" or "isAdmin" field here, so it can never
  // be set through this endpoint, even if a client sends it
}

Notice what's absent from that DTO: no role, no isAdmin, nothing a user should never be able to set about themselves. Global whitelisting only helps if the DTO itself doesn't expose the field in the first place. A "self-service profile update" endpoint and an admin endpoint should never share a DTO, even when the underlying entity is the same.

Sanitization is a separate concern from validation, and it matters most for any field rendered as HTML later, such as a bio or comment field in a React frontend. class-validator confirms a string is a string; it does nothing about a <script> tag inside that string. Sanitize such fields on write with a library like sanitize-html, or lean on your frontend's default escaping. React escapes by default, which is one more reason not to reach for dangerouslySetInnerHTML without a specific reason.

Authorization: checking the right thing, in the right place

Authentication answers "who is this." Authorization answers "is this person allowed to do this to this specific resource," and it's the layer where most real-world bugs live, because it's easy to check the first half and assume the second follows automatically.

A guard that confirms a valid JWT tells you the request comes from a real, logged-in user. It says nothing about whether that user owns the resource they're about to modify.

// src/modules/invoices/invoices.controller.ts
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { InvoicesService } from './invoices.service';

@UseGuards(JwtAuthGuard)
@Controller('invoices')
export class InvoicesController {
  constructor(private readonly invoices: InvoicesService) {}

  @Get(':id')
  async findOne(
    @Param('id') id: string,
    @CurrentUser() user: { id: string; organizationId: string },
  ) {
    // JwtAuthGuard confirms the token is valid.
    // This line confirms the invoice belongs to the caller's org.
    return this.invoices.findOneForOrganization(id, user.organizationId);
  }
}

Checking ownership as part of the query, rather than as a separate if statement after fetching, is worth making a habit. findOneForOrganization should filter by organization ID in the SQL itself, not fetch the invoice and then compare IDs in application code. Filtering in the query means a bug elsewhere can't accidentally return another organization's row, because the row was never fetched in the first place. Comparing after the fetch is riskier. The data has already left the database before the authorization check runs, and any code path that skips the comparison (a new endpoint, a background job reusing the service method) inherits the hole silently.

For anything beyond simple ownership checks, a guard centralizes role-based logic instead of scattering if statements across controllers:

// src/common/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';

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

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (!requiredRoles) return true;

    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.includes(user?.role);
  }
}

The tradeoff with a generic roles guard is that it handles coarse role checks well and resource-level ownership poorly, which is why the invoice example above still needs its own query-level check even with a roles guard in place. Don't let one mechanism convince you it covers both.

Secrets, configuration, and what never belongs in a repo

Every project accumulates secrets: database credentials, JWT signing keys, third-party API keys, webhook signing secrets. The rule that matters more than any specific tool is simple: nothing that grants access should live in source control, ever, including a config file committed "just this once" with a real value.

// src/config/env.validation.ts
import { plainToInstance } from 'class-transformer';
import { IsString, IsNumber, validateSync, MinLength } from 'class-validator';

class EnvironmentVariables {
  @IsString()
  @MinLength(32)
  JWT_SECRET!: string;

  @IsString()
  DATABASE_URL!: string;

  @IsNumber()
  PORT!: number;
}

export function validate(config: Record<string, unknown>) {
  const validated = plainToInstance(EnvironmentVariables, config, {
    enableImplicitConversion: true,
  });
  const errors = validateSync(validated, { skipMissingValues: false });

  if (errors.length > 0) {
    throw new Error(`Environment validation failed: ${errors.toString()}`);
  }
  return validated;
}
// src/app.module.ts
import { ConfigModule } from '@nestjs/config';
import { validate } from './config/env.validation';

ConfigModule.forRoot({ validate, isGlobal: true });

Validating environment variables at startup, including a minimum length on JWT_SECRET, catches a specific mistake: deploying with a default or placeholder secret because a variable was never actually set in that environment. Failing fast at boot beats failing quietly the first time a token gets signed with an empty string.

A .env file plus .gitignore is fine for local development. In AWS, prefer Secrets Manager or Parameter Store over environment variables baked into a task definition, since both give you rotation and access auditing without rebuilding the container. I'd defer the more elaborate setup until it's needed: start with .env files, and move to a secrets manager once rotation or multi-environment auditing actually becomes a requirement.

One more habit: never log a request body or header wholesale in a catch-all error handler. It's the easiest way for a password or token to end up in a log aggregator with a retention policy nobody thought through. Redact known-sensitive fields before logging.

Dependency hygiene

A NestJS API's actual attack surface includes every package in its dependency tree, not just the code you wrote. This is easy to deprioritize since it doesn't show up in a code review, and it's a common way real production systems get compromised.

npm audit
# .github/workflows/security-audit.yml
name: Dependency Audit

on:
  pull_request:
  schedule:
    - cron: '0 6 * * 1' # weekly, Monday morning

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm audit --audit-level=high

Running npm audit on every pull request catches new vulnerabilities as they're introduced; running it on a schedule catches vulnerabilities disclosed later in a dependency you haven't touched in months. Both matter, and they catch different things.

--audit-level=high is a deliberate choice, not the strictest setting available. Failing CI on every low-severity advisory turns the check into noise that gets ignored or disabled entirely. Set the bar at a severity worth stopping a merge for, and revisit it periodically.

Automated tools like GitHub's Dependabot or Snyk extend this by opening pull requests for vulnerable dependencies automatically, turning "someone has to remember to check" into "someone has to merge a PR that already exists." That shift is usually the difference between updates happening and piling up for a year.

Key takeaways

  • Set security headers with `helmet` and configure CORS with an explicit origin allow-list; a wildcard origin combined with credentials effectively opens authenticated requests to any website.
  • Global validation with `whitelist: true` and `forbidNonWhitelisted: true` closes mass-assignment gaps, but only if sensitive fields (roles, permissions) are also absent from the DTO itself.
  • Authorization needs to check ownership as part of the database query, not as a comparison after the row has already been fetched.
  • Validate environment variables at startup so a missing or placeholder secret fails the deploy instead of failing silently in production.
  • Prefer a secrets manager (AWS Secrets Manager, Parameter Store) over baked-in environment variables once rotation or multi-environment auditing becomes a real requirement; don't add that complexity before it's needed.
  • Run dependency audits on every PR and on a schedule, and set automated tools like Dependabot to open PRs for known vulnerabilities rather than relying on someone remembering to check.

Frequently asked questions

Do I need a Web Application Firewall if I already have rate limiting and validation in NestJS?

They solve different problems. A WAF or CDN-level protection absorbs large or distributed attacks before they reach your NestJS process. Application-level rate limiting and validation protect the logic and data once a request has already arrived. Larger deployments typically want both.

Is helmet enough for header security, or do I need a custom Content-Security-Policy?

Helmet's defaults cover the common cases well, but a CSP is inherently specific to your app: which scripts, styles, and origins you actually load. It's a reasonable starting point; a strict CSP tailored to your frontend's actual resources closes more gaps.

How do I handle secrets in a Docker Compose local development setup versus production?

Locally, a `.env` file excluded from version control is fine, since developer convenience is the priority. In production, prefer Secrets Manager or Parameter Store injected at container start, so a secret's value is never baked into an image layer.

What's the difference between authentication middleware and an authorization guard in NestJS?

Authentication confirms who the request is from, usually via a JWT strategy, and populates `request.user`. Authorization decides what that user is allowed to do, and should check both role and resource ownership. It's easy to implement the first without the second, which is where most real authorization bugs come from.

Should validation happen on the frontend, the backend, or both?

Both, for different reasons. Frontend validation is for user experience: immediate feedback without a round trip. Backend validation is the actual security boundary, since a request can always bypass the frontend entirely. Never treat frontend validation as sufficient on its own.

How often should dependency audits actually catch something in practice?

That depends heavily on how many dependencies a project has and how actively they're maintained. The reason to run audits on a schedule, not just on PRs, is that a package can go from clean to vulnerable after a new advisory is published with no code change on your side at all.

Related articles

Rate Limiting and Throttling — Aman Kumar Singh
API Documentation with Swagger — Aman Kumar Singh
Sending Emails from NestJS — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.