Skip to content
NestJS ns security 5 min read

Input Validation & Sanitization

Every value that crosses your API boundary is hostile until proven otherwise. The cheapest defence is to validate inputs against a strict schema, strip anything you did not explicitly allow, sanitize text that may later render as HTML, and refuse payloads that are absurdly large. NestJS gives you a single global ValidationPipe backed by class-validator and class-transformer that handles most of this declaratively, leaving only output-encoding and query parameterisation to wire up yourself.

Validating and whitelisting with ValidationPipe

The ValidationPipe validates incoming request bodies against a DTO class and rejects anything that fails. Its real security value is the whitelist option: properties that have no validation decorator are silently stripped, so an attacker cannot smuggle extra fields (like isAdmin or roleId) into an object you later persist. Pair it with forbidNonWhitelisted to reject such payloads outright instead of quietly dropping them.

Install the peer dependencies first:

npm install class-validator class-transformer

Register the pipe globally in main.ts so every route is covered:

// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // strip properties not in the DTO
      forbidNonWhitelisted: true, // reject if unknown props are present
      transform: true, // coerce payloads into DTO instances
      transformOptions: { enableImplicitConversion: true },
    }),
  );

  await app.listen(3000);
}
bootstrap();

Now constrain the shape of each input with a DTO. Decorators encode the exact rules — type, length, format, range — and become the whitelist:

// users/dto/create-user.dto.ts
import { IsEmail, IsString, Length, IsInt, Min, Max } from 'class-validator';

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

  @IsString()
  @Length(3, 32)
  username: string;

  @IsInt()
  @Min(13)
  @Max(120)
  age: number;
}
// users/users.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  @Post()
  create(@Body() dto: CreateUserDto) {
    // dto is validated, typed, and free of unknown properties
    return { created: dto.username };
  }
}

A request carrying an extra field is now rejected before your handler runs:

curl -s -X POST http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","username":"jo","age":"40","isAdmin":true}'

Output:

{
  "statusCode": 400,
  "message": [
    "username must be longer than or equal to 3 characters",
    "property isAdmin should not exist"
  ],
  "error": "Bad Request"
}

Always enable whitelist in production. Without it, a mass-assignment bug — where Object.assign(entity, body) copies an unexpected role field — can quietly escalate privileges.

Sanitizing HTML to prevent XSS

Validation confirms a string looks right; it does not make it safe to render. If user text is later injected into an HTML page or email, an attacker can embed <script> tags or event handlers — a stored XSS attack. For any field that will be rendered as markup, sanitize it through an allow-list library such as sanitize-html. The class-transformer @Transform decorator lets you do this inline on the DTO so the cleaned value is what reaches your service.

npm install sanitize-html
// posts/dto/create-post.dto.ts
import { Transform } from 'class-transformer';
import { IsString, Length } from 'class-validator';
import sanitizeHtml from 'sanitize-html';

const clean = (value: string) =>
  sanitizeHtml(value, {
    allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li'],
    allowedAttributes: { a: ['href'] },
  });

export class CreatePostDto {
  @IsString()
  @Length(1, 200)
  title: string;

  @IsString()
  @Transform(({ value }) => clean(value))
  body: string;
}

A <script>alert(1)</script> in the body field is stripped to an empty string before it can be stored. Prefer sanitizing on the way in (so your database is clean) and HTML-escaping on the way out in your templating layer — defence in depth, not one or the other.

Avoiding injection in queries

SQL and NoSQL injection both stem from concatenating untrusted input into a query string. The fix is never to build queries by interpolation; use parameter binding so the driver treats input strictly as data. With TypeORM the query builder and find options parameterise automatically:

// Safe: value is bound, not interpolated
await this.repo
  .createQueryBuilder('u')
  .where('u.email = :email', { email: dto.email })
  .getOne();
// DANGEROUS: never do this — string interpolation enables injection
await this.repo.query(`SELECT * FROM users WHERE email = '${dto.email}'`);

The same rule holds for MongoDB: pass typed objects, and reject query operators in user input. Combining whitelist: true with scalar-typed DTO fields prevents an attacker from sending { "email": { "$ne": null } } as an authentication bypass.

Limiting payload size

An unbounded request body is a denial-of-service vector — a single multi-gigabyte upload can exhaust memory. Cap the size at the HTTP layer. On the default Express adapter, configure the body parser:

import { json, urlencoded } from 'express';

app.use(json({ limit: '100kb' }));
app.use(urlencoded({ extended: true, limit: '100kb' }));

On Fastify, set bodyLimit on the adapter:

import { FastifyAdapter } from '@nestjs/platform-fastify';

const app = await NestFactory.create(
  AppModule,
  new FastifyAdapter({ bodyLimit: 100 * 1024 }), // 100 KB
);

Defence layers at a glance

ThreatPrimary defenceMechanism
Mass assignmentwhitelist + forbidNonWhitelistedstrip/reject unknown props
Malformed inputDTO decorators@IsEmail, @Length, @Min
Stored / reflected XSSsanitize-html + output escapingallow-list tags
SQL / NoSQL injectionparameterised queriesbound placeholders
Oversized payload DoSbody-size limitjson({ limit }), bodyLimit

Best Practices

  • Register a global ValidationPipe with whitelist, forbidNonWhitelisted, and transform enabled.
  • Define a DTO for every request body and decorate every property — undecorated fields are treated as unknown and dropped.
  • Sanitize HTML-bearing fields on input with an allow-list library, and HTML-escape again on output.
  • Never interpolate user input into a query; always use parameter binding or the query builder.
  • Keep DTO fields strictly typed (string, number) so injected query operators are rejected by validation.
  • Cap request body size at the adapter level to blunt memory-exhaustion attacks.
  • Return validation errors without leaking internals; the default 400 message list is safe to expose.
Last updated June 14, 2026
Was this helpful?