Error Handling Best Practices: Secure Logging, User Messages, and Recovery

Share

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:

AudienceNeedsWhere
DevelopersFull details, stack traces, contextServer logs, monitoring tools
UsersWhat went wrong, how to fix itAPI response, UI message

Best Practice 1: Generic User Messages 3 min

Never expose internal details to users:

Safe error responses
// 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:

Comprehensive error logging
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:

Sentry integration example
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:

Error type 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:

Preventing user enumeration
// 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:

Async error handling
// 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

MistakeRiskPrevention
Stack traces in responseInternal code exposureGeneric messages only
Database errors to usersSchema disclosureCatch and remap errors
User enumerationAccount discoveryUniform error messages
No error monitoringMissing production issuesUse Sentry or similar
Swallowing errorsSilent failuresAlways 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
Best Practices

Error Handling Best Practices: Secure Logging, User Messages, and Recovery