Error Handling and Information Leakage — What Your Errors Tell Attackers
Layer 10 in the HTTP Request Journey
Attackers rarely know exactly what to attack right away. They typically spend time on reconnaissance — gathering information about the application. What framework? Which version? What does the database look like? Which users does the system have?
Most of this information, applications hand over voluntarily — in error messages.
A stack trace with Java class names, an SQL message with a table name, a response saying "No user with that email exists" — each piece of information helps an attacker narrow their target. This isn't paranoia; it's a simple threat model: the more you know about a system, the easier it is to attack.
What Attackers Read from Your Error Messages
Stack Traces and Technical Details
Classic example — an application in debug mode returns something like this after a bad request:
java.lang.NullPointerException
at com.vibedefend.api.UserService.getUserById(UserService.java:42)
at com.vibedefend.api.controllers.UserController.getUser(UserController.java:18)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...
Caused by: org.hibernate.QueryException:
SELECT * FROM users WHERE id = 'abc' AND deleted_at IS NULLIn one message, the attacker gets:
- Language: Java
- Framework: Hibernate (ORM)
- Table name:
users - SQL query structure — including the fact there's a
deleted_atcolumn (soft delete) - Internal package structure of the application
That's a goldmine. And it happens through one unhandled exception in production.
SQL Errors
ERROR: column "pasword" does not exist
LINE 1: SELECT id, pasword FROM users WHERE email = $1The literal column name, table name, query structure. The attacker now knows what the users table looks like.
User Account Information in Login Messages
POST /auth/login → 400 Bad Request
{"error": "No user with email jan@example.com exists"}This is user enumeration — the ability to check which email addresses are registered in the system. An attacker sends a list of potential emails and watches the responses:
- "No such user" → email doesn't exist
- "Wrong password" → email exists
With a list of existing emails, they can start credential stuffing — trying leaked passwords from other services.
The correct response is always the same, regardless of whether the email exists or not:
{"error": "Invalid email or password"}Sounds obvious. In practice, many applications don't do this.
HTTP Headers Revealing Version Information
Server: Apache/2.4.29 (Ubuntu)
X-Powered-By: PHP/7.2.5
X-AspNet-Version: 4.0.30319PHP version 7.2.5 — end of life since 2019. Apache 2.4.29 — vulnerable to several CVEs. Every known vulnerability takes seconds to find via search.
The Server and X-Powered-By headers are marketing information for tech enthusiasts — and free reconnaissance for attackers.
Proper Error Handling — The Rules
Rule 1: Different Errors for Users, Different for Logs
The user should get a clear, general message. Technical details go to logs — where you can read them, not attackers.
// Error handling middleware in Express
app.use((err, req, res, next) => {
// Details to logs
logger.error({
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
userId: req.user?.id,
requestId: req.id
});
// General message for the client
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: getPublicErrorMessage(statusCode),
requestId: req.id // ID for log correlation when reporting issues
});
});
function getPublicErrorMessage(statusCode) {
const messages = {
400: 'Invalid request',
401: 'Authentication required',
403: 'Access denied',
404: 'Not found',
429: 'Too many requests — please try again in a moment',
500: 'A server error occurred'
};
return messages[statusCode] || 'An error occurred';
}requestId is a useful technique: the user gets an ID they can provide in a bug report, and you can find exactly those logs without exposing anything publicly.
Rule 2: Remove Headers That Reveal Technology
// Helmet.js automatically removes/changes many headers
import helmet from 'helmet';
app.use(helmet());
// Manually remove X-Powered-By
app.disable('x-powered-by');In nginx:
server_tokens off; # Hides the nginx version from the Server headerAttackers can still try fingerprinting through other methods, but removing headers eliminates the easiest path.
Rule 3: Consistent Messages for Enumeration-Sensitive Functions
Login, password reset, registration — everywhere a response could reveal whether an email exists:
// Password reset — always the same response
app.post('/auth/reset-password', async (req, res) => {
const { email } = req.body;
const user = await db.users.findByEmail(email);
// Regardless of whether the user exists — send email (or don't)
// but the HTTP response is always the same
if (user) {
await sendPasswordResetEmail(user);
}
// Always 200, always the same message
res.json({ message: 'If an account with this email exists, we\'ve sent password reset instructions' });
});Rule 4: 401 vs 403 — They Are Not the Same
The difference matters and is frequently ignored:
- 401 Unauthorized — the user is not authenticated; the token/session is missing or invalid. "Log in to continue."
- 403 Forbidden — the user is authenticated, but lacks permission for this operation. "I know who you are. You can't do this."
Returning 403 for an unauthenticated user is a mistake. Returning 401 for a user without permission is information leakage — you're confirming the resource exists, but this token doesn't have access to it. Sometimes that matters; sometimes it doesn't. Being aware of the distinction is worth it.
Practical rule:
No token / invalid token → 401
Valid token, no permission → 403
Resource doesn't exist → 404 (but carefully — 404 can also be information leakage)Rule 5: Timing Attacks — Response Time Is Information Too
Imagine a login endpoint that:
- For a non-existent email, returns an error immediately (0ms to check)
- For an existing email with a wrong password — hashes the password and compares (50ms for hashing)
Even if the error message is identical, the time difference reveals which email exists. This is a timing side-channel.
The fix: always perform the same amount of work, regardless of the outcome.
async function login(email, password) {
const user = await db.users.findByEmail(email);
// Always hash the password, even if the user doesn't exist
// Prevents timing attack
const hash = user ? user.passwordHash : '$2b$12$invalidhash.thatnevermatchesbutkeepstime';
const isValid = await bcrypt.compare(password, hash);
if (!user || !isValid) {
throw new AuthenticationError('Invalid email or password');
}
return user;
}bcrypt libraries have constant-time comparison, eliminating timing attacks at the hashing level. But you need to make sure the code before the comparison doesn't return early.
Sensitive Data in Response Body
Beyond errors — check what you return in normal responses too. Typical problems:
Excess user data:
// BAD
app.get('/api/user/me', authenticate, async (req, res) => {
const user = await db.users.findById(req.user.id);
res.json(user); // Sends passwordHash, resetToken, internalFlags...
});
// GOOD — return only what's needed
app.get('/api/user/me', authenticate, async (req, res) => {
const user = await db.users.findById(req.user.id);
res.json({
id: user.id,
email: user.email,
displayName: user.displayName,
createdAt: user.createdAt
});
});Internal IDs as public identifiers: if your API returns auto-incremented IDs from the database (/api/orders/1, /api/orders/2), an attacker knows how many orders you have and can attempt enumeration. Use UUIDs for public identifiers.
Environment Modes — Debug Only Locally
Many frameworks have development / production modes. Make sure debug mode isn't enabled in production:
// Express
if (process.env.NODE_ENV === 'development') {
// Detailed errors only locally
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
}The environment variable NODE_ENV=production (or its equivalent in other frameworks) should be set on the production server. Check this — many incidents resulted from a production server running in development mode.
Checklist
- In production — no stack traces or SQL messages in HTTP responses
- Remove
Server,X-Powered-By,X-AspNet-Versionheaders - Consistent messages on login and password reset (no user enumeration)
- Use 401/403 correctly
- Check login timing — always perform hashing regardless of outcome
- Don't return excess data (passwordHash, internal flags) from the API
- Use UUIDs instead of auto-incremented IDs in public endpoints
- Verify that
NODE_ENV=productionis set on the server
Summary
Error handling leaks are easy to miss in development and easy to exploit in production. Attackers use the information in error responses to map your stack, discover endpoints, and enumerate users — often before attempting any actual attack.
| Technique | Protects against |
|---|---|
| Generic error messages in production | Stack trace and path leakage |
| Remove Server / X-Powered-By headers | Technology fingerprinting |
| Consistent login/reset messages | User enumeration |
| Correct 401 vs 403 usage | Leaking resource existence |
| Constant-time password check | Timing attacks via response time |
| No excess fields in API responses | Internal data leakage |
| UUIDs instead of sequential IDs | IDOR reconnaissance |
Many of these are a single configuration change away from fixed — setting NODE_ENV=production alone eliminates most stack traces in Node.js apps. The rest are small decisions in your error handling middleware that collectively remove a significant reconnaissance layer from potential attackers.
Sources: