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 Problem | Sensitive fields in data models leak into API responses |
| Universal Fix | Never return raw database/ORM entities directly |
| Framework Solutions | Express: manual mapping, NestJS: class-transformer, GraphQL: field resolvers |
| Performance | Runtime 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
}| Approach | Latency | Security | Maintainability |
|---|---|---|---|
| Direct return | 0.1ms | ❌ Low | ✅ High |
| Manual mapping | 0.2ms | ⚠️ Medium | ⚠️ Medium |
| class-transformer | 0.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:
-
ClassSerializerInterceptoris 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 Do | Why |
|---|---|
| Enable ClassSerializerInterceptor globally | Ensures consistent serialization across all endpoints |
| Use @Exclude() + @ApiHideProperty() | Protects both runtime and documentation |
| Prefer whitelist approach | New fields are private by default |
| Avoid object spreading | Bypasses all serialization logic |
| Test your OpenAPI schema | Verify no sensitive fields are documented |
🔗 Related Rules
no-exposed-private-fields
Detects sensitive fields without @Exclude()
no-sensitive-payload
Prevents sensitive data in JWT payloads
📚 Further Reading
- class-transformer Documentation - Official guide to serialization decorators
- NestJS Serialization - Framework-specific patterns
- OpenAPI Security - API contract security best practices
- OWASP API Security Top 10 - API3:2023 Broken Object Property Level Authorization
Security Standards & CVE Ecosystem
Understanding OWASP, CWE, CVE, and CVSS - the vulnerability taxonomy that powers security tooling worldwide
Fixable vs. Non-Fixable Rules
Understanding why some security issues can be auto-fixed while others require human judgment, and how AI is changing this landscape