# Security review checklist for NestJS applications

> Source: https://learn-nestjs.com/review/security/
> Part of Learn NestJS, free to read.

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](/review/failure-modes/) covers the recurring bugs; this is the security-specific pass, and the underlying [TypeScript](https://learn-typescript.org/review/security/) and [Node](https://learn-javascript.org/review/security/) 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.

```ts 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:

| Missing | Consequence |
|---|---|
| `whitelist: true` | mass assignment |
| `ClassSerializerInterceptor` | `@Exclude()` does nothing; secrets serialise |
| `helmet()` | no CSP, no HSTS, no frame protection |
| explicit CORS origin | `origin: 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](/review/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.

```bash
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.

```bash
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.

```bash
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

```bash
# 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.

:::verdict 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.
