Claude Wrote a NestJS Service. TypeScript Was Happy. ESLint Found 6 Security Holes. A developer prompted Claude Sonnet 4.6 to build a NestJS users service with authentication, registration, login, profile and admin endpoints, producing 200 lines of working TypeScript code in 90 seconds. Running a custom ESLint security plugin on the generated code found 6 security vulnerabilities in 3 seconds, including missing authentication guards on an admin endpoint that exposes all user data and a login route vulnerable to brute-force attacks. The developer noted that the code would have passed human code review because AI models optimize for completing described functionality rather than enforcing security constraints not explicitly requested in the prompt. TypeScript passed it clean. The code ran. Then I ran the linter. I gave Claude Sonnet 4.6 a single prompt: "Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel." 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. I ran eslint-plugin-nestjs-security — a plugin I built specifically to catch these patterns. 6 errors. 0 warnings. 3 seconds. I would have approved this code in review. So would you. Here's what we both would have missed — and why. In a 700-function benchmark across 5 AI models https://dev.to/ofri-peretz/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain-1hgj , Claude's vulnerability rate was 65–75%. A well-structured NestJS service with correct decorators and typed DTOs is no exception. 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 }; } } TypeScript: ✅ Clean. Runtime: ✅ Would work. ESLint: ❌ 6 errors. Each finding below 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 Controller 'UsersController' lacks @UseGuards for access control /src/users/users.controller.ts:2:1 GET /users/admin/users returns every user in the database. No authentication required. 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 they assume 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?" I would have merged this. // Fix — JwtAuthGuard from @nestjs/passport @Controller 'users' @UseGuards JwtAuthGuard export class UsersController { @Get 'admin/users' @Roles 'admin' // requires a RolesGuard that reads this metadata async listAllUsers { return this.usersService.findAll ; } } Production note:Teams using APP GUARD to register JwtAuthGuard globally can configure nestjs-security/require-guards with assumeGlobalGuards: true to suppress false positives on intentionally undecorated controllers. See also: the same missing-guard pattern in a 2-year-old production NestJS codebase, and why every PR approved it https://dev.to/ofri-peretz/i-inherited-a-nestjs-codebase-the-first-lint-run-found-6-vulnerabilities-55ma . And the getting-started guide for all six rules https://dev.to/ofri-peretz/getting-started-with-eslint-plugin-nestjs-security-32ic . nestjs-security/require-throttler Route 'login' lacks @Throttle or ThrottlerGuard — brute-force exposure /src/users/users.controller.ts:10:3 An attacker can enumerate passwords against the login endpoint at full network speed. 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 that function can be invoked. 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 infrastructure concern — the assumption is nginx or the API gateway handles it. The assumption is often right. Then someone updates the route prefix. The nginx rule stops matching. Nobody cross-references the two PRs. // requires @nestjs/throttler@^4 // Note: v3 used seconds for ttl; v4 uses milliseconds — 60000 = 60 seconds @Post 'login' @UseGuards ThrottlerGuard @Throttle { default: { limit: 5, ttl: 60000 } } async login @Body dto: LoginDto { return this.usersService.login dto ; } 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 and refreshToken in the JSON body. Not could include under certain conditions. Every single response. @Entity export class User { @PrimaryGeneratedColumn 'uuid' id: string; @Column email: string; @Column password: string; // hashed — still in every response @Column refreshToken: string; // full rotation token, returned on every GET /profile } 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. js import { Exclude } from 'class-transformer'; @Entity export class User { @PrimaryGeneratedColumn 'uuid' id: string; @Column email: string; @Column @Exclude password: string; @Column @Exclude refreshToken: string; } Critical: @Exclude does nothing without ClassSerializerInterceptor registered. Add this to main.ts or the decorator is decorative: app.useGlobalInterceptors new ClassSerializerInterceptor app.get Reflector ; This is the most common implementation gap with NestJS serialization. Two schools of thought exist: @Exclude on entities shown here vs. separate response DTOs that only expose what you intend. The DTO approach eliminates the class entirely — worth the overhead on auth-sensitive resources. nestjs-security/no-missing-validation-pipe @Body parameter 'dto' in 'register' lacks ValidationPipe — runtime types not enforced /src/users/users.controller.ts:7:20 Claude generated typed DTOs with TypeScript types. At runtime — without a ValidationPipe — those types don't exist. Any JSON shape passes through, including { role: 'admin' } . Why AI generates this: TypeScript types disappear at runtime. ValidationPipe is what re-enforces them on the way in. Claude Sonnet 4.6 generates correct TypeScript — it doesn't model the gap between compile-time types and runtime validation. The code does exactly what it says it does. Why it survives review: The DTO is typed. The parameter is typed. TypeScript shows no errors. This requires knowing what NestJS doesn't do automatically — specifically that @Body dto: CreateUserDto is a type annotation, not a runtime validator. // In main.ts — global is recommended over per-parameter app.useGlobalPipes new ValidationPipe { whitelist: true, // strip properties with no decorator forbidNonWhitelisted: true, // throw on extra properties transform: true, // coerce plain objects into class instances } ; Note onWithout it, your DTO parameter is a plain transform: true : Record