Your 500 Errors Are Talking to Attackers

30 March 2026 · 5 min read · Security

Every Express application has catch blocks. Most of them do the same thing: res.status(500).json({ error: err.message }). It feels right — you're telling the client what went wrong. But that one line is telling attackers what you're built on.

Database engine names. File system paths. Library versions. Stack trace fragments. All of it packaged into a clean JSON response, delivered over HTTPS, formatted for easy reading. You might as well attach your package.json.

The Pattern

Here's what a typical Express catch block looks like:

app.post('/api/workspace', async (req, res) => {
  try {
    // ... business logic
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: err.message });
  }
});

The console.error(err) line is correct — it logs the full error to the server where developers can see it. The err.message line is the problem. It sends the error's internal description to whoever made the request.

When everything works, nobody notices. But when something breaks — a malformed query, a missing column, a bad join — the error message reveals details that should stay on the server.

What Gets Exposed

Here are real examples of what err.message can contain in a Node.js application:

Each of these is a puzzle piece. Individually, they seem harmless. Together, they build a map of your infrastructure that makes targeted attacks dramatically easier.

What We Found

Our grep-based audit on Onneta's server.js (3,052 lines) searched for err.message in response objects. We found 5 routes sending internal error details to the client:

Two were admin-only routes. Three were behind standard authentication. None were public. You might think that makes it acceptable — it doesn't.

Why Auth Doesn't Make It Safe

The argument goes: "Only authenticated users can trigger these errors, so there's no risk." This reasoning has three holes:

Even behind admin-only middleware, err.message in a response is a latent information disclosure vulnerability. It doesn't cause harm today. It amplifies harm the day something else goes wrong.

The Fix

The fix is almost trivially simple. Replace err.message with a generic string:

// Before — leaks internal details
catch (err) {
  console.error(err);
  res.status(500).json({ error: err.message });
}

// After — generic response, full server-side logging
catch (err) {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
}

The console.error(err) line stays. Developers still see the full error in server logs where it belongs. The client sees a generic string that reveals nothing about the underlying cause.

We applied this pattern to all 5 routes in a single commit. Each route got a context-appropriate generic message: "Failed to create workspace", "Failed to load dashboard", "Export failed". Specific enough for the user to understand what happened, generic enough to reveal nothing about why.

The Rule

After this fix, we added a standing rule to our audit process:

This joins our growing list of output-context-specific rules: escapeHtml() for HTML, parameterised queries for SQL, escapeCsv() for spreadsheets, and now generic strings for error responses. Every output boundary has its own escaping discipline.

Check Your Own Code

Run this grep against your Express application:

grep -n "err.message" server.js | grep -i "res\.\|json\|send"

If you find matches inside catch blocks, you have the same vulnerability we did. The fix is a find-and-replace operation. The risk of leaving it is an information leak that makes every future vulnerability easier to exploit.

Your error handling already logs the detail. Stop sending it to the people who shouldn't see it.

Automated security audits, every cycle

Onneta's AI audit system catches information disclosure, XSS, injection, and access control gaps before they reach production.

Start building with Onneta