Verbose error messages, explained
A stack trace in production hands an attacker your file paths, framework versions, and query shapes. What leaks, why it multiplies other bugs, and how to fix it.
- security
- vibe coding
- engineering
A verbose error message is a bug that tells the attacker how to find the next bug. On its own it steals nothing. What it does is convert guessing into reading: instead of probing blindly, an attacker sends malformed input and your server explains, in detail, what it tried to do with it.
OWASP A05:2021 Security Misconfiguration lists it as one of the conditions that makes an application vulnerable: “error handling reveals stack traces or other overly informative error messages to users.” The formal identifier is CWE-209, generation of an error message containing sensitive information.
What actually leaks
Sort the leak by what an attacker does with it, not by how alarming it looks.
Structure. Absolute file paths (/home/deploy/app/src/services/billing.py)
give away the deployment layout, the OS user, and often the framework
convention. A path is also the input to a
path traversal attempt that
would otherwise be pure guesswork.
Versions. Werkzeug/3.0.1 Python/3.11.4 or a Next.js error overlay pinning
the exact minor release turns “does this app have a known CVE” from research
into a lookup. This is the single most valuable line in a stack trace, because
it converts a manual search for a bug into a search of a public database.
Query shape. The classic is a database driver error echoed to the browser:
ERROR: column "u.emial" does not exist
LINE 1: SELECT u.id, u.emial, u.password_hash FROM users u WHERE u.id...
That single message published your table name, three column names, and the fact that the query is string-concatenated far enough to surface a fragment. It is also the difference between blind and non-blind SQL injection: with error output, an attacker extracts data in a handful of requests instead of thousands of timing probes.
Secrets, occasionally. Configuration objects and local variables printed in a frame have shipped API keys, database URLs with embedded passwords, and signing secrets more than once. This is the rarest and worst case, and it is why a leaked trace should be treated as a key exposure incident until you have read the trace and confirmed otherwise.
Existence. “User not found” versus “wrong password” is a verbose error too, just a one-word one. That case has its own post: account enumeration.
Why it is a force multiplier
Every class of bug that requires an attacker to iterate gets cheaper when the server narrates the result.
- Blind SQL injection becomes error-based injection.
- SSRF becomes an internal port scanner, because “connection refused” and “timed out” are different response bodies and the difference maps your private network.
- IDOR probing gets a confirmation channel, because a foreign-key violation reads differently from a permission denial.
- Deserialization and template injection probes report which gadget or engine is present.
That is the argument for treating this as a real finding rather than a tidiness issue. The severity of a verbose error is the severity of whatever it accelerates.
The framework defaults that betray you
Almost nobody writes a debug page. They inherit one.
Django, DEBUG = True. The yellow error page is the most generous of the
lot. It prints the full traceback with the local variables of every frame, the
installed apps, the request metadata, and the settings. Django does sanitize
settings whose names match API|TOKEN|KEY|SECRET|PASS|SIGNATURE, which people
often mistake for “secrets are safe here.” Local variables in frames are not
sanitized unless the view is decorated with @sensitive_variables, so a
conn_string or headers local sails straight through the filter. DEBUG = True also disables ALLOWED_HOSTS enforcement, so it is two findings in one
line.
Flask, debug=True. Worse than Django, because the Werkzeug debugger is not
a page, it is a console. An unlocked debugger gives remote code execution to
anyone who can reach it. Modern Werkzeug guards it with a PIN, which is a
mitigation and not a boundary. Our worker’s Bandit engine
(app/engines/bandit.py) flags this as B201, flask_debug_true, and it is
one of the few findings we treat as critical on sight.
Express. The default error handler returns the stack in the response body
whenever NODE_ENV is not production. Nothing warns you: an app deployed to a
host that does not set NODE_ENV looks fine and leaks every trace.
Next.js. The dev overlay is unmistakable, but the subtler case is an unhandled throw inside a route handler or Server Action, where the error message you wrote for yourself ends up serialized to the client.
FastAPI. Reasonable by default (a 500 is generic), but the common “helpfully” wrapped handler undoes it:
# Vulnerable. Attaches the internal exception text to the client response.
@app.exception_handler(Exception)
async def handle(request: Request, exc: Exception):
return JSONResponse(status_code=500, content={"detail": str(exc)})
str(exc) on a psycopg error is the SQL fragment above.
The fix: one shape for every error
The remediation is not “catch more exceptions.” It is to make the response body independent of the exception, and to move the detail to a place only you can read. Two channels, one correlation id joining them.
Express:
// Last middleware. Everything that throws lands here.
app.use((err, req, res, next) => {
const ref = crypto.randomUUID();
// Channel 1: the log. Everything, including the stack.
logger.error({ ref, err, path: req.path, userId: req.user?.id });
// Channel 2: the response. Constant shape, no detail.
res.status(err.status ?? 500).json({
error: err.status && err.status < 500 ? err.message : "Something went wrong",
ref,
});
});
FastAPI:
@app.exception_handler(Exception)
async def handle(request: Request, exc: Exception):
ref = uuid4().hex
logger.exception("unhandled", extra={"ref": ref, "path": request.url.path})
return JSONResponse(status_code=500, content={"error": "Something went wrong", "ref": ref})
Three properties make this work:
- The 500 body is a constant. It carries no branch on the underlying cause, so it cannot become an oracle.
- The reference id is random per occurrence, not derived from anything. It
lets a user say “I saw error
9f2c...” and lets you find the trace, without telling them anything about it. - Client errors keep their message, server errors do not. A 400 saying “email is required” is useful and reveals nothing. A 500 saying anything at all is a leak. The split at status 500 in the Express handler above is the whole policy.
Then close the ways the framework can override you: pin NODE_ENV=production
in the image rather than the host config, set DEBUG = False from an
environment variable with a safe default (os.environ.get("DEBUG", "") == "1",
not != "0"), and disable framework error overlays in any preview deployment
that is reachable without a login.
Do not solve it at the edge
A tempting shortcut is to strip bodies at the reverse proxy or the CDN. It helps,
and it is not the fix, for two reasons. Internal callers and any path that skips
the proxy still see everything, and a stripped 500 body with an intact
X-Powered-By header or a distinctive response time still leaks the version and
the branch. Fix it in the application and treat the edge rule as the second
layer.
What we flag
Three of the engines in apps/vibezero-scan-service/app/engines/ touch this
class from different angles, which is a good illustration of why one scanner is
never enough:
- Bandit (
bandit.py), on Python repos, forflask_debug_true(B201) and the assert-based and try/except/pass patterns that produce swallowed or over-shared errors. - OpenGrep (
opengrep.py), our primary multi-language SAST engine, for handlers that interpolate an exception into a response body. - Trivy (
trivy.py), in the config layer, for the deployment side: a Dockerfile or compose file that sets a debug flag or leavesNODE_ENVunset in a production stage.
The rule worth remembering
An error message is a message to a person who can act on it. Your user cannot act on a stack trace, so it is not for them; you cannot read the browser console of a stranger, so the response body is not for you. Once you accept that the response and the log have different audiences, the correct design falls out on its own: a constant sentence and a reference id for one, everything for the other.