Interlace ESLint
ESLint Interlace

API Response Security

Understanding how to prevent sensitive data exposure in API responses across Express, NestJS, and other Node.js frameworks

API Response Security

The Gist: Every API response is a potential data leak—without proper controls, you'll expose passwords, tokens, and PII to clients, regardless of your framework.

Quick Summary
Core ProblemSensitive fields in data models leak into API responses
Universal FixNever return raw database/ORM entities directly
Framework SolutionsExpress: manual mapping, NestJS: class-transformer, GraphQL: field resolvers
PerformanceRuntime transformation adds ~0.5-2ms per request

Why this matters: A single unprotected field can expose sensitive data across millions of API calls. This is a universal problem affecting Express, NestJS, Fastify, and every other Node.js framework.

The Universal Problem

Across all frameworks, the core vulnerability is the same: returning internal data structures directly to clients.

// ❌ DANGEROUS - Works in ANY framework
app.get('/users/:id', async (req, res) => {
  const user = await db.users.findOne(req.params.id);
  res.json(user); // Exposes password, tokens, everything!
});

This pattern appears everywhere:

  • Express: res.json(user)
  • NestJS: return user
  • Fastify: reply.send(user)
  • GraphQL: Resolver returning entity directly
  • tRPC: Procedure returning entity directly

The solution is always the same: transform data before it leaves your API.

Solutions Across Frameworks

Each framework has its own patterns for controlling API responses. Here's how to implement secure responses in the most common Node.js frameworks:

Express.js (Manual Mapping)

// ✅ Explicit field selection
app.get('/users/:id', async (req, res) => {
  const user = await db.users.findOne(req.params.id);

  res.json({
    id: user.id,
    email: user.email,
    // password explicitly omitted
  });
});

// ✅ Using a mapper function
function toUserResponse(user) {
  return {
    id: user.id,
    email: user.email,
    createdAt: user.createdAt,
  };
}

app.get('/users/:id', async (req, res) => {
  const user = await db.users.findOne(req.params.id);
  res.json(toUserResponse(user));
});

NestJS (class-transformer)

import { Exclude } from 'class-transformer';
import { ClassSerializerInterceptor } from '@nestjs/common';

@Entity()
class User {
  id: string;
  email: string;

  @Exclude()
  password: string;
}

@UseInterceptors(ClassSerializerInterceptor)
@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(id); // Automatic serialization
  }
}

Fastify (Schemas)

// ✅ Using JSON Schema for response validation
const userResponseSchema = {
  type: 'object',
  properties: {
    id: { type: 'string' },
    email: { type: 'string' },
    // password not in schema
  },
};

fastify.get(
  '/users/:id',
  {
    schema: {
      response: {
        200: userResponseSchema,
      },
    },
  },
  async (request, reply) => {
    const user = await db.users.findOne(request.params.id);

    // Fastify strips fields not in schema
    return {
      id: user.id,
      email: user.email,
      password: user.password, // This will be removed!
    };
  },
);

GraphQL (Field Resolvers)

// ✅ Explicit field definitions
type User {
  id: ID!
  email: String!
  # password field not exposed in schema
}

// Resolver
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      return db.users.findOne(id); // Full entity
    },
  },
  User: {
    // Only defined fields are exposed
    id: (user) => user.id,
    email: (user) => user.email,
    // No password resolver = not exposed
  },
};

tRPC (Zod Schemas)

import { z } from 'zod';

// ✅ Response schema defines exposed fields
const userResponseSchema = z.object({
  id: z.string(),
  email: z.string(),
  // password not in schema
});

export const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .output(userResponseSchema)
    .query(async ({ input }) => {
      const user = await db.users.findOne(input.id);

      // Must match schema
      return {
        id: user.id,
        email: user.email,
      };
    }),
});

Framework-Specific Gotchas: - Express: No automatic protection—you must manually map every response - NestJS: Interceptor must be enabled; object spreading bypasses it - Fastify: Schema validation only works if you define response schemas - GraphQL: Field resolvers can still leak data if they return nested objects - tRPC: Type safety helps, but runtime validation depends on Zod schemas

NestJS: Deep Dive into Serialization Strategies

The rest of this guide focuses on NestJS-specific patterns, as it has the most sophisticated serialization system. However, the principles apply universally.

Serialization Strategies Comparison

NestJS applications have multiple approaches to controlling what data gets sent to clients. Each has different security and performance characteristics.

✅ Declarative (Recommended)

Use class-transformer decorators (@Exclude(), @Expose()) with ClassSerializerInterceptor for automatic, consistent protection.

⚠️ Explicit Mapping

Manually construct response DTOs in controllers. More verbose but gives complete control and better performance.

❌ Direct Return (Dangerous)

Returning entities directly from controllers. High risk of exposing sensitive fields.

❌ Object Spreading (Bypasses Protection)

Using {...entity} or Object.assign() bypasses all serialization logic and decorators.

Class-Transformer Deep Dive

The class-transformer library is the foundation of NestJS serialization. Understanding its patterns is critical for security.

Basic Exclusion Pattern

import { Exclude } from 'class-transformer';

@Entity()
class User {
  id: string;
  email: string;

  @Exclude()
  password: string;

  @Exclude()
  refreshToken: string;
}

Exposure Strategies

You can choose between exclude-by-default (whitelist) or include-by-default (blacklist):

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

// ✅ Whitelist approach (most secure)
@Exclude()
class UserResponse {
  @Expose()
  id: string;

  @Expose()
  email: string;

  // password is excluded by default
  password: string;
}

// ⚠️ Blacklist approach (easier but riskier)
class UserResponse {
  id: string;
  email: string;

  @Exclude()
  password: string;
}

Security Recommendation: Use the whitelist approach (@Exclude() on the class, @Expose() on safe fields) for sensitive entities. This ensures new fields are private by default.

Enabling the Interceptor

The ClassSerializerInterceptor must be enabled for decorators to work:

// Global (recommended)
import { ClassSerializerInterceptor } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: ClassSerializerInterceptor,
    },
  ],
})
export class AppModule {}

// Per-controller
@UseInterceptors(ClassSerializerInterceptor)
@Controller('users')
export class UsersController {}

// Per-route
@UseInterceptors(ClassSerializerInterceptor)
@Get(':id')
findOne(@Param('id') id: string) {
  return this.usersService.findOne(id);
}

Groups for Context-Aware Serialization

Different endpoints may need to expose different fields:

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

class User {
  @Expose({ groups: ['public', 'admin'] })
  id: string;

  @Expose({ groups: ['public', 'admin'] })
  email: string;

  @Expose({ groups: ['admin'] })
  lastLoginIp: string;

  @Exclude()
  password: string;
}

// In controller
@Get(':id')
@SerializeOptions({ groups: ['public'] })
findOne(@Param('id') id: string) {
  return this.usersService.findOne(id);
}

@Get('admin/:id')
@SerializeOptions({ groups: ['admin'] })
findOneAdmin(@Param('id') id: string) {
  return this.usersService.findOne(id);
}

OpenAPI Integration & Contract Safety

Serialization isn't just about runtime—your API documentation must also hide sensitive fields.

The Double Exposure Problem

import { ApiProperty, ApiHideProperty } from '@nestjs/swagger';
import { Exclude } from 'class-transformer';

class User {
  @ApiProperty()
  id: string;

  @ApiProperty()
  email: string;

  // ❌ WRONG: Hidden at runtime but visible in OpenAPI schema
  @Exclude()
  @ApiProperty()
  password: string;

  // ✅ CORRECT: Hidden both at runtime and in schema
  @Exclude()
  @ApiHideProperty()
  refreshToken: string;
}

Critical: Always pair @Exclude() with @ApiHideProperty() for sensitive fields. Otherwise, your OpenAPI spec will document fields that clients can't actually access, creating confusion and potential security issues.

Dedicated Response DTOs

The most robust approach is to create separate DTOs for responses:

// entities/user.entity.ts
@Entity()
class User {
  id: string;
  email: string;
  password: string;
  refreshToken: string;
}

// dto/user-response.dto.ts
@Exclude()
export class UserResponseDto {
  @Expose()
  @ApiProperty()
  id: string;

  @Expose()
  @ApiProperty()
  email: string;

  // No password or refreshToken fields at all
}

// users.controller.ts
@Get(':id')
@ApiOkResponse({ type: UserResponseDto })
async findOne(@Param('id') id: string): Promise<UserResponseDto> {
  const user = await this.usersService.findOne(id);
  return plainToInstance(UserResponseDto, user);
}

Automatic Schema Generation

NestJS CLI plugin can automatically add @ApiProperty() decorators, but be careful with sensitive fields:

// nest-cli.json
{
  "compilerOptions": {
    "plugins": [
      {
        "name": "@nestjs/swagger",
        "options": {
          "introspectComments": true
        }
      }
    ]
  }
}

Performance Considerations

Serialization has a runtime cost. Understanding the trade-offs helps you make informed decisions.

Benchmark: Serialization Overhead

// Direct return (no serialization)
@Get(':id')
findOne(@Param('id') id: string) {
  return { id, email: 'user@example.com' }; // ~0.1ms
}

// class-transformer serialization
@UseInterceptors(ClassSerializerInterceptor)
@Get(':id')
findOne(@Param('id') id: string) {
  return this.usersService.findOne(id); // ~0.5-2ms
}

// Manual mapping
@Get(':id')
findOne(@Param('id') id: string) {
  const user = this.usersService.findOne(id);
  return { id: user.id, email: user.email }; // ~0.2ms
}
ApproachLatencySecurityMaintainability
Direct return0.1ms❌ Low✅ High
Manual mapping0.2ms⚠️ Medium⚠️ Medium
class-transformer0.5-2ms✅ High✅ High

Performance Tip: For high-throughput endpoints (>1000 req/s), consider manual mapping. For typical CRUD APIs, the security benefits of class-transformer far outweigh the ~1ms overhead.

Caching Transformed Instances

For frequently accessed, rarely changing data:

import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';

@UseInterceptors(ClassSerializerInterceptor, CacheInterceptor)
@CacheTTL(300) // 5 minutes
@Get('public-profile/:id')
getPublicProfile(@Param('id') id: string) {
  return this.usersService.findOne(id);
}

Common Pitfalls & Patterns

Pitfall 1: Object Spreading Bypasses Decorators

// ❌ WRONG: Spreading bypasses @Exclude()
@Get(':id')
async findOne(@Param('id') id: string) {
  const user = await this.usersService.findOne(id);
  return { ...user, extra: 'data' }; // password is exposed!
}

// ✅ CORRECT: Return the instance directly
@UseInterceptors(ClassSerializerInterceptor)
@Get(':id')
async findOne(@Param('id') id: string) {
  return this.usersService.findOne(id);
}

Pitfall 2: Custom toJSON Methods

// ❌ WRONG: toJSON bypasses class-transformer
class User {
  @Exclude()
  password: string;

  toJSON() {
    return { ...this }; // password is exposed!
  }
}

// ✅ CORRECT: Don't override toJSON, or use it carefully
class User {
  @Exclude()
  password: string;

  toJSON() {
    return {
      id: this.id,
      email: this.email,
      // Explicitly omit password
    };
  }
}

Pitfall 3: Nested Objects

// ❌ WRONG: Nested objects aren't transformed by default
class User {
  @Exclude()
  password: string;

  profile: UserProfile; // Not transformed!
}

// ✅ CORRECT: Use @Type() for nested objects
import { Type } from 'class-transformer';

class User {
  @Exclude()
  password: string;

  @Type(() => UserProfile)
  profile: UserProfile;
}

class UserProfile {
  @Exclude()
  privateNotes: string;
}

Pattern: Repository-Level Exclusion

For extra safety, exclude sensitive fields at the database query level:

@Injectable()
export class UsersService {
  async findOne(id: string): Promise<User> {
    // ✅ Never load password into memory unless needed
    return this.userRepository.findOne({
      where: { id },
      select: ['id', 'email', 'createdAt'], // Explicit field list
    });
  }

  async validatePassword(id: string, password: string): Promise<boolean> {
    // Only load password when explicitly needed
    const user = await this.userRepository.findOne({
      where: { id },
      select: ['password'],
    });
    return bcrypt.compare(password, user.password);
  }
}

When to Use Which Approach

Use class-transformer for: - Standard CRUD APIs with entities -

Applications with many endpoints sharing DTOs - Teams with varying security expertise - When OpenAPI documentation is critical

Use manual mapping for: - High-throughput APIs (>1000 req/s per

endpoint) - Simple microservices with few endpoints - When you need fine-grained control over every field - GraphQL resolvers (which have their own field resolution)

Use repository-level exclusion for: - Highly sensitive data (passwords,

tokens, PII) - Compliance requirements (GDPR, HIPAA) - Defense-in-depth strategy - Reducing memory footprint

Security Checklist

Before deploying your NestJS API:

  • ClassSerializerInterceptor is enabled globally or on all controllers
  • All sensitive fields have @Exclude() decorator
  • All sensitive fields have @ApiHideProperty() decorator
  • Response DTOs are explicitly typed in controller signatures
  • No object spreading ({...entity}) in controller returns
  • No custom toJSON() methods that bypass exclusion
  • Nested objects use @Type() decorator
  • OpenAPI schema reviewed for exposed sensitive fields
  • Integration tests verify sensitive fields are not in responses

⚡ Key Takeaways

What To DoWhy
Enable ClassSerializerInterceptor globallyEnsures consistent serialization across all endpoints
Use @Exclude() + @ApiHideProperty()Protects both runtime and documentation
Prefer whitelist approachNew fields are private by default
Avoid object spreadingBypasses all serialization logic
Test your OpenAPI schemaVerify no sensitive fields are documented

📚 Further Reading

On this page