← All news
data-exposure6 min read

Your default Express error handler is emailing stack traces to strangers

By Seaworthy · 19 August 2026

Light passing through a pane of textured glass

Photo by Angelo Abear on Unsplash

Throw an unhandled error in a bare Express app and this is what the client receives:

Error: connect ECONNREFUSED 10.0.4.12:5432
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
    at PoolClient.connect (/app/node_modules/pg/lib/client.js:174:11)
    at /app/src/routes/users.js:42:18

That's Express's built-in default error handler doing exactly what it's documented to do: when NODE_ENV isn't set to production, it returns the full stack trace as the response body. Plenty of AI-generated Express apps never set NODE_ENV explicitly, and plenty of container base images don't set it either unless someone adds it, so this default carries straight through to a deployed service without anyone deciding it should.

What's actually in a stack trace

Look at that example again. It reveals the internal IP address of a database host, the exact ORM library and version in use, and the absolute file path of the route handler on the server's filesystem, down to the line number. None of that is meant for a client, and all of it is useful to someone probing for a way in: internal network topology, the tech stack to target with known CVEs, and enough of the file structure to guess at other endpoints or config file locations.

It gets worse when the error originates closer to user input. A malformed request that trips a validation error, or a database query that fails on unexpected input, will often surface parameter values or partial query text in the trace, which can mean a stack trace leaks other users' data or the shape of a query an attacker can use to refine a SQL injection attempt.

Even the "fixed" version usually isn't

Some generated apps do include an explicit error handler, but often just this:

app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message })
})

This suppresses the stack trace, which is progress, but err.message on a database error is frequently just as revealing: connection strings, table names, or constraint names show up here routinely, because that's exactly the information a database driver puts in its error messages to help a developer debug locally.

The fix separates what's logged from what's returned

app.use((err, req, res, next) => {
  console.error(err)
  res.status(500).json({ error: 'Internal server error' })
})

The full error, trace and all, goes to your logs where it's genuinely useful for debugging. The client gets a generic message and nothing else. If different error types need different status codes, that logic can sit in the same handler, checking err.name or a custom error class, without ever forwarding err.message or err.stack into the response body.

It's worth setting NODE_ENV=production explicitly in your deployment config regardless, since several libraries beyond Express change behavior based on it, but don't rely on that alone. A generic error response should be the default in your own handler, not a side effect of an environment variable someone might forget to set.

An error tracking tool like Sentry is a reasonable place to send the full error alongside the generic response, since it gives you searchable stack traces without ever putting them on the wire to the client. The point isn't to throw information away, it's to make sure the only audience for a stack trace is the team that can act on it.

Framework defaults don't stop at Express

The same pattern shows up outside Node too. Django's DEBUG = True renders a full interactive traceback in the browser, complete with local variable values at every frame, and it's a setting plenty of tutorials tell you to set once and forget. Rails has a near-identical behavior tied to environment. If you're using a framework you didn't write from scratch, it's worth confirming directly what its default error page shows in whatever mode your production deployment is actually running in, rather than assuming "production" automatically means "safe."

Why this survives into production

An error response with a full stack trace is more useful, not less, while you're actively developing and testing an endpoint, so removing that detail feels like it's making the app worse right up until the moment it's deployed somewhere a stranger can trigger the error instead of you. The behavior that helps most during development is the same behavior that hurts most in production, and nothing forces a reconsideration in between.

Seaworthy checks for error handlers that return err.message or err.stack directly in the response body, since it's one of the more common ways an app answers "what's my internal architecture" to anyone who asks.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

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.

$ npx seaworthycode