I inherited a NestJS codebase where /admin/users had been returning the full user list to anyone who sent a well-formed-looking JWT — valid signature or not — for six months. Forging the shape of a token is a one-command job; this was unauthenticated in every way that mattered. Nobody knew. It was one of 47 violations my first ESLint run — 12 seconds — surfaced across 6 security vulnerability classes that had been in production for 2 years.
Not theoretical risks. Real violations: that admin controller with no auth guard, a password field serializing into every user response, a login route with no rate limit. A codebase that had passed CI on every commit. TypeScript strict mode. Two years of code reviews by engineers who cared. And 47 violations that none of it caught.
6 security vulnerability classes. 12 seconds. First lint run. The previous team had shipped these for 2 years without knowing.
That number is why this ran on every inherited service after, on day one, before I read a single line of business logic.
Here's how the 47 broke down across 6 rule classes, on a 40K-line codebase with roughly 80 controllers and service classes:
| Rule | CWE | Count |
|---|---|---|
require-guards | CWE-284 | 11 |
no-exposed-private-fields | CWE-200 | 9 |
no-missing-validation-pipe | CWE-20 | 8 |
require-class-validator | CWE-20 | 7 |
require-throttler | CWE-770 | 7 |
no-exposed-debug-endpoints | CWE-489 | 5 |
| Total | 47 |
Reproducibility (this run). Plugin
eslint-plugin-nestjs-security@1.2.3(current published version is 1.2.4 — ano-missing-null-checksfix unrelated to the six rules here), all six rules aterror(config below). Scanned withnpx eslint "src/**/*.ts"on the inherited service (~40K lines, 2 years of feature PRs, zero prior security passes). The 12-second figure is wall-clock on a single cold run, M2 / Node 20. Counts are from this one codebase — yours will differ, but the class distribution (guards and validation dominate, debug routes are rare-but-fatal) is the part that holds across the NestJS services I've audited.
MTTR estimate: Finding all 47 of these manually — reading every controller for missing guards, every entity for unexcluded fields, every DTO for missing validators — would have taken an experienced engineer 3–5 hours of focused audit work, assuming they knew exactly what to look for. ESLint found them in 12 seconds on a cold run. That gap is why lint — not a manual read — is the only realistic first pass on day one, before you've read the business logic.
If you just inherited a NestJS service and your stomach is now in a knot, run it on yours before reading further — it's one install, full config is below:
npm install --save-dev eslint-plugin-nestjs-security
npx eslint "src/**/*.ts"
Here are all 6 — and exactly why experienced engineers didn't catch each one.
What the code looked like:
// admin.controller.ts
import { Controller, Get, Delete, Param } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Controller('admin')
export class AdminController {
constructor(private readonly usersService: UsersService) {}
@Get('users')
async getAllUsers() {
return this.usersService.findAll();
}
@Delete('user/:id')
async deleteUser(@Param('id') id: string) {
return this.usersService.delete(id);
}
}
Why it survived review: This is the one that still bothers me most. The team had a global JwtAuthGuard — it was real, tested, and documented in the onboarding wiki, registered via { provide: APP_GUARD, useClass: JwtAuthGuard } so Nest could inject its JwtService properly. canActivate() rejected outright — return false, a clean 401 — for any request with no Authorization header or an obviously malformed one, before ever calling jwtService.verify(). Only a structurally well-formed token reached the try { jwtService.verify(token) } catch { return true } beyond it — added early on with a comment reading "verify() throwing here means the token library hiccuped, not that the user is unauthorized." Every test the team had sent either no token (rejected pre-verify(), correctly) or a valid one (accepted, correctly); nobody had a test for "well-formed token, verify() throws anyway," because until that day, no such token had ever existed. Six months later, a JwtModule re-registration during a refactor left JwtService pointed at a secret that no longer matched the one issuing tokens. Now every real token — still structurally well-formed — made verify() throw, straight into the catch block that had always meant "let it through." CI stayed green: nothing in the suite exercised a valid-shaped-but-cryptographically-wrong token, because that token type hadn't existed until the refactor created it. This wasn't scoped to /admin — every route behind that global guard was fail-open the same way, for the same reason: it's a global guard, one class, one catch block. It stayed invisible for six months precisely because it was global — legitimate users kept authenticating with real, freshly-issued tokens that also failed verify() against the wrong secret and also got waved through by the same catch. Nothing broke for anyone, which is exactly why nobody went looking. Any request to /admin/users, forged token or not, returned the full user list.
Why the decorator pattern hides it: When you read a controller in a PR diff, your eye looks for what's there — the route handlers, the service calls, the response shape. A missing decorator is invisible in a diff. There's no red line that says "authentication check removed here." The absence doesn't show up.
What the lint rule catches: require-guards fires on any route-handler method that lacks @UseGuards(...) — either its own or inherited from a class-level decorator — and lacks a @Public() / @SkipAuth() / @AllowAnonymous() / @NoAuth() opt-out. No type inference needed — pure structural, decorator-name analysis. Notice what that means for this exact bug: require-guards doesn't know or care whether app.useGlobalGuards() exists in main.ts — a global guard is invisible to a rule that only reads one controller file at a time. It would have flagged AdminController as missing a local @UseGuards() regardless of the global guard's health. That's the design: it forces every route's guard to be explicit and co-located with the route, so a reviewer checking guard coverage never has to trust a bootstrap file from a distance. It's worth naming the seam honestly, though — this specific bug wasn't a missing guard, it was a broken one. A local @UseGuards(JwtAuthGuard) on AdminController would have satisfied require-guards and still shipped the same fail-open catch. The rule catches the class of bug (unguarded routes); this instance was a guard-logic bug, and no structural AST rule catches a catch block that returns the wrong boolean.
// admin.controller.ts — fixed
import { Controller, Get, Delete, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { UsersService } from '../users/users.service';
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
export class AdminController {
constructor(private readonly usersService: UsersService) {}
@Get('users')
@Roles('admin')
async getAllUsers() {
return this.usersService.findAll();
}
}
What the code looked like:
// user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
email: string;
@Column()
password: string; // hashed, but still in the response
@Column()
refreshToken: string; // full token, rotated monthly
}
// users.controller.ts
@Get(':id')
async getUser(@Param('id') id: string): Promise<User> {
return this.usersService.findOne(id); // entity returned directly
}
Why it survived review: The entity was originally consumed exclusively by an internal gRPC service that deserialized it into a typed struct — stripping unknown fields silently on the client side. No API log, no Datadog response capture, no staging curl that would surface password in the body. The data left the server but never appeared anywhere the team looked. Then a REST endpoint was added three months later and never audited. TypeScript's type system was 100% correct: the types were accurate, the shape matched, the compiler was satisfied. The field name password didn't trigger anything — TypeScript doesn't know or care what data is sensitive. A penetration tester found it by running a raw HTTP client against the REST endpoint.
Why TypeScript types can't catch this: TypeScript operates on shape and type compatibility. password: string is a perfectly valid property. The bug isn't a type error — it's a policy error. Static analysis that understands the semantics of field names is the only automated tool that catches it.
What the lint rule catches: no-exposed-private-fields scans classes it recognizes as entities or DTOs — via @Entity/@Schema/@ObjectType/@ApiProperty-style decorators, or a Dto/Entity/Model/Schema name suffix — for sensitive field name patterns (password, secret, token, apiKey, refreshToken, ssn, creditCard, ...) and flags any that aren't decorated with @Exclude() from class-transformer. A plain class outside that gate isn't scanned, so the entity/DTO naming convention matters.
// user.entity.ts — fixed
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { Exclude } from 'class-transformer';
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
email: string;
@Column()
@Exclude()
password: string;
@Column()
@Exclude()
refreshToken: string;
}
// In main.ts — required for @Exclude() to take effect:
// app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
What the code looked like:
// auth.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('reset-password')
async resetPassword(@Body() dto: ResetPasswordDto) {
return this.authService.resetPassword(dto);
}
}
Why it survived review: The infra team planned to add rate limiting at the nginx layer. They did — the nginx config was updated for /api/v1/auth/login. In the same sprint, the app prefix was changed from v1 to v2. Nobody cross-referenced the two PRs. The nginx rule was there, correctly written, matching a path that no longer existed. The app had no rate limiting at all on its login endpoint. This survived because two engineers on two different teams each believed the other had covered it — and the diff for each individual PR looked correct.
What the lint rule catches: require-throttler flags any @Controller or route handler that doesn't have @Throttle(...) or ThrottlerGuard in its guard chain. Note: you need ThrottlerModule configured in your module — the rule catches the missing guard on the controller, not the module wiring.
// auth.module.ts — ThrottlerModule must be configured
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60000, limit: 10 }]),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
// auth.controller.ts — fixed
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
// requires @nestjs/throttler@^5 — ttl is in milliseconds (v4 and earlier used seconds)
@Controller('auth')
@UseGuards(ThrottlerGuard)
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60000 } }) // 5 per 60 seconds
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
}
The rule emits CWE-770 — Allocation of Resources Without Limits or Throttling. On an auth route the downstream consequence is brute-force / credential stuffing (CWE-307); that's the human-facing risk, but you'll see 770 in the tool output.
What the code looked like:
// posts.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { CreatePostDto } from './dto/create-post.dto';
import { PostsService } from './posts.service';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
async createPost(@Body() body: CreatePostDto) {
return this.postsService.create(body);
}
}
Why it survived review: CreatePostDto is typed. TypeScript enforces the shape at compile time. Every senior engineer on the team knew this — TypeScript types, decorator-based DTO, looks correct. What wasn't obvious from the diff: without a ValidationPipe registered, the TypeScript types are compile-time only. At runtime, any shape passes through to the service layer. The types were right. The validation was missing. Nobody looked for the global pipe because the DTO existed and "looked validated." This is the TypeScript false sense of security in its purest form.
What the lint rule catches: no-missing-validation-pipe flags @Body() / @Query() / @Param() parameters with a non-primitive type that don't have a ValidationPipe in scope — via @UsePipes(ValidationPipe) on the class or method. It's a single-file AST rule: it cannot see into main.ts to confirm a global pipe actually exists there. There's an assumeGlobalPipes option that disables the rule entirely if you tell it (not verify it) that a global pipe is registered — which is exactly the kind of trust-without-checking that caused this bug in the first place, so I run without it.
// main.ts — Option 1: global (recommended)
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })
);
// posts.controller.ts — Option 2: per-parameter
@Post()
async createPost(@Body(new ValidationPipe()) body: CreatePostDto) {
return this.postsService.create(body);
}
What the code looked like:
// create-user.dto.ts
import { IsEmail } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
name: string; // no validator — accepts any length, any encoding
role: string; // no validator — accepts 'admin' as easily as 'user'
}
Why it survived review: email had a decorator, so the reviewer's eye scanned the class and mentally checked "validated." One decorated field gave the whole class a passing grade. The role field was added three weeks later in a quick patch — it looked safe in context because the surrounding code looked safe. When whitelist: true wasn't enforced at runtime (bug #4), role: 'admin' passed through unchecked. This is the halo effect: a class that has some validation gets treated as fully validated.
What the lint rule catches: require-class-validator verifies that every property in a DTO class (name ending Dto/Request/Input, or carrying an @ApiProperty-style decorator) has at least one class-validator decorator — properties prefixed with _ are treated as internal and exempted.
// create-user.dto.ts — fixed
import { IsEmail, IsString, MaxLength, IsEnum } from 'class-validator';
export enum UserRole {
User = 'user',
Moderator = 'moderator',
}
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MaxLength(100)
name: string;
@IsEnum(UserRole) // 'user' | 'moderator' — not 'admin'
role: UserRole;
}
What the code looked like:
// debug.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { DebugGuard } from './debug.guard';
@Controller('debug')
@UseGuards(DebugGuard)
export class DebugController {
@Get('config')
getConfig() {
return process.env; // DATABASE_URL, JWT_SECRET, STRIPE_SECRET_KEY, all of it
}
}
// debug.guard.ts
@Injectable()
export class DebugGuard implements CanActivate {
canActivate(): boolean {
// intent: block in prod, allow elsewhere — shipped inverted
return process.env.NODE_ENV === 'production';
}
}
Why it survived review: DebugGuard was real and had its own test — the test ran with NODE_ENV unset, the default in the test runner, and asserted the guard blocked the request. That passed: undefined === 'production' is false, and a blocked debug endpoint in a test environment looked exactly as safe as it was supposed to look. Nobody wrote a second test with NODE_ENV actually set to 'production', because — on paper — that's the "does the boring, obviously-correct thing" branch: the guard exists specifically to gate access in prod, so of course it gates access in prod. Nobody traced that the one untested input was the one that flips the comparison to true. The guard was real, tested, green in CI. It had just never been tested against the one value it existed to react to. Deployed in a Friday afternoon push. Found by a user who noticed /debug/config returned valid JSON with real secrets — because in production, the guard's canActivate() returned true.
Why this survives review: "It has a guard and the guard has a test" is where most reviewers stop checking. Nobody asked which branch of the guard's logic the test actually exercised. A guard that's real, wired up, and covered by a green test can still be testing the wrong half of its own conditional — and a @Controller('debug') with @UseGuards on it reads as fully handled at a glance.
What the lint rule catches: no-exposed-debug-endpoints flags any controller or route whose path contains one of its DEFAULT_DEBUG_PATHS — debug, __debug__, admin, _admin, test, health — full stop, regardless of whether it's guarded. That's deliberate: a debug route with a guard today is one config change away from losing it, and the rule would rather you silence it explicitly (see the override below) than trust a guard to stay in place. It does not inspect the handler body, so it won't catch process.env on its own here — that exposure is what made this specific debug route catastrophic, not what the rule detects; the path-name match is what makes it fire at all.
Note the overlap this creates with bug #1: admin is in DEFAULT_DEBUG_PATHS, so AdminController from the first section — fixed or not — also trips no-exposed-debug-endpoints. In the real run, that finding is counted once, under this rule, not double-counted against require-guards; I'm calling it out here rather than in the table because it's the kind of overlap you'll hit on your own codebase too. If you have a legitimate /admin route that isn't a debug backdoor, you'll want the same per-project override shown below — drop 'admin' from the list, or scope it to something more specific like _admin/debug.
// debug.controller.ts — fix: remove the controller entirely.
// If you need a health check for load balancers / k8s probes,
// use a dedicated module that never touches process.env:
// health.controller.ts
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', timestamp: Date.now() };
}
}
// Note: 'health' IS in DEFAULT_DEBUG_PATHS. Health checks are intentionally public —
// LBs can't auth — so silence it deliberately:
//
// 'nestjs-security/no-exposed-debug-endpoints': ['error', {
// endpoints: ['debug', '__debug__', 'admin', '_admin', 'test'], // drop 'health'
// }],
// eslint.config.mjs
import tseslint from 'typescript-eslint';
import nestjsSecurity from 'eslint-plugin-nestjs-security';
export default tseslint.config(
{
languageOptions: {
parser: tseslint.parser,
parserOptions: { project: true },
},
plugins: { 'nestjs-security': nestjsSecurity },
rules: {
'nestjs-security/require-guards': 'error',
'nestjs-security/no-exposed-private-fields': 'error',
'nestjs-security/require-throttler': 'error',
'nestjs-security/no-missing-validation-pipe': 'error',
'nestjs-security/require-class-validator': 'error',
'nestjs-security/no-exposed-debug-endpoints': 'error',
},
}
);
npm install --save-dev eslint-plugin-nestjs-security typescript-eslint
npx eslint "src/**/*.ts"
The parser matters: decorators and type annotations (@Param('id') id: string) aren't valid syntax under ESLint's default espree parser. If your project already lints TypeScript, you likely have this wired up already — the plugin's own configs.recommended assumes it and only adds plugins + rules on top of it.
Full rule documentation: eslint.interlace.tools
Want the per-rule reasoning — what each of the six rules matches on and why NestJS leaves the gap open in the first place? That's the rule-by-rule getting-started guide. This post is the war story; that one is the manual.
The missing global ValidationPipe (bug #4) and the undecorated role field (bug #5) compound, and which one matters depends on how role is meant to be used. If role is never supposed to be client-settable at all, whitelist: true alone strips it — but only if the pipe is registered, which is bug #4. If role is a legitimate client-settable field (say, a "display as" preference that happens to share a name with an internal permission field), whitelist won't touch it — it's a known key — and the only thing standing between role: 'user' and role: 'admin' is a validator like @IsEnum(UserRole) constraining which values are accepted. That's bug #5. Neither rule substitutes for the other: no-missing-validation-pipe stops fields the client shouldn't be sending at all; require-class-validator stops fields the client can send but shouldn't be able to set arbitrarily. Know which threat model your DTO has, and run both checks regardless — you rarely get to assume you got the pipe config right on every controller.
Here's why I stopped treating this as a one-time incident report: these six patterns are not artifacts of 2018 NestJS or a junior who didn't know better. They are the default output of a competent developer moving fast — which is exactly what a coding assistant emulates.
I gave Claude Sonnet 4.6 a single prompt — "Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel." — and ran the same plugin on the result. It produced 200 lines of clean, TypeScript-passing NestJS, and 6 errors in 3 seconds: the same unguarded admin controller, the same password in the response body, the same unthrottled login route, the same debug endpoint returning DATABASE_URL. Not similar bugs — the same six classes. I wrote that up in Claude Wrote a NestJS Service. TypeScript Was Happy. ESLint Found 6 Security Holes. Running the same prompt through Gemini 2.5 Flash got the count down to 2 — but it still shipped auth endpoints with no rate limiting (Claude vs Gemini, same prompt, different errors).
Read that last bit again, because it's the most uncomfortable number in this whole post: the one finding that survived the model swap is #3 — the unthrottled login route. Claude failed require-throttler. Gemini, which got guards, validators, and serialization right, failed require-throttler too. Switching your AI vendor changed five of the six findings and left the brute-force hole untouched. And don't assume a smarter model saves you: across 80 Claude-written functions, 65–75% carried at least one vulnerability — and the newest model in that run (Opus 4.6) scored identically to the older Sonnet 4.5.
The reason is the same reason these survived human review: an LLM optimizes for the route logic you asked for, and the security boundary is the absence of something it was never prompted to add. A guard that isn't there doesn't show up in a diff, doesn't fail a type check, and doesn't fail a unit test that mocked it. It only shows up in structural analysis — which is the entire premise of that 80-function experiment.
So the inherited codebase and the AI-generated one converge on the same lint config. Whether the password leak came from a 2-year-old PR or from yesterday's autocomplete, no-exposed-private-fields fires identically.
Look back at the six "why it survived review" explanations and the failure mode is identical every time: the security control existed somewhere the reviewer trusted, so the reviewer stopped looking. The global guard "was in main.ts." Rate limiting "was at the nginx layer." The debug endpoint "was disabled in production." The DTO "was typed." In each case a senior engineer waved the PR through not out of negligence but because the diff in front of them was locally correct — and the broken assumption lived in a different file, a different sprint, or a different team's config.
Code review verifies the lines that changed. None of these bugs were in the lines that changed. That's the asymmetry static analysis closes.
For the full onboarding protocol — the three plugins, the jq one-liner that ranks findings by rule, and how to read the heatmap — see The 30-Minute Security Audit: I ran it on 140 Gemini-written functions, 102 shipped vulnerable.
Every finding in this post carries a CWE label — the CWE-284 / CWE-200 / CWE-770 tags in the table up top. CWE Taxonomy Explained is the map behind those labels: how the taxonomy works and why lint rules key their findings to it. And for turning "which class" into "how bad" — the severity scoring those labels feed into — see CVSS Scores Explained.
What's the first ESLint security finding you remember that surprised you in a production codebase — the one that made you think "how has this been here this long?" Drop the rule name and the war story below. I read every one.
Part of The Hardened Stack series: ← The 30-Minute Security Audit: onboarding a new codebase | You inherited a NestJS codebase (you are here) | The same six classes, written by Claude →
eslint-plugin-nestjs-security is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz
