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
whitelistin production. Without it, a mass-assignment bug — whereObject.assign(entity, body)copies an unexpectedrolefield — 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
| Threat | Primary defence | Mechanism |
|---|---|---|
| Mass assignment | whitelist + forbidNonWhitelisted | strip/reject unknown props |
| Malformed input | DTO decorators | @IsEmail, @Length, @Min |
| Stored / reflected XSS | sanitize-html + output escaping | allow-list tags |
| SQL / NoSQL injection | parameterised queries | bound placeholders |
| Oversized payload DoS | body-size limit | json({ limit }), bodyLimit |
Best Practices
- Register a global
ValidationPipewithwhitelist,forbidNonWhitelisted, andtransformenabled. - 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.