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.
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; the essentials:
Guard globally, opt out per route.
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]@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.
grep -rn "@Public()" src # in a correct app this list is short and reviewable
grep -rn "@UseGuards" src # and this one is near-emptyOwnership checks live in the service, beside the query.
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:
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.
imports: [
ThrottlerModule.forRoot([
{ name: 'short', ttl: 1000, limit: 10 },
{ name: 'long', ttl: 60_000, limit: 100 },
]),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],@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#
await this.prisma.user.update({ where: { id }, data: { ...dto } }); // neverEven with whitelist: true, pick fields explicitly. Defence in depth, and it survives someone loosening the pipe later.
grep -rn 'data: { \.\.\.' src
grep -rn 'Object.assign(' srcNested DTOs#
@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#
@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#
// 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#
@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#
ConfigModule.forRoot({
isGlobal: true,
validate: (raw) => EnvSchema.parse(raw), // fail at boot, not at 2am
})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.
grep -rln 'passwordHash\|refreshToken\|apiKey' src | xargs -r grep -L '@Exclude'GraphQL, if you use it#
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#
# 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=devUnder 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
ValidationPipewithwhitelist: true, globally.- Auth guard via
APP_GUARD, opt out with@Public()— never opt in. - Ownership checks in the service, returning 404. One test per resource type.
ThrottlerGuardglobally, tighter on auth routes.ClassSerializerInterceptorplus@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.
AGENTS.md now — no email needed.