Verbose error messages that hand attackers your architecture on a plate
By Seaworthy · 10 June 2026
Photo by Jakub Zerdzicki on Unsplash
Stack traces are love letters to attackers. They contain everything a threat actor needs to plan a more precise attack: the absolute path to your application files, the version of every framework you're running, the structure of your database queries, and sometimes the query itself with values still interpolated in.
I've seen production APIs return something like this to any consumer who sends a malformed request:
Error: select * from users where id = 'abc' - invalid input syntax for type integer
at C:\Users\dev\company-project\node_modules\pg\lib\client.js:526:17
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
That single response tells an attacker you're using PostgreSQL, confirms a probable SQL injection surface, reveals your local development path (including the developer's username), and hands them the exact library version to check for known CVEs. All from one bad request.
What a stack trace actually reveals
The damage goes beyond the obvious. File paths expose your directory structure, which often maps to your deployment layout. Framework names and versions let attackers search CVE databases. Partial query strings reveal table names, column names, and occasionally filter values. ORM error messages can expose your entire schema if a relation doesn't exist or a column is misspelled.
Node.js is particularly generous here. Unhandled promise rejections, if not caught, bubble up through the default error handler and land in the response body in development mode. In production mode, Express suppresses the stack trace from the response, but only if NODE_ENV=production is actually set, and only for Express's own error handler. Your own unhandled async errors are still fully exposed.
Two error responses, not one
The fix isn't complicated, but it requires a mental shift: there are two separate outputs every error should produce. One is for your logging system, one is for your API consumer. They should share nothing except maybe a correlation ID.
The log entry gets everything: the full stack trace, the request context, the user ID, the database error, the environment variables that might be relevant. That's what you need to debug. The API response gets a safe, generic message and an HTTP status code that doesn't confirm which internal system failed.
Here's what a proper Express error handler looks like:
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly clientMessage: string,
message: string
) {
super(message);
this.name = 'AppError';
}
}
function errorHandler(
err: unknown,
req: Request,
res: Response,
_next: NextFunction
) {
const correlationId = randomUUID();
// Full detail goes to the logger, never to the response
console.error({
correlationId,
path: req.path,
method: req.method,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.clientMessage,
correlationId,
});
}
// Unknown errors get a generic 500: no internal detail leaks
return res.status(500).json({
error: 'An unexpected error occurred.',
correlationId,
});
}
export { AppError, errorHandler };
This handler gives the client a correlation ID they can report to support, gives you everything you need in logs, and tells attackers nothing useful.
Unhandled promise rejections and uncaughtException
Express's error middleware only catches errors thrown synchronously or passed via next(err). Async functions that reject without being awaited in a try/catch bypass it entirely. In Node.js versions before 15, unhandled rejections were silent. From v15 onward, they crash the process. Neither outcome is what you want.
You need two process-level handlers:
process.on('unhandledRejection', (reason: unknown) => {
console.error({
event: 'unhandledRejection',
reason: reason instanceof Error ? reason.stack : String(reason),
});
// Optionally exit cleanly so a process manager can restart
process.exit(1);
});
process.on('uncaughtException', (err: Error) => {
console.error({
event: 'uncaughtException',
error: err.message,
stack: err.stack,
});
process.exit(1);
});
These handlers log the full detail to wherever your logger writes, then exit. They don't produce HTTP responses at all. Any request that was in flight during an uncaught exception was already compromised at the process level, and trying to send a clean response from that state isn't safe anyway.
Mapping errors to status codes without leaking state
There's a subtler version of this problem. Returning 404 versus 403 when a user requests a resource they don't own tells an attacker whether the resource exists. Returning different error messages for "wrong password" versus "no account with that email" lets them enumerate valid usernames.
The rule is: conflate error types at the boundary between your internal model and the HTTP response. Authentication failures are always 401. Authorization failures can be 403, but only if you've already confirmed the user is authenticated. Resource access errors for unauthenticated or unauthorized users should return 404, not 403, so you don't confirm whether the resource exists.
Your internal code can be as specific as it likes. Throw UserNotFoundError, InvalidPasswordError, AccountLockedError. Catch them all in a single auth-error mapper that collapses them into a single { error: 'Invalid credentials.' } response with a 401 status.
This is the same separation principle applied to a different axis: internal precision for you, minimal signal for an attacker.
The production environment check problem
One last trap. Express's built-in error handler will suppress stack traces when NODE_ENV=production. Teams sometimes rely on this as their error sanitisation strategy and never write an explicit error handler. That's fragile: it depends on an environment variable being set correctly in every deployment context, it breaks the moment you switch to a non-Express framework or add middleware that overrides the handler, and it still doesn't address unhandled rejections.
Write the explicit handler. Treat NODE_ENV as a deployment signal, not a security control.
Seaworthy flags this pattern automatically.
This article was generated by AI and summarises publicly available sources.
Seaworthy scans your repo for the issues covered in articles like this one.
Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.