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:
SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email— reveals your database engine and schemaCannot read properties of undefined (reading 'workspace_id')— reveals internal variable names and data structuresENOENT: no such file or directory, open '/workspace/onneta/data/uploads/avatar.png'— reveals your full server file pathconnect ECONNREFUSED 127.0.0.1:5432— reveals internal services and portsJsonWebTokenError: jwt malformed— reveals your authentication library
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:
- Line 1979 — workspace creation endpoint (admin)
- Line 2046 — workspace update endpoint (admin)
- Line 2577 — dashboard data aggregation (authenticated)
- Line 2805 — activity log query (authenticated)
- Line 2967 — export generation (admin)
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:
- Credential compromise — if an attacker obtains a user's credentials (phishing, password reuse, session theft), they inherit the user's access to error messages. The error detail helps them escalate further.
- XSS payloads — a stored XSS vulnerability can make authenticated API calls on behalf of the victim. The error responses go back to the attacker's script, not the user's eyes.
- Defence in depth — security layers should be independent. Authentication controls who can access a route. Error handling controls what the route reveals. If one layer fails, the other should still hold.
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:
- Never send
err.messageto the client — not in 500 responses, not in 400 responses, not behind auth, not behind admin checks - Log the full error server-side —
console.error(err)or your logging framework captures the detail where it belongs - Return static strings — the client needs to know something failed, not why
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.