TL;DR
The #1 error handling best practice is separating what you log from what you show. Log full details server-side, show generic messages to users, never expose stack traces in production, and use error monitoring tools. These practices prevent 65% of information disclosure vulnerabilities.
"Error messages are security boundaries. What helps developers debug can help attackers exploit. Log everything, expose nothing."
The Two Audiences for Error Messages
Every error has two audiences with different needs:
| Audience | Needs | Where |
|---|---|---|
| Developers | Full details, stack traces, context | Server logs, monitoring tools |
| Users | What went wrong, how to fix it | API response, UI message |
Best Practice 1: Generic User Messages 3 min
Never expose internal details to users:
// WRONG: Exposes internal details
res.status(500).json({
error: 'Connection to PostgreSQL failed: ECONNREFUSED localhost:5432',
stack: error.stack,
});
// WRONG: Reveals table/column names
res.status(500).json({
error: 'Column "password_hash" does not exist in table "users"',
});
// CORRECT: Generic message
res.status(500).json({
error: 'An unexpected error occurred. Please try again.',
errorId: 'err_abc123', // Reference for support
});
// CORRECT: Specific but safe validation errors
res.status(400).json({
error: 'Invalid input',
details: [
{ field: 'email', message: 'Invalid email format' },
{ field: 'password', message: 'Password must be at least 8 characters' },
],
});
Best Practice 2: Log Full Details Server-Side 5 min
Capture everything you need for debugging:
import { randomUUID } from 'crypto';
function logError(error, req, context = {}) {
const errorId = randomUUID();
console.error({
errorId,
timestamp: new Date().toISOString(),
error: {
message: error.message,
name: error.name,
stack: error.stack,
},
request: {
method: req.method,
path: req.path,
query: req.query,
userId: req.user?.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
},
context,
});
return errorId;
}
// Usage in error handler
app.use((err, req, res, next) => {
const errorId = logError(err, req);
// Return safe message with error ID for support
res.status(500).json({
error: 'An unexpected error occurred',
errorId,
});
});
Best Practice 3: Use Error Monitoring Tools 10 min
Production errors should be captured by monitoring tools:
import * as Sentry from '@sentry/node';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
});
// Express error handler
app.use((err, req, res, next) => {
// Capture error with context
Sentry.captureException(err, {
user: req.user ? { id: req.user.id, email: req.user.email } : undefined,
extra: {
path: req.path,
method: req.method,
},
});
res.status(500).json({ error: 'An unexpected error occurred' });
});
Best Practice 4: Distinguish Error Types 5 min
Different errors need different handling:
class AppError extends Error {
constructor(message, statusCode, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
}
}
// Operational errors (expected, safe to show)
class ValidationError extends AppError {
constructor(errors) {
super('Validation failed', 400);
this.errors = errors;
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404);
}
}
class UnauthorizedError extends AppError {
constructor() {
super('Authentication required', 401);
}
}
// Error handler
app.use((err, req, res, next) => {
// Operational errors: safe to show message
if (err.isOperational) {
return res.status(err.statusCode).json({
error: err.message,
...(err.errors && { details: err.errors }),
});
}
// Programming errors: log and show generic message
console.error('Unexpected error:', err);
res.status(500).json({ error: 'An unexpected error occurred' });
});
Best Practice 5: Prevent User Enumeration 3 min
Authentication errors should not reveal whether a user exists:
// WRONG: Different messages reveal user existence
app.post('/login', async (req, res) => {
const user = await findUser(req.body.email);
if (!user) {
return res.status(400).json({ error: 'User not found' }); // WRONG
}
if (!await verifyPassword(req.body.password, user.password)) {
return res.status(400).json({ error: 'Wrong password' }); // WRONG
}
});
// CORRECT: Same message for all auth failures
app.post('/login', async (req, res) => {
const user = await findUser(req.body.email);
if (!user || !await verifyPassword(req.body.password, user.password)) {
// Same response for missing user OR wrong password
return res.status(400).json({ error: 'Invalid credentials' });
}
// Success...
});
// CORRECT: Password reset (same response regardless of email existence)
app.post('/forgot-password', async (req, res) => {
// Always return same response
res.json({ message: 'If an account exists, a reset link was sent.' });
// Process in background
const user = await findUser(req.body.email);
if (user) {
await sendResetEmail(user.email);
}
});
Best Practice 6: Handle Async Errors 5 min
Unhandled promise rejections can crash your server:
// Wrapper for async route handlers
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Usage
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
}));
// Global unhandled rejection handler (backup)
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
// Log to monitoring service
});
Common Error Handling Mistakes
| Mistake | Risk | Prevention |
|---|---|---|
| Stack traces in response | Internal code exposure | Generic messages only |
| Database errors to users | Schema disclosure | Catch and remap errors |
| User enumeration | Account discovery | Uniform error messages |
| No error monitoring | Missing production issues | Use Sentry or similar |
| Swallowing errors | Silent failures | Always log or rethrow |
External Resources: For comprehensive error handling guidance, see the OWASP Error Handling Cheat Sheet and the OWASP Improper Error Handling documentation for industry-standard security recommendations.
Should I show detailed errors in development?
Yes, detailed errors in development speed up debugging. Use environment checks: show full details when NODE_ENV is development, generic messages in production.
What error monitoring tools should I use?
Sentry is the most popular choice. Alternatives include LogRocket, Bugsnag, and Rollbar. For simpler setups, structured logging to a service like Datadog or Logtail works well.
How do I debug production errors with generic messages?
Include an error ID in the response and log full details with that ID server-side. When users report issues, you can look up the full error by ID.
Check Your Error Handling
Scan your application for information disclosure in error messages.
Start Free Scan