← All news
security5 min read

Error messages that hand attackers information

By Seaworthy · June 12, 2026

A cracked glass window with a black background, showing intricate fracture patterns — symbolizing a security breach exposed through error messages

Photo by FlyD on Unsplash

The free recon you're giving away

Attackers spend a lot of time on reconnaissance. They probe endpoints, fuzz inputs, and look for anything that leaks about your stack. You can make their job much harder, or you can do it for them. A lot of applications do it for them.

The problem is not exotic. It is a default behavior in most frameworks: when something goes wrong, print everything you know. That is useful during development. In production it is a security control failure.

Here is what a bad Express.js error handler looks like:

// Bad: the default behavior most people leave in place
app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack,
  });
});

Send a malformed request to an endpoint backed by this handler and the response body might tell you: the Node version, the file path of the failing module, the line numbers of the call stack, and sometimes the query string or body that triggered the fault. That is a lot. A motivated person can cross-reference framework version against known CVEs in about thirty seconds.

What attackers actually do with this

I want to be specific about the threat model here, because "attackers will learn things" can sound vague.

A stack trace that says at Object.<anonymous> (/app/node_modules/sequelize/lib/dialects/postgres/query.js:314) tells you the app uses Sequelize with PostgreSQL. Now you know: look up Sequelize CVEs for that approximate version range, try SQL injection payloads against likely query parameters, and watch the error responses for changes. The error messages themselves become a side channel.

A raw database error is worse. Postgres and MySQL both return descriptive messages that include table names, column names, and constraint names. If your application surfaces ERROR: null value in column "user_id" of relation "orders" violates not-null constraint, you have just handed over your schema. An attacker can map foreign key relationships, infer which columns are indexed, and understand your data model without touching the database directly.

Framework version disclosure works the same way. A response header like X-Powered-By: Express combined with an error message like Cannot read properties of undefined (reading 'findOne') narrows the version range enough to be useful. Spring Boot is even worse about this by default. Without configuration it will return a JSON error response with the exception class name, message, and sometimes a partial stack trace. The class names alone (org.springframework.dao.DataIntegrityViolationException) tell you the ORM and the nature of the schema constraint that failed.

What good looks like

The fix is not complicated, but it does require a bit of discipline around separating what you log from what you return.

// Good: log full detail server-side, return nothing useful to the client
app.use((err, req, res, next) => {
  // Structured log with full context, visible only in your logging system
  console.error({
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    requestId: req.headers['x-request-id'],
  });

  // Generic response: no stack, no framework detail, no schema info
  res.status(500).json({
    error: 'Something went wrong. If this persists, contact support.',
    requestId: req.headers['x-request-id'],
  });
});

The requestId in the response is the one piece of diagnostic information worth returning to users. It lets support staff correlate a user complaint to a specific log line without exposing anything about the error itself.

For database errors specifically, catch them before they reach any generic handler and map them to safe messages:

function handleDbError(err, res) {
  // Log the real error
  console.error({ dbError: err.message, code: err.code });

  // Return nothing database-specific
  if (err.code === '23505') {
    // unique_violation in Postgres
    return res.status(409).json({ error: 'That record already exists.' });
  }
  return res.status(500).json({ error: 'A database error occurred.' });
}

This is a bit tedious to write. Do it anyway. The alternative is your schema in every attacker's notebook.

Framework-specific things to turn off

A few settings worth knowing:

Spring Boot: Set server.error.include-stacktrace=never and server.error.include-message=never in application.properties. The defaults are always in older versions.

Django: DEBUG = False in production. This is documented everywhere, and teams still ship with it on. When DEBUG is true, Django returns a full stack trace page with local variable values. That is genuinely a lot of information.

Laravel: Set APP_DEBUG=false in .env. With debug on, Laravel's error pages include request context, session data, and environment variable names (not values, but names alone can be informative).

Express/Node: There is no single flag. You have to write the error handler yourself, as above.

The header problem

Errors in response bodies get most of the attention, but headers matter too. Server: Apache/2.4.51 (Ubuntu) tells you the web server, version, and OS. X-Powered-By: PHP/8.1.12 tells you the PHP version. These are not error messages, but they are information disclosure, and they are trivially removed:

# nginx: suppress version from Server header
server_tokens off;
# Apache: suppress version and OS
ServerTokens Prod
ServerSignature Off

For Node/Express:

app.disable('x-powered-by');

None of this is hard. The reason it gets skipped is that it is invisible when things are working. Nobody notices the headers until someone runs a scanner.

The real pattern here

The underlying issue is that developer convenience and security pull in opposite directions for error handling. Verbose errors make debugging fast. They also make reconnaissance fast. Most frameworks default toward the developer's interest, which is reasonable: most applications start out running on a developer's laptop.

The discipline is building the habit of treating production differently. Log everything. Return nothing. Give users enough to contact support. That is the whole model.

Seaworthy flags verbose error responses, framework version disclosure in headers, and raw database errors surfaced to clients as part of its automated scan.

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