Review & Verify Updated 2026-09 11 min read View as Markdown

Security review checklist for NestJS applications

The framework gives you the right places to put security controls. What it will not do is tell you when one is missing — and a missing global is invisible until someone finds it.

Nest's structure is a genuine security asset: guards, pipes, interceptors and filters give every control an obvious home, and APP_GUARD-style global registration means a control applies everywhere by default.

The corresponding weakness is that an absent global looks exactly like a correctly configured one. There is no error, no warning, nothing in the diff. Most of this page is about verifying the things that are supposed to be everywhere.

The failure-mode catalogue covers the recurring bugs; this is the security-specific pass, and the underlying TypeScript and Node checklists still apply.

The bootstrap audit#

Read main.ts first. It is four or five lines and it determines the security posture of the entire application.

src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });

  app.use(helmet());                          // security headers
  app.enableCors({                            // NOT { origin: true }
    origin: ['https://app.example.com'],
    credentials: true,
  });
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,                          // strip undeclared properties
    forbidNonWhitelisted: true,               // or reject outright
    transform: true,
    transformOptions: { enableImplicitConversion: true },
  }));
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
  app.enableShutdownHooks();

  await app.listen(process.env.PORT ?? 3000);
}

Five things to verify, each of which is silently absent in generated bootstraps:

MissingConsequence
whitelist: truemass assignment
ClassSerializerInterceptor@Exclude() does nothing; secrets serialise
helmet()no CSP, no HSTS, no frame protection
explicit CORS originorigin: true reflects any origin, with credentials
enableShutdownHooks()connections leak on every redeploy

enableCors({ origin: true }) deserves singling out. It reflects the requesting origin back, which combined with credentials: true means any website can make authenticated requests as your user. Generated code uses it because it makes local development work.

Authorisation#

The most common real vulnerability in generated back-end code, and no linter finds it. Covered in the failure modes; the essentials:

Guard globally, opt out per route.

ts
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]
ts
@Public()                        // explicit, greppable, deliberate
@Post('auth/login')
login(@Body() dto: LoginDto) {}

The alternative — @UseGuards(JwtAuthGuard) on each controller — fails open. The endpoint someone forgets is public.

shell
grep -rn "@Public()" src         # in a correct app this list is short and reviewable
grep -rn "@UseGuards" src        # and this one is near-empty

Ownership checks live in the service, beside the query.

ts
async findOne(id: string, requester: AuthUser) {
  const inv = await this.repo.findById(id);
  if (!inv || inv.ownerId !== requester.id) throw new NotFoundException();
  return inv;
}

404 rather than 403 for resources the caller may not see — a 403 confirms the id exists.

One test per resource type. It is the highest value-per-line test in the codebase:

ts
it('does not leak another user’s invoice', () =>
  request(app.getHttpServer())
    .get(`/invoices/${bobsInvoice.id}`)
    .set('Authorization', aliceToken)
    .expect(404));

Rate limiting#

Absent from essentially every generated Nest application, and it is the difference between a failed login attempt and a credential-stuffing run.

ts
imports: [
  ThrottlerModule.forRoot([
    { name: 'short', ttl: 1000, limit: 10 },
    { name: 'long', ttl: 60_000, limit: 100 },
  ]),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
ts
@Throttle({ short: { ttl: 60_000, limit: 5 } })   // much tighter on auth
@Public()
@Post('auth/login')
login(@Body() dto: LoginDto) {}

Two things to get right: use a shared store (Redis) rather than in-memory if you run more than one instance, otherwise your limit is multiplied by your replica count. And configure trust proxy correctly, or every request appears to come from your load balancer and the limit applies globally rather than per client.

Input handling#

Mass assignment#

ts
await this.prisma.user.update({ where: { id }, data: { ...dto } });   // never

Even with whitelist: true, pick fields explicitly. Defence in depth, and it survives someone loosening the pipe later.

shell
grep -rn 'data: { \.\.\.' src
grep -rn 'Object.assign(' src

Nested DTOs#

ts
@ValidateNested({ each: true })
@Type(() => OrderLineDto)          // without this, items are NOT validated
lines!: OrderLineDto[];

Silent — no error, validation simply does not happen on the array items. A parameterised test with a deliberately invalid nested object catches it.

File uploads#

ts
@UseInterceptors(FileInterceptor('file', {
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
  fileFilter: (_req, file, cb) => {
    cb(null, ALLOWED_MIME.has(file.mimetype));    // and verify magic bytes after
  },
  storage: diskStorage({
    destination: UPLOAD_DIR,
    filename: (_req, _file, cb) => cb(null, randomUUID()),   // never the client's name
  }),
}))

Three failures in generated upload handlers: no size limit, trusting file.mimetype (client-supplied), and using file.originalname as the filename — which is path traversal and overwriting in one step. Generate the name yourself.

Raw-body webhooks#

ts
// Stripe and most webhook providers sign the RAW body.
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));

If a JSON body parser runs first, the body is re-serialised and the signature no longer verifies — or worse, someone "fixes" it by skipping verification. This belongs in AGENTS.md as a landmine.

Errors and disclosure#

ts
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const status = exception instanceof HttpException
      ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;

    this.logger.error({ err: exception }, 'unhandled');   // full detail to the log

    ctx.getResponse<Response>().status(status).json({     // minimal detail to the client
      statusCode: status,
      message: status === 500 ? 'Internal server error' : (exception as HttpException).message,
      timestamp: new Date().toISOString(),
    });
  }
}

Nest's default filter is reasonable, but a Prisma or TypeORM error escaping to the client leaks table and column names. Catch database errors specifically and map them to generic messages.

Secrets and configuration#

ts
ConfigModule.forRoot({
  isGlobal: true,
  validate: (raw) => EnvSchema.parse(raw),    // fail at boot, not at 2am
})
ts
const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  DATABASE_URL: z.url(),
  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default('15m'),
});

An application that refuses to start on a weak or missing secret is far better than one that starts and fails on the first request needing it. Generated ConfigModule setups almost never validate.

Also: @Exclude() on every secret field, verified mechanically.

shell
grep -rln 'passwordHash\|refreshToken\|apiKey' src | xargs -r grep -L '@Exclude'

GraphQL, if you use it#

ts
GraphQLModule.forRoot({
  playground: false,                               // not in production
  introspection: process.env.NODE_ENV !== 'production',
  validationRules: [depthLimit(7)],                // depth attacks
  plugins: [ApolloServerPluginLandingPageDisabled()],
})

Query depth and complexity limits are the GraphQL-specific denial-of-service control, and generated configurations omit them. Field-level authorisation also needs checking — a guard on the resolver does not protect nested field resolvers.

The review, as commands#

shell
# the bootstrap audit
grep -n 'ValidationPipe\|helmet\|enableCors\|ClassSerializer\|enableShutdownHooks' src/main.ts

# authorisation
grep -rn '@Public()' src
grep -rn '@UseGuards' src
grep -rn 'PrismaService\|Repository<' src --include='*.controller.ts'

# input
grep -rn 'data: { \.\.\.\|Object.assign(' src
grep -rn '@ValidateNested' src | while read -r l; do echo "$l"; done   # check each has @Type
grep -rn 'originalname' src

# secrets
grep -rln 'passwordHash\|refreshToken' src | xargs -r grep -L '@Exclude'
grep -rn 'ConfigModule.forRoot' src | grep -v validate

npm audit --omit=dev

Under a minute, and it covers the mechanical half. What is left for attention is authorisation logic and anything touching money or personal data.

The five that matter most

  1. ValidationPipe with whitelist: true, globally.
  2. Auth guard via APP_GUARD, opt out with @Public() — never opt in.
  3. Ownership checks in the service, returning 404. One test per resource type.
  4. ThrottlerGuard globally, tighter on auth routes.
  5. ClassSerializerInterceptor plus @Exclude() on every secret field.

Common questions#

Does Nest give me security by default?#

It gives you the right places — guards, pipes, interceptors, filters — and sensible behaviour once configured. It does not enable them for you. A generated Nest application typically has none of the five above, and each absence is invisible in review unless you go looking.

Why is rate limiting the most commonly missing control?#

Because nothing fails without it. Validation errors are visible, auth errors are visible, and a missing rate limit produces no symptom at all until someone is running a credential-stuffing attack against your login endpoint.

Should I trust class-validator for security?#

For shape and format, yes, with whitelist: true. It is not a substitute for authorisation or business-rule checks: it verifies that a field is a valid UUID, not that the caller is allowed to reference that UUID. Those checks belong in the service.

Is helmet enough for headers?#

It sets sensible defaults and is a good baseline. The Content-Security-Policy needs tailoring to your application — helmet's default is strict enough to break most front ends, so people disable it entirely rather than configuring it, which is the outcome to avoid.

Get the NestJS agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for NestJS. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.