cd /news/large-language-models/claude-wrote-a-nestjs-service-typesc… · home topics large-language-models article
[ARTICLE · art-17209] src=dev.to pub= topic=large-language-models verified=true sentiment=↓ negative

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.

read9 min publishedMay 29, 2026

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, 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 usingAPP_GUARD

to registerJwtAuthGuard

globally can configurenestjs-security/require-guards

withassumeGlobalGuards: 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. And the getting-started guide for all six rules.

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.

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 withoutClassSerializerInterceptor

registered. Add this tomain.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 plaintransform: true

:Record<string, unknown>

, not a class instance.instanceof CreateUserDto

checks will fail and decorated methods won't work as expected. Include it.

nestjs-security/require-class-validator
Property 'role' in CreateUserDto has no class-validator decorator
  /src/users/dto/create-user.dto.ts:5:3

Claude correctly added @IsEmail()

to the email field. It left role: string

bare. This is the subtler failure:

export class CreateUserDto {
  @IsEmail()
  email: string;

  role: string; // no validator
}

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 a developer adds @IsString()

to role

to pass it through the whitelist (a natural refactor), role: 'admin'

becomes a valid payload because @IsString()

doesn't constrain the value — only @IsEnum(UserRole)

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. A developer writing this by hand would immediately ask "what are the valid roles?"

Findings 4 and 5 are coupled: whitelist: true

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

import { IsEmail, IsString, MaxLength, IsEnum } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MaxLength(100)
  name: string;

  @IsEnum(UserRole) // 'user' | 'moderator' — explicit allow-list, not 'admin'
  role: UserRole;
}
nestjs-security/no-exposed-debug-endpoints
Controller path 'debug/config' returns process.env — information disclosure
  /src/users/users.controller.ts:22:3
@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. No rate limiting. Available to any HTTP client that can reach your server.

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: In staging, this endpoint was protected — someone had added @UseGuards(JwtAuthGuard)

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

// Fix: isolate behind an environment-gated module — never conditionally guard
// 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() {
    // Never return process.env directly — return only what you intend to expose
    return { env: process.env.NODE_ENV };
  }
}

Guarding the endpoint is not enough. 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.

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

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

Static analysis asks it on every file, every run. The Hydra Problem 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. NestJS is no different.

// 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
npx eslint src/

Full rule documentation at eslint.interlace.tools. Run this against every AI-generated file before it reaches review.

Has a missing decorator ever shipped to production in your codebase — AI-generated or otherwise? How far did it get before someone caught it?

Part of the AI Security Benchmark Series:

📦 eslint-plugin-nestjs-security ·

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

── more in #large-language-models 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-wrote-a-nestj…] indexed:0 read:9min 2026-05-29 ·