Skip to content

CWE-209: Generation of Error Message Containing Sensitive Information - JavaScript

Overview

CWE-209 in JavaScript applications occurs when error stack traces, database query details, or internal system information is exposed through API responses, error pages, or client-side console output. Node.js applications with Express, Fastify, Koa, and Next.js each have different error handling mechanisms that must be configured to avoid information disclosure.

Primary Defence: Return generic error messages to clients while logging detailed errors server-side, use centralized error handling middleware, and check NODE_ENV to prevent stack traces in production.

Common Vulnerable Patterns

Returning Raw Error Messages in Express

// VULNERABLE - Exposes database errors and stack traces
const express = require('express');
const app = express();

app.get('/user/:id', async (req, res) => {
  try {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
    res.json(user);
  } catch (error) {
    // node-postgres passes the server's own message through untouched:
    //   relation "users" does not exist
    //   connect ECONNREFUSED 10.0.0.5:5432
    res.status(500).json({ error: error.message });
  }
});

Why this is vulnerable:

  • Reveals table/column names, SQL syntax, and schema structure.
  • Exposes database vendor details and error codes.
  • Leaks internal host addresses and ports.
  • Enables targeted injection planning.

Exposing Full Stack Traces

// VULNERABLE - Returns complete stack trace
app.post('/process', async (req, res) => {
  try {
    const result = await complexOperation(req.body);
    res.json(result);
  } catch (error) {
    // Exposes: file paths, function names, line numbers, dependencies
    res.status(500).json({
      error: error.message,
      stack: error.stack,
      name: error.name
    });
  }
});

Why this is vulnerable:

  • Exposes absolute paths, function names, and line numbers.
  • Reveals module structure and third-party dependencies.
  • Helps attackers fingerprint versions with known CVEs.
  • Maps internal code paths for exploitation.

Default Express Error Handler

// VULNERABLE - Express default error handler shows stack traces
const express = require('express');
const app = express();

app.get('/data', async (req, res) => {
  // If error thrown here, Express default handler exposes details
  const data = await fetchData();
  res.json(data);
});

// No custom error handler - uses Express default behavior.
// In development it includes stack traces; in production it sends a
// generic response, but relying on defaults gives inconsistent handling.

Why this is vulnerable:

  • Default development handling leaks stack traces and internal paths.
  • Reveals middleware chain and routing structure.
  • Exposes framework internals and file layout.

Unhandled Promise Rejections

// VULNERABLE - Unhandled rejections may log sensitive data
app.get('/async-data', (req, res) => {
  // Promise rejection not caught
  fetchDataFromAPI(req.params.id)
    .then(data => res.json(data));
  // If fetchDataFromAPI rejects, error details may be logged or exposed
});

// Unhandled rejection logging
process.on('unhandledRejection', (reason, promise) => {
  // May log sensitive data to console or logs
  console.error('Unhandled Rejection:', reason);
});

Why this is vulnerable:

  • Unhandled rejections log full error details to stderr.
  • Error text can include secrets or connection strings.
  • Logs may be accessible via container/monitoring systems.
  • Attackers can trigger errors to probe behavior.

Next.js Development Mode Errors

// VULNERABLE - Next.js dev mode shows detailed error overlay
// next.config.js
module.exports = {
  // No production optimization
  reactStrictMode: true,
}

// pages/api/users/[id].js
export default async function handler(req, res) {
  try {
    const user = await prisma.user.findUnique({
      where: { id: req.query.id }
    });
    res.json(user);
  } catch (error) {
    // In dev mode this reaches the client. Measured on Next.js 16.3.1,
    // Pages Router: GET /api/users/1 returns a ~6 KB text/html body - the
    // _error page - carrying the message and full stack twice inside its
    // embedded __NEXT_DATA__ JSON.
    throw error;
  }
}

Why this is vulnerable:

  • Exposes file paths, node_modules layout, schema details, and configs.
  • Attackers can trigger errors to view details.
  • The response is HTML rather than the JSON an API route usually returns, and a client that only reads response.json() will not surface it - so the leak is invisible from the front end while being plainly there in the raw body.
  • Whether this reaches a real user depends on the build, not on the code. The same handler in a production build returns a 21-byte Internal Server Error with nothing in it, so the finding is about what runs where: a preview deployment, a staging box, or anything started with next dev behind a public hostname.

Logging Sensitive Data to Console

// VULNERABLE - Console logs may be visible
app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  // Logs plaintext password!
  console.log('Login attempt:', { username, password });

  try {
    const token = await authenticateUser(username, password);
    // Logs sensitive token!
    console.log('Generated token:', token);
    res.json({ token });
  } catch (error) {
    console.error('Auth error:', error);
    res.status(401).json({ error: 'Authentication failed' });
  }
});

Why this is vulnerable:

  • Logs may capture passwords, tokens, and session IDs.
  • Aggregation systems make logs widely accessible.
  • Plaintext credentials enable account takeover.
  • Secrets persist long after the request.

Secure Patterns

Express Global Error Handler

// SECURE - Generic errors to users, detailed logs server-side
const express = require('express');
const winston = require('winston');
const { v4: uuidv4 } = require('uuid');

const app = express();

// Express advertises itself in every response, including error responses.
app.disable('x-powered-by');

// Configure Winston logger
const logger = winston.createLogger({
  level: 'error',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: '/var/log/app/error.log' })
  ]
});

// Route handlers
app.get('/user/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'Resource not found' });
    }
    res.json(user);
  } catch (error) {
    next(error); // Pass to error handler
  }
});

// Global error handler (must be last middleware)
app.use((err, req, res, next) => {
  const errorId = uuidv4();

  // Log diagnostic details server-side
  logger.error({
    errorId,
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    timestamp: new Date().toISOString()
  });

  // Return generic message to client
  res.status(500).json({
    error: 'An error occurred',
    errorId,
    message: 'Please contact support with this error ID'
  });
});

module.exports = app;

Why this works:

  • Error handler runs last and catches upstream failures.
  • Full details are logged server-side only.
  • Clients receive generic messages with error IDs.
  • Error IDs correlate support tickets to logs.
  • Logs stay outside the web root with OS permissions.
  • x-powered-by is off. Express sets it on every response by default, including the sanitized 500 above, so a handler that reveals nothing about the failure still names the framework that produced it. It costs one line and there is no reason to keep it.

Environment-Aware Error Handling

// SECURE - Different error handling for dev vs production
// Opt IN to verbosity: an unset or misspelled NODE_ENV gets the quiet branch.
// Note this is NOT the usual `!== 'production'` idiom - see below.
const isDevelopment = process.env.NODE_ENV === 'development';

app.use((err, req, res, next) => {
  const errorId = uuidv4();

  // Always log server-side
  logger.error({
    errorId,
    error: err.message,
    stack: err.stack,
    path: req.path
  });

  // Conditional response based on environment
  const response = {
    error: 'An error occurred',
    errorId
  };

  // Only include details in development
  if (isDevelopment) {
    response.message = err.message;
    response.stack = err.stack;
  }

  res.status(err.status || 500).json(response);
});

Why this works:

  • Production responses omit stack traces and internals.
  • Development keeps details for debugging.
  • Full errors are always logged server-side.
  • Error IDs provide consistent traceability.
  • The comparison is === 'development', not the conventional !== 'production', and the difference is the finding rather than a style preference. With !== 'production', every way of failing to set the variable selects the branch that returns err.message and err.stack: unset, empty string, Production, prod, a Dockerfile that never declared it, a serverless platform that does not populate it. That is the first entry in Common Pitfalls below, and writing the check the common way is how a page ends up recommending the thing it warns about. This direction inverts which mistakes hurt: a missing variable now costs a developer terse local errors instead of publishing tracebacks.
  • Because NODE_ENV also drives behaviour in Express and in dependencies that do use !== 'production', keep setting NODE_ENV=production in deploys. This inversion decides what your error handler does when nobody did.

Async Error Wrapper

// SECURE - Wrapper to handle async errors consistently
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Usage
app.get('/user/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new NotFoundError('User not found');
  }
  res.json(user);
}));

// Custom error classes
class AppError extends Error {
  constructor(message, statusCode = 500, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(message, 404);
  }
}

class ValidationError extends AppError {
  constructor(message = 'Invalid input') {
    super(message, 400);
  }
}

// Error handler with custom errors
app.use((err, req, res, next) => {
  const errorId = uuidv4();

  // Log all errors
  logger.error({
    errorId,
    message: err.message,
    stack: err.stack,
    isOperational: err.isOperational
  });

  // Return appropriate response
  if (err.isOperational) {
    // Known operational error with a deliberately user-safe message
    res.status(err.statusCode).json({
      error: err.message,
      errorId
    });
  } else {
    // Unknown error - return generic message
    res.status(500).json({
      error: 'An unexpected error occurred',
      errorId
    });
  }
});

Why this works:

  • Async errors are forwarded to the centralized middleware instead of surfacing as unhandled rejections.
  • Error classes separate safe from unsafe messages, and isOperational is what the handler branches on.
  • Full details logged with generic client responses.

Check your Express version before adding the wrapper. Express 5 awaits an async handler and routes a rejection to the error middleware on its own - verified on 5.2.1, where an async route that throws produces the same sanitized 500 as a synchronous one. On Express 4 the rejection is never observed and asyncHandler is required. Keeping it on Express 5 is harmless, but do not treat its presence as the thing making async routes safe.

Note also what isOperational does and does not decide. Anything that is not an AppError has isOperational === undefined, so an unexpected error from a driver or a dependency falls to the generic branch - that part is sound. What the flag cannot do is judge the message: AppError defaults isOperational to true, so new AppError(dbError.message) is marked safe to show and the driver text goes to the client. The rule the code assumes is that an AppError message is one you wrote, never one you forwarded.

Fastify Error Handler

// SECURE - Fastify with custom error handling
const fastify = require('fastify')({ logger: true });
const { v4: uuidv4 } = require('uuid');

// Custom error handler
fastify.setErrorHandler((error, request, reply) => {
  const errorId = uuidv4();

  // Log diagnostic error details
  fastify.log.error({
    errorId,
    error: error.message,
    stack: error.stack,
    path: request.url,
    method: request.method
  });

  // Determine status code
  const statusCode = error.statusCode || 500;

  // Return generic error
  reply.status(statusCode).send({
    error: statusCode === 404 ? 'Resource not found' : 'An error occurred',
    errorId
  });
});

// Route with error - the response schema is an allowlist, not documentation
fastify.get('/user/:id', {
  schema: {
    response: {
      200: {
        type: 'object',
        properties: {
          id: { type: 'integer' },
          email: { type: 'string' }
        }
      }
    }
  }
}, async (request, reply) => {
  const user = await User.findById(request.params.id);
  if (!user) {
    return reply.code(404).send({ error: 'User not found' });
  }
  return user;   // only id and email are serialized; every other column is dropped
});

Why this works:

  • Global handler blocks Fastify default detail exposure.
  • Structured logging keeps full details server-side.
  • Error IDs correlate client reports to logs.
  • Status codes are preserved without leaking internals.
  • The response schema is what keeps the success path safe: fast-json-stringify emits only the declared properties, so a column added to the model later cannot appear in the response without someone adding it here too. Measured on Fastify 5.12.5 - the same route without a response schema returns every property of the object it is handed, passwordHash included.

Next.js API Error Handling

// SECURE - Next.js API routes with secure error handling
// pages/api/users/[id].js
import { v4 as uuidv4 } from 'uuid';
import logger from '../../../lib/logger';

export default async function handler(req, res) {
  const errorId = uuidv4();

  try {
    const user = await prisma.user.findUnique({
      where: { id: req.query.id }
    });

    if (!user) {
      return res.status(404).json({ error: 'Resource not found' });
    }

    res.status(200).json(user);
  } catch (error) {
    // Log full error server-side
    logger.error({
      errorId,
      message: error.message,
      stack: error.stack,
      path: req.url
    });

    // Return generic error
    res.status(500).json({
      error: 'An error occurred',
      errorId
    });
  }
}

// next.config.js - Production configuration
module.exports = {
  reactStrictMode: true,
  productionBrowserSourceMaps: false, // Disable source maps in production
}

Why this works:

  • API errors are caught and sanitized before response, so nothing reaches Next.js's own error path and the dev-mode _error page never comes into it.
  • Full details are logged with an error ID.
  • Application logging is handled explicitly instead of relying on build-time console removal.
  • 404s are handled explicitly, so a missing row returns a chosen 404 rather than whatever the ORM raises.
  • productionBrowserSourceMaps: false is already the Next.js default; setting it explicitly documents the intent and prevents someone enabling it for one debugging session and leaving it on.

Structured Logging with Redaction

// SECURE - Winston logger with sensitive data redaction
const winston = require('winston');

// Custom format to redact sensitive data.
// Note this walks the WHOLE record, not just info.message - see below.
const SENSITIVE_PATTERNS = [
  { pattern: /(password["']?\s*[:=]\s*["']?)[^"'},\s]+/gi, replacement: '$1***REDACTED***' },
  { pattern: /(token["']?\s*[:=]\s*["']?)[^"'},\s]+/gi, replacement: '$1***REDACTED***' },
  { pattern: /\b\d{13,19}\b/g, replacement: '***CARD***' }, // Credit card numbers
  { pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, replacement: '***EMAIL***' },
];

const redactString = (text) =>
  SENSITIVE_PATTERNS.reduce(
    (acc, { pattern, replacement }) => acc.replace(pattern, replacement),
    text
  );

const redactValue = (value, seen = new WeakSet()) => {
  if (typeof value === 'string') return redactString(value);
  if (value === null || typeof value !== 'object' || seen.has(value)) return value;
  seen.add(value); // guards against a circular reference in logged metadata
  for (const key of Object.keys(value)) {
    value[key] = redactValue(value[key], seen);
  }
  return value;
};

const redactSensitiveData = winston.format((info) => redactValue(info));

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    redactSensitiveData(),
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({
      filename: '/var/log/app/error.log',
      level: 'error'
    }),
    new winston.transports.File({
      filename: '/var/log/app/combined.log'
    })
  ]
});

// Don't log to console in production
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }));
}

module.exports = logger;

Why this works:

  • Redaction runs before the record reaches any transport, so every file the logger writes is covered by one piece of configuration.
  • It walks the entire record rather than info.message alone. That matters because the error handler further up this page logs { errorId, message, stack, path } - the connection string and any secret in the error text live in stack, not in message. A format that rewrites only info.message leaves stack and every other metadata field in the clear, which is the shape this pattern is most often written in.
  • Console logging is disabled in production.
  • Separate files support log rotation and access control.

Two limits to know before relying on it. Content patterns can only match a secret that sits next to a marker they recognise. A value split from its key across two fields - { path: 'password', value: 'hunter2' }, which is the shape express-validator's errors.array() produces - has nothing for /password\s*[:=]/ to anchor on and passes straight through. Where the leak you are guarding against is structural rather than textual, use a path-based redactor: pino has one built in, and pino({ redact: { paths: ['password', '*.password', 'req.headers.authorization', 'errors[*].value'], censor: '***REDACTED***' } }) covers all four of those, including the nested one.

And redaction is a backstop for a secret that leaked past review, not a licence to log secrets deliberately. The fix for the console.log('Login attempt:', { username, password }) above is to delete password from the call, not to trust a regex downstream of it.

Validation Error Sanitization

// SECURE - Sanitized validation errors
const { body, validationResult } = require('express-validator');

app.post('/register', [
  body('email').isEmail(),
  body('password').isLength({ min: 8 })
], (req, res) => {
  const errors = validationResult(req);

  if (!errors.isEmpty()) {
    // Log the field and the rule that failed - NOT errors.array(), whose
    // entries carry a `value` holding what was submitted. On this route that
    // is the password.
    logger.warn('Validation failed', {
      fields: errors.array().map(({ path, msg, location }) => ({ path, msg, location }))
    });

    // Return generic error to client
    return res.status(400).json({
      error: 'Invalid input provided'
    });
  }

  // Process valid request
  res.json({ status: 'success' });
});

Why this works:

  • The client gets a fixed string, so field names, constraints and the shape of the model stay server-side, and the 400 status is preserved.
  • The log keeps what makes a validation failure diagnosable - which field, in which part of the request, against which rule - and drops the one thing that is never needed for that: the value.
  • errors.array() is not safe to log as-is. Each entry is { type, value, msg, path, location }, and value is the submitted input. Measured on express-validator 7, posting {"email":"nope", "password":"hunter2"} to this route produces [{"type":"field","value":"hunter2","msg":"Invalid value","path":"password", "location":"body"}] - so logging the array verbatim writes the password to disk, past the redactor above, which cannot match a value whose key sits in a different field. Projecting the fields you want is the fix, and it is worth doing at the call site rather than trusting a downstream filter to notice.

Common Pitfalls

  • NODE_ENV never actually set to production in the deployment environment: environment-aware error handlers that branch on process.env.NODE_ENV !== 'production' fail open - a container, serverless platform, or process manager that doesn't explicitly set NODE_ENV=production leaves the verbose branch active in what is otherwise a live production deployment.
  • A route's own catch block still responds directly instead of calling next(error): a centralized error-handling middleware only runs for errors that reach it - any route that keeps its old res.status(500).json({ error: error.message }) instead of forwarding to the global handler bypasses the sanitization entirely, even after the middleware is added.
  • Winston/Pino redaction configured on one logger instance, but console.log/console.error calls remain elsewhere: a redacting format only processes messages that go through that specific logger - any stray console.error(err) left in older route handlers, middleware, or a quick debugging line writes the unredacted error straight to stdout.
  • A promise chain missing .catch() inside an Express route not wrapped by an async-error handler: without express-async-errors, a wrapping helper, or Express 5's built-in promise rejection handling, an unhandled rejection in an unwrapped async route handler can crash the process or fall through to a default handler that behaves differently from the app's own sanitized error middleware.

Additional Resources