TypeScript said this NestJS service was clean. ESLint found 6 security holes in 3 seconds.
I gave Claude Sonnet 4.6 one prompt — "Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel." — and 90 seconds later I had 200 lines of NestJS. Decorators in the right places, DTOs typed correctly, dependency injection wired. It looked like code written by a developer who knew NestJS.
Then static analysis flagged six findings the compiler could not: an admin endpoint with no access-control guard (CWE-284), a login route with no rate limit (CWE-770), an entity shipping password in every response (CWE-200), a @Body() parameter with no runtime validation (CWE-20), a privilege-bearing DTO field with no constraint (CWE-915), and a debug route returning DATABASE_URL (CWE-489).
TypeScript: ✅ Clean. Runtime: ✅ Would work. One of the six was a debug route silently serializing the database connection string in plaintext — the kind of finding that looks like a toy example until Finding 6, below, where I've seen the exact same pattern outside a demo.
Looking at this output with no context from the generation session: 6 of these findings would have passed my own code review. The linter caught all 6 in 3 seconds.
Every one of them is the absence of something — a guard, a pipe, a decorator, an environment check. None of them is a thing the model got wrong; each is a constraint the prompt never stated. That is the failure class this article is about.
The tool that caught these is eslint-plugin-nestjs-security, a plugin I built to flag exactly this negative space. Full rule reference at eslint.interlace.tools/docs/security/plugin-nestjs-security →. If you want to run it against your own AI-generated controllers before reading further, it's one install — full config is below:
npm install --save-dev eslint-plugin-nestjs-security
Claude generated a 200-line, TypeScript-clean NestJS users service from a single prompt — no security requirements included. Here is the output. The prompt was intentionally minimal: no security requirements, just functionality. This is how most developers prompt AI assistants — describe what the code should do, not what it should prevent.
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post('register')
async register(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Post('login')
async login(@Body() dto: LoginDto) {
return this.usersService.login(dto);
}
@Get('profile/:id')
async getProfile(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Get('admin/users')
async listAllUsers() {
return this.usersService.findAll();
}
@Get('debug/config')
async getConfig() {
return { env: process.env.NODE_ENV, db: process.env.DATABASE_URL };
}
}
Claude also generated the entity and DTOs referenced below — all from the same single prompt.
Each finding follows the same structure: what ESLint caught, why AI generates this pattern, and why it survives code review. The second question is the one worth sitting with.
nestjs-security/require-guards
🔒 CWE-284 OWASP:A01-Broken CVSS:9.8 | Controller/route handler listAllUsers lacks @UseGuards for access control | CRITICAL
Fix: Add @UseGuards(AuthGuard): @UseGuards(AuthGuard) before the handler | https://docs.nestjs.com/guards
/src/users/users.controller.ts nestjs-security/require-guards
GET /users/admin/users returns every user in the database. No authentication required. The rule walks each route handler in the controller and reports the unguarded method by name — here, listAllUsers.
Why AI generates this: Authorization is a constraint, not a feature. AI models optimize for completing described behavior, not for restrictions the prompt didn't mention. "List all users" is a valid feature. "Only admins can list users" is a negation of default behavior that requires explicit intent. Claude Sonnet 4.6 fulfilled exactly what it was asked.
Why it survives review: Reviewers know the team has JwtAuthGuard registered — or think they do. The guard is off the mental stack when reading route logic. Nobody scans a controller and asks "is there a guard here?" They ask "does the logic look right?" So would anyone on your team reviewing typed DTOs returning from a named service.
On the CVSS 9.8: that's require-guards's fixed class severity, not a per-handler score — a missing guard could front a read, a write, or an admin action, and the rule can't tell which from the AST alone. This example is realistically closer to a 7.5 read-only exposure; don't cite the 9.8 to a security team as if it were per-handler.
A note on scope: the rule flags every handler lacking @UseGuards (or a @Public()/@SkipAuth()-style opt-out), with no built-in exception for auth entry points — login and register above are correctly unguarded (no prior session to check yet), so their fix is @Public(), not @UseGuards(). If your app relies on app.useGlobalGuards() in main.ts instead of per-route decorators, set assumeGlobalGuards: true.
Blind spot: guards registered via
APP_GUARDproviders or inherited from a base controller aren't visible to the rule either — it can stay silent on a route that's actually protected, the mirror image of the false positives above.
// The rule reports the offending route handler, but is satisfied by @UseGuards at
// either class or method level. Method-level is correct here — this controller also
// handles unauthenticated routes (login, register). Class-level would 401 them.
@Controller('users')
export class UsersController {
@Post('login') // intentionally unauthenticated
async login(@Body() dto: LoginDto) { /* ... */ }
@Get('admin/users')
@UseGuards(JwtAuthGuard, RolesGuard) // satisfies require-guards; RolesGuard reads @Roles metadata
@Roles('admin') // your own decorator: export const Roles = (...roles: string[]) => SetMetadata('roles', roles)
async listAllUsers() {
return this.usersService.findAll();
}
}
See also: the same missing-guard pattern in a 2-year-old production codebase, and why every PR approved it.
nestjs-security/require-throttler
🔒 CWE-770 CVSS:7.5 | Controller login lacks rate limiting protection (Throttler) | HIGH
Fix: Add @UseGuards(ThrottlerGuard) or configure global ThrottlerModule | https://docs.nestjs.com/security/rate-limiting
/src/users/users.controller.ts nestjs-security/require-throttler
An attacker can enumerate passwords against the login endpoint at full network speed. The rule tags this CWE-770 (Allocation of Resources Without Limits or Throttling) — the missing control is a rate limit, full stop.
Why AI generates this: Brute-force protection is a rate-at-which constraint, not a what-does-it-do constraint — those never appear in feature prompts. "Build a login endpoint" describes a function, not a limit on how fast it can be called. Claude Sonnet 4.6 knows @Throttle exists; it will add it if you ask. The prompt didn't ask.
Why it survives review: Reviewers look at handler logic (correct), DTO types (correct), error handling (present). Rate limiting reads as an infra concern — the assumption is nginx handles it. Two sprints later, someone updates the route prefix. The nginx rule stops matching. Nobody cross-references the two PRs.
The fix requires two steps: registering the module in AppModule and applying the guard to the route. Without the module registration, NestFactory.create() throws UnknownDependenciesException at bootstrap — the app never starts, rather than failing per-request — because ThrottlerGuard can't resolve its module options from the DI container. One more thing this fix doesn't cover: ThrottlerModule.forRoot's default storage is in-memory, which means it's per-instance. Behind a load balancer with more than one replica, each instance counts requests independently, so the effective limit is limit × replica count — with the limit: 10 configured below and 3 replicas, an attacker who rotates across instances gets 30 login attempts per window, not 10. For a real deployment, back the throttler with shared storage — ThrottlerStorageRedisService from the community @nest-lab/throttler-storage-redis package is the common choice.
// app.module.ts — register ThrottlerModule FIRST or the app fails to boot
import { ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60000, limit: 10 }]),
// ... your other imports
],
})
export class AppModule {}
// requires @nestjs/throttler@^5 — ttl is in milliseconds (v4 and earlier used seconds)
// route-level @Throttle overrides the module-level default (10) for this handler only —
// login gets the tighter limit (5), other routes keep the module's 10
@Post('login')
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } }) // 60 seconds
async login(@Body() dto: LoginDto) {
return this.usersService.login(dto);
}
nestjs-security/no-exposed-private-fields
nestjs-security/no-exposed-private-fields
Property 'password' in User entity not excluded from serialization
/src/users/user.entity.ts:8:3
Every API response from this service included password in the JSON body. Not could include under certain conditions. Every single response. This is the one finding I've never seen miss — I've yet to run this against an AI-generated NestJS service where it doesn't fire.
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid') id: string;
@Column() email: string;
@Column() password: string; // hashed — still in every API response
}
Why AI generates this: AI models the entity as a data structure, not as a serialization contract. @Exclude() from class-transformer is only meaningful within NestJS's HTTP response lifecycle — invisible to a model focused on making the class definition correct.
Why it survives review: The entity type is User. The controller returns User. TypeScript shows no errors. Reviewers see typed, structured data. What they don't see is the JSON shape at runtime, because they're reading code, not running curl against staging. I would have approved this — the type system looked correct because it was.
import { Exclude } from 'class-transformer';
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid') id: string;
@Column() email: string;
@Column()
@Exclude()
password: string; // same rule catches any other secret-bearing column you add later
}
Two implementation approaches:
@Exclude()on entities (shown here) vs. dedicated response DTOs that only expose what you intend. The DTO approach is architecturally cleaner — returning entity classes from controllers is the smell; the decorator is the patch. Either way, register the interceptor or@Exclude()does nothing:app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));One scope note:
@Exclude()only strips the field duringClassSerializerInterceptor's HTTP serialization step. It does nothing to your repository reads —usersService.login()still needs the hashedpasswordback fromfindOne()to compare against, and it gets it, because that read never goes through the interceptor. The decorator protects the response boundary, not the query.
@Exclude()also has a durability problem: it's opt-out. Add astripeCustomerIdorresetTokencolumn six months from now and it ships exposed by default until someone remembers to decorate it. Flip the default withnew ClassSerializerInterceptor(app.get(Reflector), { excludeExtraneousValues: true })and switch to@Expose()on the fields you do want returned — now new columns are hidden until named. More upfront annotation, but the failure mode moves from silent leak to broken response, which is the direction you want a mistake to fail in.
nestjs-security/no-missing-validation-pipe
nestjs-security/no-missing-validation-pipe
@Body() parameter 'dto' in 'register' lacks ValidationPipe — runtime types not enforced
/src/users/users.controller.ts:6:20
Claude generated typed DTOs. TypeScript enforces the shape at compile time. At runtime — without a ValidationPipe — those types don't exist. Any JSON shape passes through.
Why AI generates this: TypeScript types are erased at compile time — there's no runtime trace of them at all, so the type-checker's guarantee ends the moment tsc finishes. NestJS closes that gap with class-validator decorators (@IsEmail(), @IsString(), etc.) evaluated by ValidationPipe at request time — a separate, decorator-driven check, not the TS type system somehow persisting. Claude generates correct TypeScript; it doesn't reach for the separate runtime-validation layer unless the prompt asks for it.
Why it survives review: The DTO is typed. The parameter is typed. TypeScript shows no errors. Nobody checks whether app.useGlobalPipes(new ValidationPipe()) actually made it into main.ts — that's a one-line, easy-to-miss wiring step in a different file from the DTO the reviewer is looking at.
// In main.ts — global is recommended over per-parameter
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip properties with no class-validator decorator
forbidNonWhitelisted: true, // throw on unexpected properties
transform: true, // coerce to class instances; without this, instanceof checks fail
})
);
The hole no linter catches — and the most important paragraph in this article: Claude also omits
@ValidateNested()+@Type(() => NestedDto)on nested DTO objects. Without them, nested objects skip validation entirely — the class-validator decorators on the nested class are ignored at runtime. This is the single most frequentValidationPipehole I see in AI-generated NestJS code, and it has no ESLint error: TypeScript compiles, the pipe is registered, validation appears to run, and the nested object passes through unchecked. Static analysis can flag the missing pipe (Finding 4) and the missing decorator (Finding 5); it cannot yet prove that a present decorator actually recurses. The lint gate narrows the gap — it does not close it, and pretending otherwise is how the nested hole survives. If you read one fix in this piece twice, make it this one.Until a rule exists for this, the working mitigation is a test, not a lint pass: write one integration test per DTO with a nested object, submit garbage inside the nested field (
{ profile: { age: 'not-a-number' } }), and assert on the 400. If it passes with a 200 instead,@ValidateNested()is missing. Grep for nested class properties without@ValidateNested()in the meantime — it's a five-minute manual audit that catches what the pipe can't yet prove.
nestjs-security/require-class-validator
The role field in CreateUserDto has no class-validator decorator, which means a caller can submit any string value — including role: 'admin' — and nothing at the validation layer rejects it.
nestjs-security/require-class-validator
Property 'role' in CreateUserDto has no class-validator decorator
/src/users/dto/create-user.dto.ts:5:3
export class CreateUserDto {
@IsEmail()
email: string;
role: string; // no validator
}
This is analogous to mass assignment — CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes) is the classic Rails/Python framing, where attributes are resolved dynamically at runtime; a statically-typed TypeScript DTO isn't a perfect fit for "dynamically-determined," so treat this as the closest CWE, with CWE-269 (Improper Privilege Management) as the TypeScript-native alternative. Either way, a caller can pass role: 'admin' and, depending on how the service layer maps the DTO to the entity, escalate their own privilege. The distinction from Finding 4 matters: Finding 4 is about missing runtime enforcement; Finding 5 is about missing value constraints that survive runtime enforcement.
With whitelist: true alone, an undecorated role field is silently stripped. Add forbidNonWhitelisted: true (as the Finding 4 config above does) and it stops being silent — Nest throws a 400 on the unrecognized role key instead. Either way, this isn't the fix, for a specific reason: developers add decorators later. The day someone adds @IsString() to role so it clears the whitelist and stops 400ing (a natural, well-intentioned refactor), role: 'admin' becomes a valid, accepted payload. @IsString() satisfies the pipe; it doesn't constrain the value — only @IsEnum(SelfAssignableRole) does.
This is the one finding of the six I'd flag as hardest to catch by eye: Claude adds validation for fields where the constraint is obvious from the semantic type (email → @IsEmail()), but role has no tutorial default — valid values are a domain-specific enum the model can't infer from an unspecified domain. And it doesn't fail loud in review. Reviewers see @IsEmail() on email and pattern-match "this DTO is validated"; nobody audits field by field for the one bare property, especially when role arrives as a quick patch after the initial commit and nobody circles back.
Findings 4 and 5 are coupled: whitelist/forbidNonWhitelisted police unknown keys — strip or reject them. Neither constrains values on keys that are already known and decorated. You need both: the pipe (Finding 4) and enum decorators (Finding 5). Either without the other leaves a privilege escalation path — the pipe alone just changes whether that path fails loud (400) or silent (dropped field) today, and reopens the moment role gets any decorator that isn't @IsEnum.
import { IsEmail, IsString, MaxLength, IsEnum } from 'class-validator';
// Separate from UserRole — admin is not self-assignable at registration.
// Using UserRole here would allow role: 'admin' since it's a valid member.
enum SelfAssignableRole {
user = 'user',
moderator = 'moderator',
// admin intentionally absent
}
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MaxLength(100)
name: string;
@IsEnum(SelfAssignableRole) // rejects 'admin' — not because it's unknown, but because it's not in this enum
role: SelfAssignableRole;
}
nestjs-security/no-exposed-debug-endpoints
The debug/config route returns process.env.DATABASE_URL directly in the response body — an unauthenticated request discloses the full database connection string, credentials included.
nestjs-security/no-exposed-debug-endpoints
Controller path 'debug/config' returns process.env — information disclosure
/src/users/users.controller.ts
@Get('debug/config')
async getConfig() {
return { env: process.env.NODE_ENV, db: process.env.DATABASE_URL };
}
One curl to /users/debug/config. Your DATABASE_URL — hostname, port, username, password — serialized as JSON, no authentication. I found this exact pattern live in a staging environment in under 60 seconds, running the same plugin against a client's repo during an onboarding audit — not a pentest, not a scan they'd scheduled. It had been live for four months. The blast radius was contained to a staging replica with no production data, which is the only reason this is a story and not an incident report.
The rule reports this as CWE-489 (Active Debug Code) — the endpoint itself shouldn't ship. It's worth also naming CWE-215 (Insertion of Sensitive Information Into Debugging Code), since Finding 3 already owns CWE-200 for the entity-level leak: this is the same class of mistake — secrets reachable through a code path meant for developers, not requests — just triggered by a route instead of a serializer.
Why AI generates this: Claude added this as a diagnostic helper. It's genuinely useful during development. AI generates code for the specification given to it and has no concept of a production boundary. "Useful during development" and "never deploy this" are the same to a model that doesn't model deployment environments.
Why it survives review: Debug endpoints arrive via two routes: AI generates them unguarded (this case), or a developer adds one temporarily and forgets to remove it. Either way, review approves it for a mundane reason, not a careless one — nobody ran curl against the deployed route before sign-off. Review verifies the diff; it rarely verifies the running service. The four-month staging case above fits this exactly: the code was correct enough to pass a read, and nobody executed the one request that would have caught it. The linter doesn't need to execute anything. It sees process.env in a response and fires.
Guarding is not a fix. A guarded endpoint returning
DATABASE_URLis still a credential leak waiting for a token to be compromised. Remove the sensitive values from the response entirely.
A DATABASE_URL leaked through a live route is one path to the same outcome as a sk_live_ key committed straight into source — the secret ends up somewhere a scanner or a curious visitor can find it. If hardcoded credentials are more your team's failure mode than exposed routes, the language-level version of this same problem — 28 CWE-mapped rules for hardcoded secrets, unsafe deserialization, and injection classes — is the sibling plugin for it.
// Fix: environment-gated module — never conditionally guard a live endpoint
// In app.module.ts:
@Module({
imports: [
...(process.env.NODE_ENV !== 'production' ? [DebugModule] : []),
],
})
export class AppModule {}
// In debug.controller.ts — AdminGuard is your own CanActivate checking req.user.role === 'admin'
@Controller('debug')
@UseGuards(JwtAuthGuard, AdminGuard)
export class DebugController {
@Get('config')
getConfig() {
return { env: process.env.NODE_ENV }; // never return DATABASE_URL
}
}
// In debug.module.ts — the module itself is completely absent in production builds
@Module({ controllers: [DebugController] })
export class DebugModule {}
All six findings share a root cause: the AI fulfilled the prompt, and the prompt didn't specify a security constraint.
TypeScript can't catch any of these. They compile, run, and do exactly what the code says. What's missing in each case isn't behavior — it's the absence of something: a decorator, a pipe, a guard, an enum constraint, an environment check. Call it the negative-space failure class: a bug you can't diff against working code, because the code works. There's no wrong line to point at — only a missing one.
The question that surfaces all six: "What happens when someone who isn't supposed to use this endpoint tries?" That's a negative-space question. AI doesn't ask it unless you do. Code reviewers often don't either — we're trained to verify correctness, not the absence of unauthorized access.
One caveat on the number itself: "6 findings" spans the controller, the entity, and a DTO — not 6 findings across 5 endpoints. Findings 3 and 5 are scoped to the User entity and CreateUserDto, not to a single route, so the honest framing is 6 findings across the service, not a per-endpoint rate. It's also one generation pass, not a sampled average — run the same prompt again and the count will move. What won't move is the failure class: the missing-guard pattern reappears on a retry, just attached to a different endpoint.
Static analysis asks the negative-space question on every file, every run — and a domain-specific plugin asks a sharper version of it than a general one does: the general-purpose ESLint security floor caught 0 of 40 vulnerabilities that a NestJS-aware ruleset like this one is built to see. The Hydra Problem shows what happens when you try to fix AI omissions one at a time in review: fixing one surfaces others. This run's count isn't what matters — what matters is that the same missing-guard pattern shows up across every security domain we've benchmarked. NestJS is no exception.
The natural objection: maybe six is a Claude-specific weakness, and another toolchain gets it right. The count does move with the toolchain — but the root cause doesn't. These aren't bugs the model got wrong; they're constraints the prompt never stated. Change assistants and the count changes; the negative-space class survives. That's also the finding of a broader leaderboard comparing 5 AI models on security: rank one model above another and you're still measuring which constraints it happened to infer, not which ones it was told.
I ran the identical prompt through Gemini 2.5 Flash via the Gemini CLI and scanned the output with the same plugin: Same NestJS Prompt. Claude Got 6 Errors. Gemini Got 2. Gemini's default scaffolding was structurally tighter — it got guards, validators, and serialization right where Claude didn't. But both toolchains shipped the same Finding 2: no rate limiting on the login endpoint. The one class that survived the model swap is the one neither prompt thought to constrain. Zoomed out across four security domains — not just NestJS — the pattern holds at a larger sample too: the two models trade wins domain by domain, but converge on skipping the same hardening step.
You can verify the whole thing yourself in three steps:
- Paste the same prompt — "Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel." — into whatever assistant you use (Claude, Gemini, GPT-4, Copilot).
- Run the config below on the output.
- Count the findings by class, not by line. The total drifts by toolchain; the rate-limit, missing-guard, and exposed-
passwordclasses keep recurring. The rules read the decorator tree, not the git blame. Expect at minimum: arequire-throttlerhit onlogin, arequire-guardshit on whatever route lists all resource records, and ano-exposed-private-fieldshit on any entity with apasswordor token column — those three classes are the ones that survived every assistant I've run this against.
This is the full flat-config setup for eslint-plugin-nestjs-security — copy it into eslint.config.mjs to run all six rules from this article against your own NestJS code.
// eslint.config.mjs
import nestjsSecurity from 'eslint-plugin-nestjs-security';
export default [
{
plugins: { 'nestjs-security': nestjsSecurity },
rules: {
'nestjs-security/require-guards': ['error', { assumeGlobalGuards: false }],
'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
Note: NestJS is always TypeScript. Add these rules to your existing
typescript-eslintconfiguration — the config above assumeslanguageOptions.parserandlanguageOptions.parserOptions.projectare already set (both live underlanguageOptionsin flat config). Runningeslint src/without the TS parser will fail on decorators.
Full rule documentation at eslint.interlace.tools/docs/security/plugin-nestjs-security. New to the plugin? Architectural Security: The NestJS Static Analysis Standard covers the full rule set end to end.
{% cta https://github.com/ofri-peretz/eslint %} ⭐ Star on GitHub {% endcta %}
Which of these six shipped in your codebase — and how long was it live before anyone noticed?
Part of the AI Security Benchmark Series: ← I Let Claude Write 60 Functions. 65-75% Had Security Vulnerabilities. | Claude Wrote a NestJS Service (you are here) | Same Prompt, Gemini → | Aggregate Benchmarks Lie →
eslint-plugin-nestjs-security is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz · ofriperetz.dev · npm/~ofri-peretz
