Skip to content
ExponaBLOG
NESTJS · DAY 074 / 100

NestJS security checklist: practical tips and trade-offs

The defaults I set on every production API.

Expona Admin8 min read

Introduction

Every production API starts with the same question: what can go wrong? We've all felt the weight of a security incident—whether it's a forgotten rate limit causing abuse, unvalidated user input slipping past a gate, or an overlooked dependency vulnerability making headlines. This NestJS security checklist: practical tips and trade-offs captures the defaults we set on every production API, tested across e-commerce, SaaS, and ride-hailing platforms.

Security isn't a feature; it's a foundation. The good news is that NestJS gives us the primitives to build it right from day one. Let's walk through each layer, understand the trade-offs, and implement them without overthinking.

Helmet and CORS — Security headers and explicit allowed origins

Helmet sets HTTP security headers with sensible defaults. CORS locks down which origins can talk to your API. Together, they form your first line of defense.

Start by installing and configuring Helmet in your main.ts:

typescript
import helmet from '@nestjs/helmet';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

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

  // Apply Helmet with defaults
  app.use(helmet());

  // Configure CORS explicitly
  app.enableCors({
    origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 3600,
  });

  await app.listen(3000);
}

bootstrap();

Helmet adds headers like Strict-Transport-Security, X-Content-Type-Options: nosniff, and X-Frame-Options: DENY. These prevent clickjacking, MIME-type sniffing, and downgrade attacks.

CORS is often misunderstood. Allowing all origins with origin: '*' is not a security breach by itself—it just means any website can make requests to your API. However, when credentials: true is set, browsers enforce stricter rules and won't actually send cookies or auth headers for wildcard origins. The real risk is overpermissive origins combined with credential exposure.

Trade-off: Tight CORS rules protect against third-party abuse but require you to maintain an allowlist. In development, this can feel slow. Use environment variables to separate dev and production allowlists.

Validation everywhere — Global ValidationPipe with whitelist and forbidNonWhitelisted

Unvalidated input is the root cause of injection attacks, NoSQL injection, and data corruption. We validate at the handler level, but enforcing it globally prevents human error.

Register a global ValidationPipe in your main.ts:

typescript
import { ValidationPipe } from '@nestjs/common';

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

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

  await app.listen(3000);
}

bootstrap();

Now define your DTOs with class-validator decorators:

typescript
import { IsEmail, IsString, IsOptional, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsString()
  @IsOptional()
  firstName?: string;
}

export class UpdateUserDto {
  @IsEmail()
  @IsOptional()
  email?: string;

  @IsString()
  @IsOptional()
  @MinLength(8)
  password?: string;
}

whitelist: true strips unknown properties; forbidNonWhitelisted: true throws an error if someone sends extra fields. This prevents property pollution attacks where an attacker tries to set admin flags or other internal fields.

transform: true and enableImplicitConversion coerce strings to numbers and booleans when appropriate, reducing type-related edge cases.

Trade-off: Validation is CPU-bound. On high-throughput endpoints, custom faster validators or schema caching can help. For most cases, the security win outweighs the micro-overhead.

Rate limiting — Throttler on auth and public routes

Rate limiting stops brute-force attacks on login endpoints and abuse of public APIs. NestJS provides @nestjs/throttler for easy integration.

Install and configure it:

typescript
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      {
        name: 'short',
        ttl: 60000, // 1 minute
        limit: 10,
      },
      {
        name: 'long',
        ttl: 3600000, // 1 hour
        limit: 100,
      },
    ]),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

Apply different limits to sensitive endpoints:

typescript
import { Controller, Post, Body } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';

@Controller('auth')
export class AuthController {
  @Post('login')
  @Throttle({ short: { limit: 5, ttl: 60000 } })
  async login(@Body() dto: LoginDto) {
    // Login logic
  }

  @Post('register')
  @Throttle({ short: { limit: 3, ttl: 3600000 } })
  async register(@Body() dto: RegisterDto) {
    // Register logic
  }
}

By default, ThrottlerGuard uses the client IP. For requests behind a proxy, configure trust proxy and use the X-Forwarded-For header.

Trade-off: Naive rate limiting can block legitimate users on shared networks. Consider user ID-based limiting for authenticated endpoints and IP-based for public ones. Redis-backed throttling scales better than in-memory stores.

Auth by default — Global JWT guard with an explicit @Public() decorator

Security defaults matter. If every endpoint is public by default, someone will forget to protect a sensitive route. Flip the model: guard everything, then opt-in to public access.

Create a @Public() decorator:

typescript
import { SetMetadata } from '@nestjs/common';

export const Public = () => SetMetadata('isPublic', true);

Create a global JWT auth guard:

typescript
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';

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

  async canActivate(context: ExecutionContext): Promise {
    const isPublic = this.reflector.getAllAndOverride('isPublic', [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException('Missing authorization token');
    }

    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: process.env.JWT_SECRET,
      });
      request.user = payload;
    } catch (error) {
      throw new UnauthorizedException('Invalid token');
    }

    return true;
  }

  private extractTokenFromHeader(request: any): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

Register it globally:

typescript
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],
})
export class AppModule {}

Now mark public endpoints explicitly:

typescript
@Controller('auth')
export class AuthController {
  @Post('login')
  @Public()
  async login(@Body() dto: LoginDto) {
    // Login logic
  }

  @Post('register')
  @Public()
  async register(@Body() dto: RegisterDto) {
    // Register logic
  }

  @Get('profile')
  async getProfile(@Request() req) {
    return req.user;
  }
}

Trade-off: Requiring auth on every endpoint means more overhead, more token refresh logic, and more potential for token leaks. Mitigate by using short-lived access tokens (15 min) and refresh tokens stored in HTTP-only cookies. Monitor token usage and revoke compromised tokens quickly.

Dependency hygiene — npm audit in CI, lockfiles committed, Renovate or Dependabot

Your code is only as secure as your dependencies. A single unpatched vulnerability in a transitive dependency can escalate from obscure to critical overnight.

Set up a CI check for known vulnerabilities:

typescript
// In your GitHub Actions workflow (.github/workflows/ci.yml)
name: CI
on: [push, pull_request]
jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
      - run: npm ci
      - run: npm audit --audit-level=moderate

Commit your lockfile (package-lock.json or yarn.lock) to version control. Lockfiles ensure every developer and CI runner uses the exact same versions. Without them, someone might test against a newer patch that introduced a regression or vulnerability.

Use Renovate or Dependabot to automate dependency updates:

typescript
// renovate.json
{
  "extends": ["config:base"],
  "schedule": ["before 3am on Monday"],
  "vulnerabilityAlerts": {
    "enabled": true,
    "labels": ["security"]
  },
  "automerge": false,
  "groupName": "dependencies",
  "groupSlug": "dependencies"
}

These tools monitor CVE databases and create pull requests for new versions. Pair them with automated tests so you can safely merge low-risk updates.

Trade-off: Aggressive auto-patching can introduce breaking changes. Conservative update policies leave you vulnerable. Balance with a weekly security review and automated tests that catch regressions early.

Common mistakes

  • Forgetting to configure Helmet on production. Developers often test locally without it, then deploy unprotected. Make it mandatory via linting or middleware.
  • Using a single rate limit for all endpoints. Login should be tighter than fetching public data. Tailor limits to intent and risk.
  • Storing secrets in environment variables without rotation. Environment variables are convenient, but they're static. Rotate JWT secrets, database passwords, and API keys regularly, or use a secret manager like AWS Secrets Manager or HashiCorp Vault.
  • Assuming validation at the handler layer is enough. Global validation catches mistakes and improves consistency. Use both.
  • Ignoring CORS during development. Test with actual CORS constraints early. Leaving origin: '*' in dev often leaks into production.
  • Committing lockfiles to .gitignore. This is a security anti-pattern. Always commit lockfiles to ensure reproducible builds.

Key takeaways

  • Apply Helmet and explicit CORS configuration in main.ts to set HTTP security headers and restrict cross-origin requests.
  • Use a global ValidationPipe with whitelist and forbidNonWhitelisted to prevent injection attacks and property pollution.
  • Implement rate limiting on public and sensitive endpoints—especially auth—using @nestjs/throttler with tiered limits.
  • Favor a default-deny auth model: guard everything globally, then opt-in to @Public() for truly open endpoints.
  • Commit lockfiles, run npm audit in CI, and automate dependency updates with Renovate or Dependabot to stay ahead of vulnerabilities.
  • Security is not a one-time checklist. Review and update these defaults as threats evolve and your application scales.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles