Skip to content
ExponaBLOG
NESTJS · DAY 083 / 100

Response serialization: a practical guide for developers

Never leak password hashes again.

Expona Admin8 min read

Response serialization: a practical guide for developers

We've all been there: a security audit finds that your API is returning password hashes, internal IDs, or database-specific fields to clients. Response serialization is your shield against these leaks. In this guide, we'll walk through the practical techniques we use across production systems to ensure that only intended data reaches the client, keeping sensitive information safely behind the API boundary.

Why response serialization matters

When your API endpoint returns an entity directly from the database, you're betting that every single field is safe to expose. That's rarely the case. Password hashes, API keys, internal flags, and cost breakdowns shouldn't exist in responses—yet they slip through constantly when teams treat the database model as the API contract.

Response serialization is the practice of transforming your domain entities into safe output before sending them to clients. In NestJS, we have two primary patterns: using ClassSerializerInterceptor with decorators, or explicitly mapping to Data Transfer Objects (DTOs). Both work; the choice depends on your team's scale and how strict you want to be.

ClassSerializerInterceptor plus @Exclude()

NestJS's built-in ClassSerializerInterceptor offers a quick way to strip fields from responses globally or per-controller. It works by applying decorator metadata to your entity classes, telling the framework which fields to exclude during serialization.

Here's a typical User entity with sensitive fields marked:

typescript
import { Exclude, Expose } from 'class-transformer';

export class User {
  @Expose()
  id: string;

  @Expose()
  email: string;

  @Expose()
  name: string;

  @Exclude()
  passwordHash: string;

  @Exclude()
  refreshTokenHash: string;

  @Exclude()
  internalNotes: string;

  createdAt: Date; // Excluded by default unless explicitly exposed
}

Then enable the interceptor at the controller or application level:

typescript
import { ClassSerializerInterceptor, Controller, Get, UseInterceptors } from '@nestjs/common';

@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
  constructor(private usersService: UsersService) {}

  @Get(':id')
  async getUser(@Param('id') id: string) {
    return this.usersService.findById(id);
  }
}

When a request hits GET /users/123, the service returns the full User entity, but the interceptor strips out fields marked with @Exclude() before serialization. The client receives only id, email, and name.

This approach is fast to implement and works well for smaller teams or simple entities. However, it has drawbacks. Your entity becomes tightly coupled to your API contract, making it harder to evolve. When you need different response shapes for different endpoints—say, a summary view versus a detailed view—you end up with conditional decorators or multiple entity classes, which gets messy quickly.

Explicit mapping to response DTOs

The more robust pattern, especially for production systems, is to define explicit response DTOs and map your entities to them. This keeps your domain model clean, your API contract crystal clear, and your responses under strict control.

Start by defining a response DTO:

typescript
export class UserResponseDto {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

Then create a mapper service or use a library like @nestjs/mapped-types or class-transformer to handle the transformation:

typescript
import { Injectable } from '@nestjs/common';
import { User } from './entities/user.entity';
import { UserResponseDto } from './dto/user-response.dto';

@Injectable()
export class UserMapper {
  toResponseDto(user: User): UserResponseDto {
    return {
      id: user.id,
      email: user.email,
      name: user.name,
      createdAt: user.createdAt,
    };
  }

  toResponseDtoArray(users: User[]): UserResponseDto[] {
    return users.map(user => this.toResponseDto(user));
  }
}

Now use it in your controller:

typescript
@Controller('users')
export class UsersController {
  constructor(
    private usersService: UsersService,
    private userMapper: UserMapper,
  ) {}

  @Get(':id')
  async getUser(@Param('id') id: string) {
    const user = await this.usersService.findById(id);
    return this.userMapper.toResponseDto(user);
  }

  @Get()
  async listUsers() {
    const users = await this.usersService.findAll();
    return this.userMapper.toResponseDtoArray(users);
  }
}

This is more verbose upfront, but every field that leaves your API is explicit. When a junior developer adds a new field to the User entity, it won't accidentally leak in responses unless they consciously add it to the DTO and mapper.

Why explicit mapping is clearer for big teams

In a team of five, shared understanding holds things together. In a team of fifty spread across multiple services, you need the code itself to enforce the contract. Explicit mapping does exactly that.

Consider a refactor where you need to return a user summary in list endpoints but detailed information in single-entity endpoints. With decorators and ClassSerializerInterceptor, you'd reach for conditional logic or multiple entity classes. With explicit DTOs, you write two separate response types:

typescript
export class UserSummaryResponseDto {
  id: string;
  name: string;
}

export class UserDetailResponseDto {
  id: string;
  email: string;
  name: string;
  phone: string;
  createdAt: Date;
}

Your controller then explicitly selects which shape to return. The OpenAPI schema generated from your DTOs automatically documents both. New team members reading the code immediately understand what data is exposed where. There's no hidden logic in decorators; it's all plain to see.

For larger teams, this clarity prevents bugs. It's also easier to track. A code review of a change to a DTO is straightforward; a review of a decorator change might miss side effects across multiple controllers.

Testing responses for sensitive fields

Even with the best intentions, sensitive fields slip through. Integration tests are your safety net. Write tests that verify your responses don't contain forbidden fields, and run them on every API endpoint.

Here's a practical test suite:

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController - Response Serialization', () => {
  let app: INestApplication;
  let usersService: UsersService;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [UsersService],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
    usersService = moduleFixture.get(UsersService);
  });

  afterAll(async () => {
    await app.close();
  });

  it('should not expose passwordHash in GET /users/:id', async () => {
    const user = {
      id: '1',
      email: 'test@example.com',
      name: 'Test User',
      passwordHash: '$2b$10$...',
      refreshTokenHash: 'secret',
    };

    jest.spyOn(usersService, 'findById').mockResolvedValue(user as any);

    const response = await request(app.getHttpServer())
      .get('/users/1')
      .expect(200);

    expect(response.body).toHaveProperty('id');
    expect(response.body).toHaveProperty('email');
    expect(response.body).toHaveProperty('name');
    expect(response.body).not.toHaveProperty('passwordHash');
    expect(response.body).not.toHaveProperty('refreshTokenHash');
  });

  it('should not expose internalNotes in list responses', async () => {
    const users = [
      {
        id: '1',
        email: 'user1@example.com',
        name: 'User One',
        internalNotes: 'Do not trust this user',
      },
    ];

    jest.spyOn(usersService, 'findAll').mockResolvedValue(users as any);

    const response = await request(app.getHttpServer())
      .get('/users')
      .expect(200);

    expect(Array.isArray(response.body)).toBe(true);
    response.body.forEach(user => {
      expect(user).not.toHaveProperty('internalNotes');
    });
  });

  it('should include only allowed fields', async () => {
    const user = {
      id: '1',
      email: 'test@example.com',
      name: 'Test User',
      createdAt: new Date(),
      passwordHash: 'secret',
      apiKey: 'key123',
      lastLoginIp: '192.168.1.1',
    };

    jest.spyOn(usersService, 'findById').mockResolvedValue(user as any);

    const response = await request(app.getHttpServer())
      .get('/users/1')
      .expect(200);

    const allowedFields = ['id', 'email', 'name', 'createdAt'];
    const responseKeys = Object.keys(response.body);

    expect(responseKeys.sort()).toEqual(allowedFields.sort());
  });
});

These tests catch regressions. When someone accidentally exposes passwordHash, the suite fails immediately. Run these as part of your CI/CD pipeline to prevent sensitive data leaks from reaching production.

Common mistakes

Returning the entity directly without serialization. This is the number-one cause of data leaks. Always transform before returning, even if it seems safe now. Requirements change.

Mixing serialization approaches. If you use ClassSerializerInterceptor on some controllers and explicit DTOs on others, you create inconsistency. A missing decorator or DTO on a new endpoint becomes a vulnerability. Pick one pattern and stick to it across your codebase.

Forgetting nested objects. If your User entity has a related Company object with sensitive fields, both the User and Company need serialization. Nested objects leak data just as easily as top-level fields.

Skipping tests. Manual verification isn't enough. Automated tests catch edge cases and prevent regressions. Test both positive cases (allowed fields present) and negative cases (forbidden fields absent).

Assuming decorators cover everything. @Exclude() only works if the field is defined and decorated. A forgotten field or a typo means it still gets serialized. Explicit DTOs force you to list every field.

Key takeaways

  • Response serialization is essential: never assume all entity fields are safe to expose to clients.
  • ClassSerializerInterceptor with @Exclude() is quick but couples your entity to your API contract.
  • Explicit DTO mapping is more verbose but provides clarity, maintainability, and stronger guarantees in larger teams.
  • Always test your responses to verify that sensitive fields like password hashes, tokens, and internal notes are excluded.
  • Choose one serialization pattern and apply it consistently across all endpoints to prevent gaps.
  • Nested objects require serialization too; don't forget related entities.
  • A code review of a DTO or mapper is straightforward; a review of decorator logic is not—favor clarity for team velocity.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles