How verbose error messages leak more than stack traces
Por Seaworthy · 2 de agosto de 2026
Photo by Joshua Hoehne on Unsplash
A user does something slightly wrong. Maybe they submit a form with a field your validation missed, or hit an endpoint with a malformed ID. Your app throws. And because nobody explicitly told it not to, the raw exception sails straight through to the HTTP response as JSON.
Here's roughly what that looks like in the wild:
{
"error": "SequelizeDatabaseError: column \"user.email_verifed\" does not exist",
"stack": "at Query.run (/app/node_modules/sequelize/lib/dialects/postgres/query.js:76:25)\n at /app/node_modules/sequelize/lib/sequelize.js:312:12\n at PostgresQueryInterface.select (/app/node_modules/sequelize/lib/dialects/abstract/query-interface.js:451:19)",
"sql": "SELECT * FROM \"Users\" WHERE \"email_verifed\" = $1 LIMIT 1"
}
Nobody chose to expose this. It's the default. Express, Rails, Django, Flask, Laravel: every framework's out-of-the-box behavior in development mode is to show you everything, because when you're debugging on localhost, more information is strictly better. The problem is that "development mode" has a way of following projects into production, especially the ones stitched together fast with an AI assistant that got the happy path working and moved on. Nobody wrote the try/catch. Nobody set NODE_ENV=production in the deploy config. Nobody swapped the default error handler for something that redacts.
What's actually in that payload
Look at the example again and count what an attacker gets for free, no exploit required, just a slightly wrong request:
The ORM is Sequelize, so the stack is Node/JS and the database is Postgres. The internal file path shows the project lives at /app in a container, and the dependency tree is at the default node_modules location, which rules out a few deployment patterns and confirms others. The typo email_verifed is a column name pulled straight out of the schema. That's not a hypothetical column, it's the actual one, spelled the way the original developer spelled it, typo and all.
None of that is "sensitive" in the way a leaked password is sensitive. But it's reconnaissance, and reconnaissance is the first phase of basically every real attack. An attacker probing a login form for SQL injection doesn't need your source code to succeed. They need to know it's Postgres, not MySQL, so they can pick the right injection syntax. They need to know the query shape so they know where to break it. A verbose error handed them both in one shot, and it did it before they'd tried anything more sophisticated than a slightly wrong request.
Stack traces are worse than they look for a second reason: they map your architecture. A trace that walks through middleware/auth.js, then services/PaymentService.js, then lib/stripeClient.js tells an attacker exactly which internal modules touch payment data and in what order, without them ever seeing the repo. You've handed over a diagram of the parts of the system most worth attacking, for free, because a request failed.
The fix is boring, which is why it works
You don't need an error-tracking platform or a clever middleware library to fix this. You need two rules, applied consistently: the client gets a generic message and an ID, the server log gets everything.
Here's the pattern in an Express app:
// Bad: whatever Express's default handler does with an unhandled throw,
// or a catch block that just forwards err to the client.
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
// Fixed: log the full detail server-side, return nothing but an ID.
app.use((err, req, res, next) => {
const errorId = crypto.randomUUID();
logger.error({ errorId, message: err.message, stack: err.stack, sql: err.sql });
res.status(500).json({
error: 'Something went wrong. Please try again or contact support.',
errorId,
});
});
The errorId matters more than it looks. Without it, "generic error" and "no way to debug it" become the same thing, and teams predictably drift back toward verbose responses because at least those are useful when something breaks. With it, a user can report "error ID a3f9-2b1c" and you go straight to the matching log line with the full stack, the query, the works. You get all the debugging value and none of the exposure. This isn't a new idea, it's just the same discipline as not committing .env files: keep the sensitive detail in the place only you can see.
The same split applies whatever your stack is. Rails: config.consider_all_requests_local = false outside development, paired with a rescue_from StandardError that logs and renders a generic response. Django: DEBUG = False in production settings, full stop, because Django's debug page is arguably the worst offender here, it will show you a request's session cookies alongside the traceback. Flask: don't run with debug=True in production, use an errorhandler for Exception that logs and returns a plain JSON body.
Worth saying explicitly: this isn't about hiding error status codes. Return the correct 400, 404, 409, 500, whatever fits. Client-side error handling depends on the status code, and that's not sensitive. What's sensitive is the message body underneath it: don't let err.message from an ORM or a database driver reach res.json() unfiltered, ever, in a codebase your users can hit in production.
If you're paranoid about false economy, i.e. worrying you'll strip out a message a legitimate client actually needs, split the categories. Validation errors ("email is required") are fine to return verbatim, they're describing user input, not your infrastructure. Anything that originates from a database driver, an ORM, a filesystem call, or an unhandled exception should never reach the client as-is, because none of those are written with an external audience in mind.
Seaworthy's static analysis flags exactly this pattern: response bodies and error handlers that forward stack traces, file paths, or raw exception messages to the client instead of a generic error and a logged detail.
Este artículo fue generado por IA y resume fuentes públicamente disponibles.
Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.
Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.