How to Properly Sanitize Input in NestJS: Essential Steps for Secure DTOs Beyond Validation

In modern web development, securing user input is non-negotiable. Malicious actors often exploit unsanitized data to launch attacks like SQL injection, cross-site scripting (XSS), or data corruption. While validation ensures data meets expected formats (e.g., "email must be valid"), sanitization goes further: it cleans, transforms, or modifies input to eliminate risks before it reaches your application’s core.

NestJS, a popular Node.js framework, leverages Data Transfer Objects (DTOs) to manage input/output. But many developers stop at validation (using class-validator), missing critical sanitization steps. This blog will guide you through sanitizing DTOs in NestJS, ensuring your application is resilient against common vulnerabilities.

Table of Contents#

  1. Understanding DTOs: The Foundation of Input Handling
  2. Validation vs. Sanitization: What’s the Difference?
  3. Essential Tools: class-validator and class-transformer
  4. Step 1: Setting Up the Validation Pipe
  5. Step 2: Whitelisting and Stripping Unknown Properties
  6. Step 3: Built-in Sanitization with class-transformer Decorators
  7. Step 4: Custom Sanitization with @Transform
  8. Step 5: Sanitizing Nested Objects and Arrays
  9. Step 6: Sanitizing Specific Data Types
  10. Common Pitfalls to Avoid
  11. Real-World Example: Sanitizing a User Registration DTO
  12. Advanced: Custom Sanitization Pipes
  13. Testing Input Sanitization
  14. Conclusion: Building Secure DTOs
  15. References

1. Understanding DTOs: The Foundation of Input Handling#

DTOs (Data Transfer Objects) are classes that define the structure of data passed between layers of your application (e.g., from the client to the API). In NestJS, DTOs act as a contract for input validation and sanitization, ensuring only expected, safe data enters your system.

Example of a basic DTO:

// user.dto.ts
export class CreateUserDto {
  email: string;
  username: string;
  age: number;
}

Without sanitization, this DTO would accept raw, unprocessed input—leaving your app vulnerable to attacks like XSS (if username contains HTML) or data inconsistency (e.g., email with accidental uppercase letters).

2. Validation vs. Sanitization: What’s the Difference?#

  • Validation: Checks if input meets rules (e.g., "email must be a valid format," "age must be ≥ 18"). It answers: "Is this data valid?"
  • Sanitization: Modifies input to make it safe and consistent (e.g., trimming whitespace, escaping HTML, converting email to lowercase). It answers: "Is this data safe to use?"

Why both matter: Even valid data can be unsafe. For example, a valid email like [email protected] is technically correct but inconsistent—sanitizing it to [email protected] prevents duplicates. Similarly, a valid username with HTML (<script>...</script>) is dangerous without sanitization.

3. Essential Tools: class-validator and class-transformer#

NestJS relies on two libraries for DTO handling:

  • class-validator: Provides decorators for validation (e.g., @IsEmail(), @Min(18)).
  • class-transformer: Transforms raw input (e.g., JSON from HTTP requests) into class instances, enabling sanitization via decorators like @Transform.

Install them first:

npm install class-validator class-transformer

4. Step 1: Setting Up the Validation Pipe#

NestJS uses pipes to process input data. The ValidationPipe (built into NestJS) integrates class-validator and class-transformer to validate and sanitize DTOs globally.

Configure it in main.ts to apply sanitization across your app:

// 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);
 
  // Global validation/sanitization pipe
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true, // Enable class-transformer to transform input into DTO instances
      transformOptions: {
        enableImplicitConversion: true, // Auto-convert types (e.g., string "18" → number 18)
      },
    }),
  );
 
  await app.listen(3000);
}
bootstrap();
  • transform: true: Ensures raw input (e.g., JSON) is converted into a DTO class instance, making sanitization decorators work.
  • enableImplicitConversion: Safely converts strings to numbers/booleans where needed (e.g., query params like ?age=18number 18).

5. Step 2: Whitelisting and Stripping Unknown Properties#

Attackers often send extra, unexpected fields (e.g., isAdmin: true) to exploit vulnerabilities. Whitelisting ensures only properties defined in the DTO are allowed.

Update the ValidationPipe to enable whitelisting:

new ValidationPipe({
  whitelist: true, // Strip properties not defined in the DTO
  forbidNonWhitelisted: true, // Throw an error if unknown properties are present (optional but strict)
  transform: true,
})

Example: If a client sends { email: "[email protected]", isAdmin: true } to a DTO with only email, isAdmin will be stripped (whitelisting) or trigger an error (if forbidNonWhitelisted: true).

6. Step 3: Built-in Sanitization with class-transformer Decorators#

class-transformer provides decorators to sanitize input without writing custom logic. Here are the most useful ones:

Trimming Strings#

Use @Transform to remove leading/trailing whitespace from strings:

import { Transform } from 'class-transformer';
 
export class CreateUserDto {
  @Transform(({ value }) => value?.trim()) // Trim whitespace
  username: string;
}

Converting Data Types#

Force input into a specific type (e.g., string → number):

import { Transform } from 'class-transformer';
 
export class CreateProductDto {
  @Transform(({ value }) => Number(value)) // Convert input to number
  price: number;
}

Escaping HTML#

Prevent XSS attacks by escaping HTML in user-generated content (e.g., bios, comments):

import { Transform } from 'class-transformer';
import { escape } from 'html-escaper'; // Install with: npm install html-escaper
 
export class CreatePostDto {
  @Transform(({ value }) => escape(value)) // Escape HTML tags
  content: string;
}

Lowercasing/Uppercasing Strings#

Normalize strings (e.g., emails, usernames) to avoid duplicates:

import { Transform } from 'class-transformer';
 
export class CreateUserDto {
  @Transform(({ value }) => value?.toLowerCase()) // Convert email to lowercase
  email: string;
}

7. Step 4: Custom Sanitization with @Transform#

For complex sanitization (e.g., clamping numbers, formatting phone numbers), use @Transform with a custom function.

Example: Clamping a Number#

Ensure age is between 18 and 120:

import { Transform } from 'class-transformer';
 
export class CreateUserDto {
  @Transform(({ value }) => {
    const num = Number(value);
    return Math.min(Math.max(num, 18), 120); // Clamp between 18 and 120
  })
  age: number;
}

Example: Formatting Phone Numbers#

Normalize phone numbers to +1234567890 format:

import { Transform } from 'class-transformer';
 
export class CreateContactDto {
  @Transform(({ value }) => {
    // Remove non-digit characters and prepend country code
    const cleaned = value.replace(/\D/g, '');
    return `+${cleaned}`;
  })
  phone: string;
}

8. Step 5: Sanitizing Nested Objects and Arrays#

Nested objects (e.g., addresses) or arrays (e.g., list of tags) require sanitization too. Define nested DTOs and apply sanitization decorators there.

Example: Nested Address DTO#

// address.dto.ts
import { Transform } from 'class-transformer';
 
export class AddressDto {
  @Transform(({ value }) => value?.trim())
  street: string;
 
  @Transform(({ value }) => value?.toUpperCase())
  city: string;
}
 
// user.dto.ts
import { Type } from 'class-transformer';
import { AddressDto } from './address.dto';
 
export class CreateUserDto {
  // ... other fields ...
 
  @Type(() => AddressDto) // Transform nested object to AddressDto instance
  address: AddressDto;
}

Example: Sanitizing Arrays#

Sanitize each element in an array (e.g., tags):

import { Transform, Type } from 'class-transformer';
 
export class CreatePostDto {
  @Type(() => String) // Ensure array elements are strings
  @Transform(({ value }) => 
    value.map((tag: string) => tag.trim().toLowerCase()) // Trim and lowercase each tag
  )
  tags: string[];
}

9. Step 6: Sanitizing Specific Data Types#

Different data types require unique sanitization logic. Here’s how to handle common types:

Strings#

  • Trim whitespace.
  • Escape HTML (for user-generated content).
  • Normalize case (e.g., emails to lowercase).

Numbers#

  • Clamp to min/max values (e.g., age ≥ 18).
  • Round to fixed decimals (e.g., price to 2 decimals).

Dates#

  • Parse raw strings (e.g., "2024-01-01") into Date objects.
  • Validate date ranges (e.g., "birthdate must be in the past").

Booleans#

  • Convert "true"/"false" strings to actual booleans:
    @Transform(({ value }) => value === 'true' || value === true)
    isActive: boolean;

10. Common Pitfalls to Avoid#

  • Forgetting transform: true: Without transform: true in ValidationPipe, class-transformer won’t process decorators like @Transform.
  • Sanitizing After Validation: Sanitize before validation (e.g., lowercase an email before checking if it’s valid with @IsEmail()).
  • Ignoring Nested Objects: Always use @Type(() => NestedDto) for nested objects—otherwise, sanitization decorators in nested DTOs won’t run.
  • Over-Sanitizing: Avoid stripping valid characters (e.g., hyphens in phone numbers) unless necessary.

11. Real-World Example: Sanitizing a User Registration DTO#

Let’s combine all steps into a RegisterUserDto that sanitizes email, username, bio, age, and address:

// register-user.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { escape } from 'html-escaper';
import { AddressDto } from './address.dto';
 
export class RegisterUserDto {
  @Transform(({ value }) => value?.trim().toLowerCase()) // Trim + lowercase
  @IsEmail() // Validate after sanitization
  email: string;
 
  @Transform(({ value }) => value?.trim()) // Trim whitespace
  @IsString()
  @MinLength(3)
  @MaxLength(20)
  username: string;
 
  @Transform(({ value }) => escape(value)) // Escape HTML
  @IsString()
  bio: string;
 
  @Transform(({ value }) => {
    const age = Number(value);
    return Math.min(Math.max(age, 18), 120); // Clamp between 18-120
  })
  @Min(18) // Validate after clamping
  age: number;
 
  @Type(() => AddressDto) // Sanitize nested address
  address: AddressDto;
}

12. Advanced: Custom Sanitization Pipes#

For reusable sanitization logic (e.g., across multiple DTOs), create a custom pipe.

Example: TrimPipe for All Strings#

// trim.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
 
@Injectable()
export class TrimPipe implements PipeTransform {
  transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.isDto(metatype)) {
      return value; // Skip if not a DTO
    }
 
    // Convert value to DTO instance and trim all string properties
    const instance = plainToInstance(metatype, value);
    for (const key in instance) {
      if (typeof instance[key] === 'string') {
        instance[key] = instance[key].trim();
      }
    }
    return instance;
  }
 
  private isDto(metatype: Function): boolean {
    return metatype === String || metatype === Number ? false : true;
  }
}

Use it in a controller:

@Post('register')
@UsePipes(new TrimPipe()) // Apply TrimPipe to this endpoint
register(@Body() dto: RegisterUserDto) {
  // dto.username is now trimmed!
}

13. Testing Input Sanitization#

Ensure sanitization works with unit tests. Use Jest to verify transformed output.

Example Test for RegisterUserDto#

// register-user.dto.spec.ts
import { plainToInstance } from 'class-transformer';
import { RegisterUserDto } from './register-user.dto';
 
describe('RegisterUserDto', () => {
  it('should sanitize email to lowercase and trim', () => {
    const rawInput = { email: '  [email protected]  ' };
    const dto = plainToInstance(RegisterUserDto, rawInput);
    expect(dto.email).toBe('[email protected]');
  });
 
  it('should escape HTML in bio', () => {
    const rawInput = { bio: '<script>alert("xss")</script>' };
    const dto = plainToInstance(RegisterUserDto, rawInput);
    expect(dto.bio).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
  });
});

14. Conclusion: Building Secure DTOs#

Input sanitization is a critical layer of defense in NestJS applications. By combining class-validator (validation) and class-transformer (sanitization), you ensure data is both valid and safe. Remember to:

  • Use ValidationPipe with transform: true and whitelist: true.
  • Sanitize early (before validation).
  • Handle nested objects with @Type.
  • Test sanitization logic to catch regressions.

With these steps, you’ll build DTOs that protect your app from data leaks, XSS, and inconsistencies.

15. References#