DEV Community

Cover image for Claude Wrote a NestJS Service. TypeScript Was Happy. ESLint Found 6 Security Holes.
Ofri Peretz
Ofri Peretz

Posted on • Originally published at ofriperetz.dev

Claude Wrote a NestJS Service. TypeScript Was Happy. ESLint Found 6 Security Holes.

TypeScript passed it clean. The code ran. I would have approved it in review. 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 to catch exactly these patterns.

6 errors. 0 warnings. 3 seconds.

Every AI-generated NestJS service I've tested ships password in the response body — 8 services across 3 different teams, all using Claude or GPT-4. This run was no different — it also shipped an admin endpoint with no auth guard, a login route with no rate limit, and a debug endpoint returning DATABASE_URL. I found the equivalent of that last one live in a staging environment four months after it was deployed, in under 60 seconds. Those are the six findings below.

This isn't a one-off. In a 700-function benchmark across 5 AI models, Claude's vulnerability rate was 65–75%. The specific count in your run will vary — LLM output is non-deterministic — but the failure classes are consistent. The missing-guard pattern does not disappear on a retry.


What Claude generated

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

Claude also generated the entity and DTOs referenced below — all from the same single prompt.

TypeScript: ✅ Clean.
Runtime: ✅ Would work.
ESLint: ❌ 6 errors.

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.


Finding 1: No auth guards (CWE-284)

nestjs-security/require-guards

nestjs-security/require-guards
Controller 'UsersController' lacks @UseGuards for access control
  /src/users/users.controller.ts:2:1
Enter fullscreen mode Exit fullscreen mode

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 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.

// The rule fires at class scope (2:1) 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')
  async listAllUsers() {
    return this.usersService.findAll();
  }
}
Enter fullscreen mode Exit fullscreen mode

False-positive note for CI: Teams registering JwtAuthGuard as an APP_GUARD globally can set assumeGlobalGuards: true to suppress false positives on controllers that inherit protection. The rule also handles guards applied via inheritance and re-exported consts — it reads the decorator tree, not just immediate decorators on the class.

See also: the same missing-guard pattern in a 2-year-old production codebase, and why every PR approved it.


Finding 2: No rate limiting on auth endpoints (CWE-307)

nestjs-security/require-throttler

nestjs-security/require-throttler
Route 'login' lacks @Throttle or ThrottlerGuard — brute-force exposure
  /src/users/users.controller.ts:10:3
Enter fullscreen mode Exit fullscreen mode

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 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.

// requires @nestjs/throttler@^5 — ttl is in milliseconds (v4 and earlier used seconds)
@Post('login')
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } }) // 60 seconds
async login(@Body() dto: LoginDto) {
  return this.usersService.login(dto);
}
Enter fullscreen mode Exit fullscreen mode

Necessary, not sufficient: Per-IP throttling raises the cost of single-source enumeration. It does not stop distributed credential-stuffing from rotating source IPs. That requires anomaly detection at a different layer — @Throttle is the floor, not the ceiling.


Finding 3: Sensitive fields in API responses (CWE-200)

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;

  @Column()
  @Exclude()
  refreshToken: string;
}
Enter fullscreen mode Exit fullscreen mode

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)));

Finding 4: No runtime input validation (CWE-20)

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
Enter fullscreen mode Exit fullscreen mode

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 disappear at runtime. ValidationPipe 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.

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.

// 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
  })
);
Enter fullscreen mode Exit fullscreen mode

AI-specific miss: nested validation. 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 most frequent ValidationPipe hole in AI-generated NestJS code and it has no ESLint error: TypeScript compiles, validation appears to run, the nested object passes through unchecked.


Finding 5: DTO fields without enum constraints (CWE-915)

nestjs-security/require-class-validator

nestjs-security/require-class-validator
Property 'role' in CreateUserDto has no class-validator decorator
  /src/users/dto/create-user.dto.ts:5:3
Enter fullscreen mode Exit fullscreen mode
export class CreateUserDto {
  @IsEmail()
  email: string;

  role: string; // no validator
}
Enter fullscreen mode Exit fullscreen mode

This is mass assignment — CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes). 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 ValidationPipe({ whitelist: true }), an undecorated role field is stripped — which sounds safe. It isn't, for a specific reason: developers add decorators later. When someone adds @IsString() to role to pass it through the whitelist (a natural refactor), role: 'admin' becomes a valid payload. @IsString() doesn't constrain the value — only @IsEnum(SelfAssignableRole) does.

Why AI generates this: Claude adds validation for fields where the constraint is obvious from the semantic type (email@IsEmail()). For role, valid values are a domain-specific enum with no tutorial default. The model can't infer the allowed values from an unspecified domain.

Why it survives review: Reviewers see @IsEmail() on email and pattern-match "this DTO is validated." They don't audit field by field for the one bare property. role typically arrives as a quick patch after the initial commit — nobody circles back.

Findings 4 and 5 are coupled: whitelist: true strips unknown keys. It doesn't constrain 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.

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;
}
Enter fullscreen mode Exit fullscreen mode

Finding 6: Debug endpoint exposing credentials (CWE-489)

nestjs-security/no-exposed-debug-endpoints

nestjs-security/no-exposed-debug-endpoints
Controller path 'debug/config' returns process.env — information disclosure
  /src/users/users.controller.ts:25:3
Enter fullscreen mode Exit fullscreen mode
@Get('debug/config')
async getConfig() {
  return { env: process.env.NODE_ENV, db: process.env.DATABASE_URL };
}
Enter fullscreen mode Exit fullscreen mode

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. It had been live for four months.

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 the same reason — the code does what it says, the name implies "development only," and nothing breaks when it ships. The linter doesn't assume intent. It sees process.env in a response and fires.

Guarding is not a fix. A guarded endpoint returning DATABASE_URL is still a credential leak waiting for a token to be compromised. Remove the sensitive values from the response entirely.

// 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.module.ts — completely absent in production builds
@Controller('debug')
@UseGuards(JwtAuthGuard, AdminGuard)
export class DebugController {
  @Get('config')
  getConfig() {
    return { env: process.env.NODE_ENV }; // never return DATABASE_URL
  }
}
Enter fullscreen mode Exit fullscreen mode

The pattern: AI optimizes for compilation, not for absence

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.

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.

Static analysis asks it on every file, every run. The Hydra Problem shows what happens when you try to fix AI omissions one at a time in review: fixing one surfaces others. The 65–75% rate held across every security domain we tested. NestJS is no exception.


The config

// 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',
    },
  },
];
Enter fullscreen mode Exit fullscreen mode
npm install --save-dev eslint-plugin-nestjs-security
Enter fullscreen mode Exit fullscreen mode

Note: NestJS is always TypeScript. Add these rules to your existing typescript-eslint configuration — the config above assumes languageOptions.parser and parserOptions.project are already set. Running eslint src/ without the TS parser will fail on decorators.

Full rule documentation at eslint.interlace.tools. New to the plugin? Architectural Security: The NestJS Static Analysis Standard covers the full rule set end to end.


What's the most embarrassing thing a debug endpoint or an unguarded route has leaked in a codebase you inherited — and how long had it been live?


Part of the AI Security Benchmark Series:
I Let Claude Write 80 Functions. 65-75% Had Security Vulnerabilities. | **Claude Wrote a NestJS Service (you are here)* | Aggregate Benchmarks Lie →*


📦 eslint-plugin-nestjs-security · Rule docs

⭐ Star on GitHub


GitHub | X | LinkedIn | Dev.to | ofriperetz.dev

Top comments (1)

Collapse
 
cryptokeesan profile image
keesan.eth

Yep. TypeScript being happy doesnt mean the agent was safe. The expensive bug is the one that compiles clean and quietly widens blast radius.