Error handling is one of the most overlooked attack surfaces in API security. While developers focus on authentication, authorization, and input validation, a poorly configured error response can silently leak database schemas, internal file paths, stack traces, and environment details handing attackers a roadmap to your system.
The solution is secure, centralized error handling, intercepting all exceptions at a global level, sanitizing what gets returned to the client, and ensuring that internal details stay internal.
In this guide, you'll learn how to implement global exception filters in NestJS, design a secure error response schema, prevent sensitive data leaks, and structure error handling for both development and production environments.
Why API Error Responses Are a Security Risk
Consider this typical unhandled exception response from an Express or NestJS app in development mode:
{
"statusCode": 500,
"message": "QueryFailedError: column \"usr.emailAdress\" does not exist",
"stack": "QueryFailedError: column \"usr.emailAdress\" does not exist\n at PostgresQueryRunner (/app/node_modules/typeorm/src/...)\n at /app/src/users/users.service.ts:42:18"
}
This single response reveals:
- The database engine (PostgreSQL via TypeORM)
- The exact column name that failed, confirming your schema structure
- The internal file path of your application
- The ORM library and version in use
- The service layer architecture of your backend
This type of information disclosure is classified under OWASP API Security Top 10 - API3: Excessive Data Exposure and is trivially exploitable by anyone probing your API.
The Secure Error Response Standard
Before writing any code, define what a safe error response looks like. A production API error should expose only:
- A generic status code (HTTP standard)
- A user-friendly message that reveals nothing about internals
- A unique error ID for traceability in logs (without exposing logs to the client)
- Optionally, a machine-readable error code for client-side handling
{
"statusCode": 500,
"errorCode": "INTERNAL_SERVER_ERROR",
"message": "An unexpected error occurred. Please try again later.",
"errorId": "a3f92c1d-8e44-4b2e-9f3a-1c7d5b2e4a8f",
"timestamp": "2025-04-04T10:23:45.123Z",
"path": "/api/users"
}
The errorId is critical, it allows your support team to correlate the client-facing error to a detailed internal log entry, without ever exposing that detail publicly.
Implementing a Global Exception Filter in NestJS
NestJS provides a built-in exception filter system. By default, it catches HttpException instances and returns structured responses, but unhandled errors (database failures, third-party timeouts, unexpected nulls) fall through as raw 500 responses.
A global exception filter intercepts every thrown exception, handled or not, and applies consistent, secure formatting.
Step 1: Create the Global Exception Filter
// src/common/filters/global-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const errorId = uuidv4();
const timestamp = new Date().toISOString();
const path = request.url;
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'An unexpected error occurred. Please try again later.';
let errorCode = 'INTERNAL_SERVER_ERROR';
if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
// Use the HttpException message but sanitize it
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || message;
errorCode = this.resolveErrorCode(statusCode);
}
// Log the full error internally - never send this to the client
this.logger.error({
errorId,
statusCode,
path,
message: exception instanceof Error ? exception.message : 'Unknown error',
stack: exception instanceof Error ? exception.stack : undefined,
timestamp,
});
// Send the sanitized response to the client
response.status(statusCode).json({
statusCode,
errorCode,
message,
errorId,
timestamp,
path,
});
}
private resolveErrorCode(statusCode: number): string {
const codes: Record<number, string> = {
400: 'BAD_REQUEST',
401: 'UNAUTHORIZED',
403: 'FORBIDDEN',
404: 'NOT_FOUND',
409: 'CONFLICT',
422: 'UNPROCESSABLE_ENTITY',
429: 'TOO_MANY_REQUESTS',
500: 'INTERNAL_SERVER_ERROR',
};
return codes[statusCode] ?? 'INTERNAL_SERVER_ERROR';
}
}
Step 2 - Register the Filter Globally
Apply the filter at the application level so it catches exceptions from every controller, service, and middleware:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new GlobalExceptionFilter());
await app.listen(3000);
}
bootstrap();
Registering via useGlobalFilters() ensures the filter runs outside the dependency injection scope, catching even bootstrap-level errors.
Handling Validation Errors Securely
NestJS's ValidationPipe throws BadRequestException with a detailed array of validation failures. These are safe to return, they contain field-level feedback with no internal details. Ensure your filter preserves this structure for 400 errors:
// In GlobalExceptionFilter - extend the HttpException branch
if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (statusCode === 400 && typeof exceptionResponse === 'object') {
// Preserve validation error details for client-side form handling
return response.status(statusCode).json({
statusCode,
errorCode: 'VALIDATION_ERROR',
message: 'Request validation failed.',
errors: (exceptionResponse as any).message, // array of field errors
errorId,
timestamp,
path,
});
}
}
This gives clients enough information to display helpful form errors, without leaking anything about internals.
Environment-Aware Error Detail
During local development, stack traces are invaluable. In production, they're a liability. Use an environment flag to control detail level:
const isDevelopment = process.env.NODE_ENV === 'development';
// In the catch method — add debug info only in development
response.status(statusCode).json({
statusCode,
errorCode,
message,
errorId,
timestamp,
path,
...(isDevelopment && {
debug: {
originalMessage: exception instanceof Error ? exception.message : null,
stack: exception instanceof Error ? exception.stack : null,
},
}),
});
This pattern ensures developers get rich error context locally, while production clients always receive the sanitized version.
Preventing Information Leaks Beyond the Filter
The global filter handles runtime exceptions, but sensitive data can leak through other vectors too. Address these proactively:
Disable the X-Powered-By Header
By default, Express advertises the framework in response headers:
const app = await NestFactory.create(AppModule, { rawBody: true });
app.getHttpAdapter().getInstance().disable('x-powered-by');
Sanitize Database Error Messages
ORM errors (TypeORM, Prisma) contain raw SQL and schema details. Never let them propagate as HttpException messages. Catch them at the service layer:
async findUser(id: string) {
try {
return await this.usersRepository.findOneOrFail({ where: { id } });
} catch (error) {
// Log the ORM error internally, throw a clean HttpException
this.logger.error('Database error in findUser', error);
throw new NotFoundException('User not found.');
}
}
Avoid Reflecting User Input in Error Messages
Never echo back user-supplied values in error messages, this can enable reflected XSS or information probing:
// Unsafe — reflects user input
throw new BadRequestException(`User with email ${dto.email} already exists`);
// Safe — generic message
throw new ConflictException('An account with this email already exists.');
Structured Logging for Traceability
The errorId in your response is only useful if your logging system captures it with full context. Use a structured logger like Winston or Pino to ensure every log entry is queryable:
this.logger.error({
errorId, // correlates to client-facing error
userId: request.user?.id,
method: request.method,
path: request.url,
statusCode,
exceptionType: exception?.constructor?.name,
message: exception instanceof Error ? exception.message : 'Unknown',
stack: exception instanceof Error ? exception.stack : undefined,
});
With this structure, when a user reports an issue with their errorId, your team can instantly pull the full context from your log aggregator (Datadog, ELK, CloudWatch), without ever having exposed that detail publicly.
Security Checklist
Before shipping your error handling to production, verify the following:
- Stack traces never appear in API responses
- Database error messages are caught and replaced at the service layer
- ORM or driver names are not present in any response body or header
-
X-Powered-Byheader is disabled - Validation errors return field names but never internal paths or schema details
- Every unhandled exception generates a unique
errorIdand is logged with full context -
NODE_ENVcontrols debug detail visibility - User input is never reflected verbatim in error messages
Conclusion
Secure error handling is not just about user experience, it's a critical layer of your API's defense-in-depth strategy. A single unhandled exception that leaks a stack trace can give an attacker everything they need to probe your system further.
By implementing a global exception filter in NestJS, you establish a single, auditable point of control over every error your API emits. Pair it with environment-aware detail levels, structured logging, and service-layer ORM sanitization, and you've closed one of the most commonly overlooked API security gaps.
The rule is simple: log everything internally, expose nothing externally.
Top comments (0)