{"slug": "claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes", "title": "Claude Wrote a NestJS Service. TypeScript Was Happy. ESLint Found 6 Security Holes.", "summary": "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.", "body_md": "TypeScript passed it clean. The code ran. Then I ran the linter.\n\nI 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.\n\nI ran `eslint-plugin-nestjs-security`\n\n— a plugin I built specifically to catch these patterns.\n\n**6 errors. 0 warnings. 3 seconds.**\n\nI would have approved this code in review. So would you. Here's what we both would have missed — and why.\n\nIn 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.\n\nThe 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*.\n\n```\n@Controller('users')\nexport class UsersController {\n  constructor(private readonly usersService: UsersService) {}\n\n  @Post('register')\n  async register(@Body() dto: CreateUserDto) {\n    return this.usersService.create(dto);\n  }\n\n  @Post('login')\n  async login(@Body() dto: LoginDto) {\n    return this.usersService.login(dto);\n  }\n\n  @Get('profile/:id')\n  async getProfile(@Param('id') id: string) {\n    return this.usersService.findOne(id);\n  }\n\n  @Get('admin/users')\n  async listAllUsers() {\n    return this.usersService.findAll();\n  }\n\n  @Get('debug/config')\n  async getConfig() {\n    return { env: process.env.NODE_ENV, db: process.env.DATABASE_URL };\n  }\n}\n```\n\nTypeScript: ✅ Clean.\n\nRuntime: ✅ Would work.\n\nESLint: ❌ 6 errors.\n\nEach 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.\n\n```\nnestjs-security/require-guards\nController 'UsersController' lacks @UseGuards for access control\n  /src/users/users.controller.ts:2:1\n```\n\n`GET /users/admin/users`\n\nreturns every user in the database. No authentication required.\n\n**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.\n\n**Why it survives review:** Reviewers know the team has `JwtAuthGuard`\n\nregistered — 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.\n\n```\n// Fix — JwtAuthGuard from @nestjs/passport\n@Controller('users')\n@UseGuards(JwtAuthGuard)\nexport class UsersController {\n  @Get('admin/users')\n  @Roles('admin') // requires a RolesGuard that reads this metadata\n  async listAllUsers() {\n    return this.usersService.findAll();\n  }\n}\n```\n\nProduction note:Teams using`APP_GUARD`\n\nto register`JwtAuthGuard`\n\nglobally can configure`nestjs-security/require-guards`\n\nwith`assumeGlobalGuards: true`\n\nto suppress false positives on intentionally undecorated controllers.\n\nSee 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).\n\n```\nnestjs-security/require-throttler\nRoute 'login' lacks @Throttle or ThrottlerGuard — brute-force exposure\n  /src/users/users.controller.ts:10:3\n```\n\nAn attacker can enumerate passwords against the login endpoint at full network speed.\n\n**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`\n\nexists; it will add it if you ask. The prompt didn't ask.\n\n**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.\n\n```\n// requires @nestjs/throttler@^4\n// Note: v3 used seconds for ttl; v4 uses milliseconds — 60000 = 60 seconds\n@Post('login')\n@UseGuards(ThrottlerGuard)\n@Throttle({ default: { limit: 5, ttl: 60000 } })\nasync login(@Body() dto: LoginDto) {\n  return this.usersService.login(dto);\n}\nnestjs-security/no-exposed-private-fields\nProperty 'password' in User entity not excluded from serialization\n  /src/users/user.entity.ts:8:3\n```\n\nEvery API response from this service included `password`\n\nand `refreshToken`\n\nin the JSON body. Not *could* include under certain conditions. Every single response.\n\n```\n@Entity()\nexport class User {\n  @PrimaryGeneratedColumn('uuid') id: string;\n  @Column() email: string;\n  @Column() password: string;      // hashed — still in every response\n  @Column() refreshToken: string;  // full rotation token, returned on every GET /profile\n}\n```\n\n**Why AI generates this:** AI models the entity as a data structure, not as a serialization contract. `@Exclude()`\n\nfrom `class-transformer`\n\nis only meaningful within NestJS's HTTP response lifecycle — invisible to a model focused on making the class definition correct.\n\n**Why it survives review:** The entity type is `User`\n\n. The controller returns `User`\n\n. 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.\n\n``` js\nimport { Exclude } from 'class-transformer';\n\n@Entity()\nexport class User {\n  @PrimaryGeneratedColumn('uuid') id: string;\n  @Column() email: string;\n\n  @Column()\n  @Exclude()\n  password: string;\n\n  @Column()\n  @Exclude()\n  refreshToken: string;\n}\n```\n\nCritical:`@Exclude()`\n\ndoes nothing without`ClassSerializerInterceptor`\n\nregistered. Add this to`main.ts`\n\nor the decorator is decorative:\n\n```\napp.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));\n```\n\nThis is the most common implementation gap with NestJS serialization. Two schools of thought exist:\n\n`@Exclude()`\n\non 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.\n\n```\nnestjs-security/no-missing-validation-pipe\n@Body() parameter 'dto' in 'register' lacks ValidationPipe — runtime types not enforced\n  /src/users/users.controller.ts:7:20\n```\n\nClaude generated typed DTOs with TypeScript types. At runtime — without a `ValidationPipe`\n\n— those types don't exist. Any JSON shape passes through, including `{ role: 'admin' }`\n\n.\n\n**Why AI generates this:** TypeScript types disappear at runtime. `ValidationPipe`\n\nis 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.\n\n**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`\n\nis a type annotation, not a runtime validator.\n\n```\n// In main.ts — global is recommended over per-parameter\napp.useGlobalPipes(\n  new ValidationPipe({\n    whitelist: true,           // strip properties with no decorator\n    forbidNonWhitelisted: true, // throw on extra properties\n    transform: true,           // coerce plain objects into class instances\n  })\n);\n```\n\nNote onWithout it, your DTO parameter is a plain`transform: true`\n\n:`Record<string, unknown>`\n\n, not a class instance.`instanceof CreateUserDto`\n\nchecks will fail and decorated methods won't work as expected. Include it.\n\n```\nnestjs-security/require-class-validator\nProperty 'role' in CreateUserDto has no class-validator decorator\n  /src/users/dto/create-user.dto.ts:5:3\n```\n\nClaude correctly added `@IsEmail()`\n\nto the email field. It left `role: string`\n\nbare. This is the subtler failure:\n\n```\nexport class CreateUserDto {\n  @IsEmail()\n  email: string;\n\n  role: string; // no validator\n}\n```\n\nWith `ValidationPipe({ whitelist: true })`\n\n, an undecorated `role`\n\nfield is *stripped* — which sounds safe. It isn't, for a specific reason: **developers add decorators later**. When a developer adds `@IsString()`\n\nto `role`\n\nto pass it through the whitelist (a natural refactor), `role: 'admin'`\n\nbecomes a valid payload because `@IsString()`\n\ndoesn't constrain the value — only `@IsEnum(UserRole)`\n\ndoes.\n\n**Why AI generates this:** Claude adds validation for fields where the constraint is obvious from the semantic type (`email`\n\n→ `@IsEmail()`\n\n). For `role`\n\n, valid values are a domain-specific enum with no tutorial default. The model can't infer the allowed values from an unspecified domain. A developer writing this by hand would immediately ask \"what are the valid roles?\"\n\n**Findings 4 and 5 are coupled:** `whitelist: true`\n\nstrips unknown *keys*. It doesn't prevent invalid *values* on known keys. You need both: the pipe (Finding 4) and enum decorators (Finding 5). Either without the other leaves a privilege escalation path.\n\n``` js\nimport { IsEmail, IsString, MaxLength, IsEnum } from 'class-validator';\n\nexport class CreateUserDto {\n  @IsEmail()\n  email: string;\n\n  @IsString()\n  @MaxLength(100)\n  name: string;\n\n  @IsEnum(UserRole) // 'user' | 'moderator' — explicit allow-list, not 'admin'\n  role: UserRole;\n}\nnestjs-security/no-exposed-debug-endpoints\nController path 'debug/config' returns process.env — information disclosure\n  /src/users/users.controller.ts:22:3\n@Get('debug/config')\nasync getConfig() {\n  return { env: process.env.NODE_ENV, db: process.env.DATABASE_URL };\n}\n```\n\nOne `curl`\n\nto `/users/debug/config`\n\n. Your `DATABASE_URL`\n\n— hostname, port, username, password — serialized as JSON. No authentication. No rate limiting. Available to any HTTP client that can reach your server.\n\n**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.\n\n**Why it survives review:** In staging, this endpoint was protected — someone had added `@UseGuards(JwtAuthGuard)`\n\n. A debugging session removed it. Or it was added to a module that was never registered in production. Or nobody knew it existed. The PR that introduced it was filed as \"add dev tooling.\" It passed review because it was exactly that.\n\n```\n// Fix: isolate behind an environment-gated module — never conditionally guard\n// In app.module.ts:\n@Module({\n  imports: [\n    ...(process.env.NODE_ENV !== 'production' ? [DebugModule] : []),\n  ],\n})\nexport class AppModule {}\n\n// In debug.module.ts — completely absent in production builds\n@Controller('debug')\n@UseGuards(JwtAuthGuard, AdminGuard)\nexport class DebugController {\n  @Get('config')\n  getConfig() {\n    // Never return process.env directly — return only what you intend to expose\n    return { env: process.env.NODE_ENV };\n  }\n}\n```\n\nGuarding the endpoint is not enough. A guarded endpoint returning `DATABASE_URL`\n\nis still a credential leak waiting for a token to be compromised. Remove the sensitive values from the response entirely.\n\nAll six findings share a root cause: **the AI fulfilled the prompt, and the prompt didn't specify a security constraint.**\n\nTypeScript can't catch any of these. They all compile, run, and do exactly what the code says they do. 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.\n\nThe question that surfaces all six is: *\"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 ask it either — we're trained to verify correctness, not to verify the absence of incorrect access.\n\nStatic analysis asks it on every file, every run. [The Hydra Problem](https://dev.to/ofri-peretz/the-ai-hydra-problem-fix-one-ai-bug-get-two-more-5g1l) shows what happens when you try to fix AI omissions in review: fixing one surfaces others, and the 65-75% rate held [across every security domain we tested](https://dev.to/ofri-peretz/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain-1hgj). NestJS is no different.\n\n``` python\n// eslint.config.mjs\nimport nestjsSecurity from 'eslint-plugin-nestjs-security';\n\nexport default [\n  {\n    plugins: { 'nestjs-security': nestjsSecurity },\n    rules: {\n      'nestjs-security/require-guards': ['error', { assumeGlobalGuards: false }],\n      'nestjs-security/no-exposed-private-fields': 'error',\n      'nestjs-security/require-throttler': 'error',\n      'nestjs-security/no-missing-validation-pipe': 'error',\n      'nestjs-security/require-class-validator': 'error',\n      'nestjs-security/no-exposed-debug-endpoints': 'error',\n    },\n  },\n];\nnpm install --save-dev eslint-plugin-nestjs-security\nnpx eslint src/\n```\n\nFull rule documentation at [eslint.interlace.tools](https://eslint.interlace.tools). Run this against every AI-generated file before it reaches review.\n\n*Has a missing decorator ever shipped to production in your codebase — AI-generated or otherwise? How far did it get before someone caught it?*\n\n*Part of the AI Security Benchmark Series:*\n\n📦 [ eslint-plugin-nestjs-security](https://www.npmjs.com/package/eslint-plugin-nestjs-security) ·\n\n[GitHub](https://github.com/ofri-peretz) | [X](https://x.com/ofriperetzdev) | [LinkedIn](https://linkedin.com/in/ofri-peretz) | [Dev.to](https://dev.to/ofri-peretz) | [ofriperetz.dev](https://ofriperetz.dev)", "url": "https://wpnews.pro/news/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes", "canonical_source": "https://dev.to/ofri-peretz/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes-51nj", "published_at": "2026-05-29 04:52:38+00:00", "updated_at": "2026-05-29 05:12:23.642732+00:00", "lang": "en", "topics": ["large-language-models", "ai-safety", "ai-tools", "generative-ai", "artificial-intelligence"], "entities": ["Claude", "NestJS", "TypeScript", "ESLint", "Claude Sonnet 4.6", "Ofri Peretz"], "alternates": {"html": "https://wpnews.pro/news/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes", "markdown": "https://wpnews.pro/news/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes.md", "text": "https://wpnews.pro/news/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes.txt", "jsonld": "https://wpnews.pro/news/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes.jsonld"}}