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_moduleslayout, 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 Errorwith nothing in it, so the finding is about what runs where: a preview deployment, a staging box, or anything started withnext devbehind 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-byis 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 returnserr.messageanderr.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_ENValso drives behaviour in Express and in dependencies that do use!== 'production', keep settingNODE_ENV=productionin 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
isOperationalis 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-stringifyemits 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 aresponseschema returns every property of the object it is handed,passwordHashincluded.
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
_errorpage 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: falseis 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.messagealone. 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 instack, not inmessage. A format that rewrites onlyinfo.messageleavesstackand 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 }, andvalueis 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 avaluewhose 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_ENVnever actually set toproductionin the deployment environment: environment-aware error handlers that branch onprocess.env.NODE_ENV !== 'production'fail open - a container, serverless platform, or process manager that doesn't explicitly setNODE_ENV=productionleaves the verbose branch active in what is otherwise a live production deployment.- A route's own
catchblock still responds directly instead of callingnext(error): a centralized error-handling middleware only runs for errors that reach it - any route that keeps its oldres.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.errorcalls remain elsewhere: a redacting format only processes messages that go through that specific logger - any strayconsole.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: withoutexpress-async-errors, a wrapping helper, or Express 5's built-in promise rejection handling, an unhandled rejection in an unwrappedasyncroute handler can crash the process or fall through to a default handler that behaves differently from the app's own sanitized error middleware.