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

> Source: <https://dev.to/ofri-peretz/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes-51nj>
> Published: 2026-05-29 04:52:38+00:00

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

``` js
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](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.

``` python
// 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](https://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](https://www.npmjs.com/package/eslint-plugin-nestjs-security) ·

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