CWE-201: Insertion of Sensitive Information Into Sent Data - JavaScript
Overview
In JavaScript and Node.js applications, CWE-201 means sensitive information leaving the server in an API response, an error body, or the client bundle. Express, NestJS, React, and Next.js all serialize objects and configuration on the developer's behalf, so password hashes, tokens, API keys, environment variables, stack traces, and user PII go out by default unless something names the fields that may go.
The mechanisms recur: Node.js's default error handling exposes stack traces, Mongoose and Sequelize models serialize all fields including the sensitive ones, bundlers inline environment variables into client-side code, and detailed development-mode error responses reach production. Logging libraries such as Winston and Morgan capture sensitive request data, GraphQL introspection exposes internal schema details, a Redux store hands its contents to anyone with browser DevTools, and secrets end up committed to version control.
The frameworks ship controls for all of this - .gitignore, environment variable management, serialization control - but each one has to be configured deliberately.
Primary Defence: Use explicit field selection with .select() or Data Transfer Objects (DTOs) to control exposed fields instead of serializing entire Mongoose/Sequelize models, implement global error handlers that return generic messages while logging full details server-side, use environment variable prefixes (NEXT_PUBLIC_, REACT_APP_) to separate client/server configs, and configure Winston/Pino with custom formats to redact sensitive fields (passwords, tokens, credit cards) before logging.
The examples below apply that across Express, NestJS, React, and Next.js.
Common Vulnerable Patterns
Direct MongoDB/Sequelize Model Serialization
// VULNERABLE - Exposing entire database document including sensitive fields
const express = require('express');
const mongoose = require('mongoose');
const app = express();
// MongoDB schema with sensitive fields
const userSchema = new mongoose.Schema({
username: String,
email: String,
passwordHash: String, // SENSITIVE!
resetToken: String, // SENSITIVE!
apiKey: String, // SENSITIVE!
isAdmin: Boolean, // INTERNAL!
creditCard: String // SENSITIVE!
});
const User = mongoose.model('User', userSchema);
app.get('/api/user/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
// Returns ALL fields to the client!
res.json(user);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Attack result - JSON response:
// {
// "_id": "507f1f77bcf86cd799439011",
// "username": "john",
// "email": "john@example.com",
// "passwordHash": "$2b$10$N9qo8uLOickgx2ZMRZoMye...", ← EXPOSED!
// "resetToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ← EXPOSED!
// "apiKey": "sk-1234567890abcdef", ← EXPOSED!
// "isAdmin": true, ← INTERNAL INFO!
// "creditCard": "4111-1111-1111-1111", ← EXPOSED!
// "__v": 0
// }
Why this is vulnerable: The response shape is whatever the document or row currently contains, so the API changes when the schema does and nobody edits the route. With MongoDB that is sharper than with a relational store: documents are not uniform, so a field added to some records by an older code path is returned for those records only, and never appears in a review of the model definition.
res.json(user) also serialises the Mongoose document's own properties, including __v. Use a projection or a toJSON transform that lists what goes out, so a new field is invisible until someone adds it deliberately.
Stack Traces in Production Error Responses
// VULNERABLE - Exposing internal paths, code structure, and environment details
const express = require('express');
const app = express();
app.post('/api/process', async (req, res) => {
try {
const result = await performDatabaseOperation(req.body);
res.json(result);
} catch (error) {
// Exposes full stack trace with file paths, line numbers, dependencies
res.status(500).json({
error: error.message,
type: error.name,
stack: error.stack, // DANGEROUS!
code: error.code
});
}
});
// Attack result when error occurs:
// {
// "error": "connect ECONNREFUSED 10.0.1.50:5432",
// "type": "Error",
// "stack": "Error: connect ECONNREFUSED 10.0.1.50:5432
// at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1144:16)
// at Protocol._enqueue (/home/deploy/app/node_modules/mysql/lib/protocol/Protocol.js:144:48)
// at Connection.query (/home/deploy/app/routes/api.js:67:18)
// Database connection string: mysql://admin:password123@10.0.1.50:3306/production",
// "code": "ECONNREFUSED"
// }
// ← Exposes internal IP addresses, file paths, credentials!
Why this is vulnerable: A Node stack trace names absolute paths, so it discloses the deployment directory and usually the username, and the frames identify every middleware and library version in the request path. That is reconnaissance which stays useful after the original error is fixed.
Express is the specific trap. Its default error handler already includes the stack in the response, and it suppresses that only when NODE_ENV is exactly the string production - so a container started without that variable, or with NODE_ENV=prod, leaks traces with no error-handling code of its own involved.
Environment Variables Leaked to Client
// VULNERABLE - Webpack/Vite exposing secrets to browser bundle
// .env file
/*
DATABASE_URL=postgresql://admin:secret@db.internal:5432/prod
API_KEY=sk_live_1234567890abcdef
JWT_SECRET=super-secret-jwt-key
STRIPE_SECRET_KEY=sk_test_abc123
*/
// webpack.config.js
const webpack = require('webpack');
module.exports = {
plugins: [
// Exposes ALL environment variables to client bundle!
new webpack.DefinePlugin({
'process.env': JSON.stringify(process.env) // DANGEROUS!
})
]
};
// Client-side code
const config = {
apiUrl: process.env.API_URL,
apiKey: process.env.API_KEY // EXPOSED IN BROWSER!
};
// Attack: View page source or check browser DevTools
// window.process = {
// env: {
// DATABASE_URL: "postgresql://admin:secret@db.internal:5432/prod", ← EXPOSED!
// JWT_SECRET: "super-secret-jwt-key", ← EXPOSED!
// STRIPE_SECRET_KEY: "sk_test_abc123", ← EXPOSED!
// ...
// }
// }
Why this is vulnerable: DefinePlugin performs a textual substitution at build time, so JSON.stringify(process.env) bakes every variable the build machine held into the bundle as a literal. The result is not a runtime lookup that might fail - it is a constant in a file served to every visitor, and it stays in whatever CDN and browser caches picked it up after the fix ships.
This is why the frameworks require a prefix. Vite exposes only VITE_-prefixed variables and Create React App only REACT_APP_, precisely so that adding a secret to .env does not publish it. Overriding process.env wholesale removes the one control that made the convention safe, and the CI environment usually holds more than the developer's does.
Detailed Login Error Messages
// VULNERABLE - Enables user enumeration and reveals password validation
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ username });
// Reveals whether username exists
if (!user) {
return res.status(404).json({
error: `No user found with username: ${username}` // USER ENUMERATION!
});
}
// Reveals password validation details
const passwordValid = await bcrypt.compare(password, user.passwordHash);
if (!passwordValid) {
return res.status(401).json({
error: `Invalid password for user ${username}`,
passwordHash: user.passwordHash, // EXPOSES PASSWORD HASH!
hint: 'Password must be at least 8 characters'
});
}
res.json({ token: generateToken(user) });
});
// Attack result for enumeration:
// POST /api/login {"username": "admin"}
// Response: "No user found with username: admin"
//
// POST /api/login {"username": "john"}
// Response: "Invalid password for user john"
// ← Attacker knows "john" exists but "admin" doesn't!
Why this is vulnerable: Reporting "no such user" separately from "wrong password" turns the endpoint into a membership oracle, and a confirmed username list is what decides whether a credential-stuffing run is aimed at this host.
The message is not the only channel. Differing status codes, a Set-Cookie present on one path, and the response time all carry the same answer - the unknown-user path returns without running bcrypt, which is a difference measurable over the network. Comparing against a dummy hash on that path is what removes it.
Sensitive Data in Application Logs
// VULNERABLE - Logging sensitive data accessible in log files
const express = require('express');
const winston = require('winston');
const morgan = require('morgan');
const logger = winston.createLogger({
level: 'debug',
format: winston.format.json(),
transports: [new winston.transports.File({ filename: 'app.log' })]
});
const app = express();
// Logs the full request line - including any secret in the query string
app.use(morgan('combined')); // /api/reset?token=abc123 is written verbatim
app.post('/api/payment', async (req, res) => {
// Logs sensitive payment information
logger.info('Processing payment', { request: req.body }); // LOGS CREDIT CARDS!
// Logs credentials
logger.debug(`User: ${req.body.username}, Password: ${req.body.password}`); // DANGEROUS!
try {
const result = await chargeCard(req.body.cardNumber, req.body.cvv);
res.json({ status: 'success' });
} catch (error) {
// Logs full request with sensitive data
logger.error('Payment failed', {
request: req.body, // LOGS SENSITIVE DATA!
error: error.stack
});
res.status(500).json({ error: 'Payment failed' });
}
});
// app.log will contain:
// {
// "message": "Processing payment",
// "request": {
// "username": "john",
// "password": "secret123", ← EXPOSED!
// "cardNumber": "4111111111111111", ← EXPOSED!
// "cvv": "123" ← EXPOSED!
// }
// }
Why this is vulnerable: Logs are shipped, indexed, replicated and retained by whatever policy operations chose, so a secret written once is readable for longer and by more people than the record it came from.
The usual cause is logging an object rather than named fields, because that captures whatever the object holds later. Redaction is available in both loggers but not in the same form, and the difference matters when copying a snippet between them. Pino has it built in: redact: ['password'] names paths, and a path is matched literally - it does not cover body.password, or a field a spread renamed on the way in, so the control quietly stops covering the case it was added for. Winston has no redact option at all; winston.createLogger({ redact: ['password'] }) is accepted and ignored, and on winston 3.19.0 the field is written in full. Redaction there is a custom format, which is what the secure logging pattern below builds.
morgan('combined') is a separate channel with a separate exposure. It logs the request line, the status, the referrer and the user-agent - not the request body, so it is not where a posted password ends up. What it does capture is the full URL including the query string, which is why credentials belong in a body or a header rather than in a query parameter (CWE-598 is the variant of this CWE for exactly that).
React State Exposing Sensitive Data
// VULNERABLE - Storing sensitive data in React state visible in DevTools
import React, { useState, useEffect } from 'react';
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user/me')
.then(res => res.json())
.then(data => {
// Stores ALL user data in React state, visible in DevTools!
setUser(data); // Includes passwordHash, apiKey, etc.
});
}, []);
return (
<div>
<h1>{user?.username}</h1>
<p>{user?.email}</p>
</div>
);
}
// Attack: Open React DevTools → Components tab
// UserProfile
// useState
// user: {
// username: "john",
// email: "john@example.com",
// passwordHash: "$2b$10$...", ← VISIBLE IN DEVTOOLS!
// apiKey: "sk-1234567890abcdef", ← VISIBLE IN DEVTOOLS!
// resetToken: "abc-123-def" ← VISIBLE IN DEVTOOLS!
// }
Why this is vulnerable: The leak has already happened before React is involved - /api/user/me returned the hash and the key, so they are in the network response, in the browser's memory and in any DevTools session regardless of what the component stores. Fixing the component changes nothing.
That is the point worth taking from this example: client-side state is not a place to fix an over-broad response, because everything the client received is visible to whoever is using the client. The endpoint has to stop sending the fields. Trimming what goes into state is still worth doing afterwards - it keeps secrets out of Redux DevTools' persisted action log and out of state-restoring bug reporters - but it is hygiene, not the remediation.
Secure Patterns
DTO Pattern with Field Selection
// SECURE - Using explicit field selection and DTOs
const express = require('express');
const mongoose = require('mongoose');
const app = express();
const userSchema = new mongoose.Schema({
username: String,
email: String,
passwordHash: String,
resetToken: String,
apiKey: String,
isAdmin: Boolean,
creditCard: String
});
const User = mongoose.model('User', userSchema);
// DTO class - only safe fields
class UserDTO {
constructor(user) {
this.id = user._id;
this.username = user.username;
this.email = user.email;
// NEVER include: passwordHash, resetToken, apiKey, creditCard
}
static fromDocument(user) {
return new UserDTO(user);
}
}
app.get('/api/user/:id', async (req, res) => {
try {
// Select only safe fields from database
const user = await User.findById(req.params.id)
.select('username email') // Explicit field selection
.lean(); // Convert to plain object
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Convert to DTO
const userDTO = UserDTO.fromDocument(user);
res.json(userDTO);
} catch (error) {
console.error('Error fetching user:', error);
res.status(500).json({
error: 'Failed to fetch user',
errorCode: 'USER_FETCH_ERROR'
});
}
});
// Defence in depth: a schema-level toJSON transform that BUILDS the output
// rather than deleting from it, so a field added to the schema later is
// absent from responses until someone names it here.
const PUBLIC_USER_FIELDS = ['username', 'email'];
userSchema.set('toJSON', {
transform: function (doc, ret, options) {
const safe = { id: ret._id };
for (const field of PUBLIC_USER_FIELDS) {
if (ret[field] !== undefined) safe[field] = ret[field];
}
return safe;
}
});
Why this works: Both halves are allowlists, and that is the property that matters. .select('username email') asks the database for two fields, so the sensitive ones are never in the process to begin with; the DTO constructor names the same two again at the boundary, so a change to the query cannot widen the response on its own.
The toJSON transform is written the same way deliberately. Writing it as a list of delete ret.passwordHash statements is the more common form and it is a denylist: it removes the fields somebody remembered, and the next sensitive field added to the schema is serialised by default - the precise failure the DTO exists to prevent, reintroduced one layer down. Building safe from a named list inverts that, and it also drops Mongoose's __v without anyone having to think of it.
Note the ordering constraint on the query path: .lean() returns a plain object rather than a document, so toJSON never runs on it. The transform protects the routes that return documents; it is not a reason to stop selecting fields.
Global Error Handling with Generic Responses
// SECURE - Centralized error handling with generic user errors
const express = require('express');
const winston = require('winston');
const app = express();
// Configure logger for server-side only
const logger = winston.createLogger({
// Same reasoning as the stack-trace branch below: test for development,
// so an unset NODE_ENV does not turn on debug logging in production
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Custom error classes.
//
// `message` is the DIAGNOSTIC, for the log only. What the client is told is
// the fixed string on the class - so a caller writing
// new NotFoundError(`No user with email ${email}`) cannot echo that email
// back, however the error is constructed.
class AppError extends Error {
static publicMessage = 'Request could not be processed';
constructor(message, statusCode, errorCode) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
this.isOperational = true;
}
// Never the constructor's message
get clientMessage() {
return this.constructor.publicMessage;
}
}
class ValidationError extends AppError {
static publicMessage = 'Invalid request data';
constructor(message) {
super(message, 400, 'VALIDATION_ERROR');
}
}
class NotFoundError extends AppError {
static publicMessage = 'Resource not found';
constructor(message) {
super(message, 404, 'NOT_FOUND');
}
}
// Routes are registered FIRST. Express walks the middleware stack in
// registration order and only forwards to handlers that come after the
// point next(err) was called, so a 404 or error handler declared above
// the routes is unreachable from them.
app.post('/api/process', async (req, res, next) => {
try {
const result = await performDatabaseOperation(req.body);
res.json(result);
} catch (error) {
// Pass error to global handler
next(error);
}
});
// 404 handler - after every route, before the error handler
app.use((req, res) => {
res.status(404).json({
error: 'Resource not found',
errorCode: 'NOT_FOUND'
});
});
// Global error handler middleware - registered LAST
app.use((err, req, res, next) => {
// Log full error details server-side (with stack trace)
logger.error('Error occurred', {
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
ip: req.ip,
userId: req.user?.id // If available
});
// Determine status code
const statusCode = err.statusCode || 500;
// Generic error response to client. Read the class's fixed public string,
// never err.message - the message is attacker-influenced far more often
// than it looks (it routinely interpolates the value that failed).
const errorResponse = {
error: err.isOperational ? err.clientMessage : 'Internal server error',
errorCode: err.errorCode || 'INTERNAL_ERROR',
timestamp: new Date().toISOString()
};
// Test for development, not against production: an unset or misspelled
// NODE_ENV must select the quiet branch, not the one that ships a stack
if (process.env.NODE_ENV === 'development' && !err.isOperational) {
errorResponse.stack = err.stack; // Only when explicitly in development
}
res.status(statusCode).json(errorResponse);
});
Why this works: The handler logs the full error, stack trace included, on the server and returns a fixed message to the client. The error code and timestamp give support enough to find that log line without the response carrying a file path or a database endpoint.
Two things about this example are load-bearing and easy to get wrong.
Registration order is the whole control. Express dispatches middleware in the order it was added and, on next(err), resumes searching forward from where the error was raised. An error handler added before the routes is never reached, and a catch-all 404 added before them answers every request itself. Measured on Express 5.2.1 with the handlers declared above the route, POST /api/process returned 404 {"error":"Resource not found"} - the route body never ran and the error handler never fired. Registering routes, then the 404, then the error handler is what makes the other two reachable.
The environment test is written against development, not production. NODE_ENV !== 'production' makes every way of getting the variable wrong - unset, empty, prod, a container that never inherited the env block - a way of selecting the branch that serialises err.stack. That is the same default this page's own stack trace pattern warns about in Express's built-in handler. Measured with NODE_ENV unset, the !== form returned the full trace including the absolute deployment path; the === 'development' form returns the generic body unless someone sets the variable on purpose.
Secure Environment Variable Management
# .env - server-side only, NEVER commit to git
DATABASE_URL=postgresql://admin:secret@db.internal:5432/prod
JWT_SECRET=super-secret-jwt-key
STRIPE_SECRET_KEY=sk_live_abc123
# .env.local - client-safe variables, prefixed with NEXT_PUBLIC_ or REACT_APP_
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_abc123
Next.js inlines NEXT_PUBLIC_-prefixed variables into the client bundle at build
time. Unprefixed variables are never sent to the client, so no server or public
runtime config is needed here - the legacy
serverRuntimeConfig/publicRuntimeConfig API is not part of current Next.js
guidance. What the config below does add is a guard against a server-only module
being pulled into the client bundle by an import that should not be there:
// next.config.js
module.exports = {
// SECURE - refuse to bundle server-side modules into the client
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
};
}
return config;
}
};
// pages/api/config.js - runs on the server, so it can read the secrets
export default function handler(req, res) {
const { DATABASE_URL, JWT_SECRET } = process.env;
// SECURE - only the public variable is sent back
res.json({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
// DO NOT include DATABASE_URL, JWT_SECRET, etc.
});
}
// components/App.js - runs in the browser
export default function App() {
// Only public variables are available on the client
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// process.env.JWT_SECRET is undefined here (secure!)
return <div>API URL: {apiUrl}</div>;
}
Why this works: The prefix is the allowlist - NEXT_PUBLIC_ for Next.js, REACT_APP_ for Create React App - so the split is enforced by the bundler rather than by remembering which variable is which. DATABASE_URL and JWT_SECRET can sit beside the publishable key and still never reach the browser. Code that needs a secret runs on the server, as the API route above does, and returns only the public value. The webpack fallbacks keep server-only modules out of the client bundle, and .gitignore keeps the .env files out of version control.
Secure Login with Generic Error Messages
// SECURE - Preventing user enumeration with consistent responses
const express = require('express');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const winston = require('winston');
const app = express();
const logger = winston.createLogger({
transports: [new winston.transports.File({ filename: 'auth.log' })]
});
const GENERIC_ERROR = 'Invalid credentials';
// Must match the cost factor used when storing passwords, or the dummy path
// below is measurably cheaper than a real verification
const BCRYPT_COST = 12;
// A real hash of a value no account can hold, generated at startup. A literal
// placeholder is not a valid bcrypt string: bcrypt.compare rejects it on parse
// and returns immediately, leaving the timing gap it was meant to close.
const DUMMY_HASH = bcrypt.hashSync(crypto.randomBytes(32).toString('hex'), BCRYPT_COST);
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
// Input validation
if (!username || !password) {
return res.status(401).json({ error: GENERIC_ERROR });
}
try {
const user = await User.findOne({ username });
let isValid = false;
if (!user) {
// Log attempt server-side
logger.warn(`Login attempt for non-existent user: ${username}`);
// Perform dummy hash comparison to prevent timing attacks
await bcrypt.compare(password, DUMMY_HASH);
} else {
// Check actual password
isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
logger.warn(`Failed login attempt for user: ${username}`);
} else {
logger.info(`Successful login for user: ${username}`);
}
}
// Return same error for both "user not found" and "wrong password"
if (!user || !isValid) {
return res.status(401).json({ error: GENERIC_ERROR });
}
// Success - return only safe data
const token = generateToken(user);
res.json({
token,
user: {
id: user._id,
username: user.username,
email: user.email
// NO passwordHash, apiKey, or other sensitive fields
}
});
} catch (error) {
logger.error('Login error', { error: error.message, username });
res.status(500).json({ error: 'Authentication service unavailable' });
}
});
Why this works: Every authentication failure returns the same status and the same message, so the response no longer says whether the username exists. The dummy comparison runs bcrypt.compare against a hash bcrypt itself produced at the same cost factor, so the unknown-username path takes roughly as long as a real verification and timing no longer distinguishes the two. Detailed failures are logged server-side for security monitoring, while responses contain only safe user fields.
Secure Logging with Sensitive Data Filtering
// SECURE - Custom Winston format to redact sensitive data
const winston = require('winston');
const express = require('express');
// Custom format to redact sensitive fields.
// Keys are compared lowercase, so every entry here must be lowercase too -
// a camelCase needle can never match a lowercased key.
const SENSITIVE_KEYS = [
'password', 'token', 'apikey', 'api_key', 'secret',
'creditcard', 'credit_card', 'cardnumber', 'card_number',
'cvv', 'ssn', 'authorization'
];
const CARD_PATTERN = /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g;
const redactSensitiveData = winston.format((info) => {
// Mutates in place rather than returning a copy: winston carries the
// level and splat on Symbol keys, which a {...spread} would drop.
const redactInPlace = (obj) => {
for (const key of Object.keys(obj)) {
if (key === 'level' || key === 'timestamp') continue;
const lowerKey = key.toLowerCase();
if (SENSITIVE_KEYS.some(sensitive => lowerKey.includes(sensitive))) {
obj[key] = '[REDACTED]';
}
// Recurse into nested objects and arrays
else if (obj[key] && typeof obj[key] === 'object') {
redactInPlace(obj[key]);
}
// Redact strings that look like credit cards
else if (typeof obj[key] === 'string') {
obj[key] = obj[key].replace(CARD_PATTERN, 'XXXX-XXXX-XXXX-XXXX');
}
}
return obj;
};
// Walk info itself, not only its object-valued properties: winston merges
// the metadata argument into info as top-level keys, so a scalar like
// info.cardNumber is exactly the case that has to be covered.
return redactInPlace(info);
});
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
redactSensitiveData(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Custom Morgan token filter for HTTP request logging
const morgan = require('morgan');
// Don't log authorization headers
morgan.token('safe-headers', (req) => {
const headers = { ...req.headers };
delete headers.authorization;
delete headers.cookie;
return JSON.stringify(headers);
});
// Don't log the query string. `:url` is the whole request target, so a
// password-reset or magic link puts its token straight into the access log.
// Keep the path for routing diagnostics and the parameter NAMES for debugging
// - the names are what you need to know a filter was applied, the values are
// what you must not keep.
morgan.token('safe-url', (req) => {
const url = new URL(req.originalUrl || req.url, 'http://placeholder');
const keys = [...url.searchParams.keys()];
return keys.length ? `${url.pathname}?${keys.join('&')}=[REDACTED]` : url.pathname;
});
const app = express();
// Use custom format that excludes sensitive data
app.use(morgan(':method :safe-url :status :response-time ms - :safe-headers', {
stream: {
write: (message) => logger.info(message.trim())
}
}));
app.post('/api/payment', async (req, res) => {
// Safe to log - sensitive fields will be redacted
logger.info('Processing payment', {
userId: req.body.userId,
amount: req.body.amount,
cardNumber: req.body.cardNumber, // Will be redacted
password: req.body.password // Will be redacted
});
try {
const result = await chargeCard(req.body);
res.json({ status: 'success', transactionId: result.id });
} catch (error) {
logger.error('Payment failed', {
userId: req.body.userId,
error: error.message
// Don't log sensitive request data here
});
res.status(500).json({ error: 'Payment processing failed' });
}
});
Why this works: The format sits in the pipeline ahead of winston.format.json(), so it rewrites the log record before anything serialises it. Redaction is by field name rather than by value, which keeps it working on tokens and keys that have no recognisable shape, and the credit-card pattern is a second pass for numbers that arrive inside a free-text string.
Two details decide whether it covers anything, and both are places an earlier version of this example failed when it was run.
Walk the whole record, not just its object-valued properties. Winston merges the metadata argument into info as top-level keys, so logger.info('Processing payment', { cardNumber, password }) produces info.cardNumber and info.password as plain strings. A loop that recurses only where typeof info[key] === 'object' skips exactly those, and measured on winston 3.19.0 it wrote "cardNumber":"4111111111111111","password":"secret123" to the log while redacting the same fields correctly when they were nested one level deeper. Redact info itself and the nested case comes along for free.
Lowercase the needles as well as the key. The comparison lowercases each key and then tests lowerKey.includes(sensitive), so a camelCase entry in the list can never match anything: 'cardnumber'.includes('cardNumber') is false. Enumerating the list against itself is a ten-second check and is worth doing whenever one is edited - in an earlier version apiKey, creditCard and cardNumber were all declared and all inert, and the miss was hidden because a 16-digit card value was caught by the pattern instead.
Mutating info in place matters for a third reason: winston carries the level and the splat on Symbol keys, which Object.keys() does not enumerate and a {...spread} copy would silently drop.
What this does not cover: the exception. logger.error('Payment failed', { error: error.message }) passes the message through as an ordinary string, so it gets the credit-card pattern and nothing else - an exception whose text contains a token or a connection string is written verbatim. Keeping sensitive values out of exception messages in the first place is the control; the redactor is a backstop and cannot be the reason it is safe to log an arbitrary error.
NestJS with DTOs and Class Validators
// SECURE - Using NestJS DTOs with explicit field control
// src/users/users.controller.ts
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { Expose, plainToInstance } from 'class-transformer';
// TypeORM Entity (database model)
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
email: string;
@Column()
passwordHash: string; // NEVER expose
@Column({ nullable: true })
resetToken: string; // NEVER expose
@Column({ nullable: true })
apiKey: string; // NEVER expose
}
// DTO for responses - only safe fields
export class UserResponseDTO {
@Expose()
id: number;
@Expose()
username: string;
@Expose()
email: string;
// Explicitly exclude sensitive fields
// (they won't be included even if present)
static fromEntity(user: User): UserResponseDTO {
return plainToInstance(UserResponseDTO, user, {
excludeExtraneousValues: true // Only include @Expose() fields
});
}
}
@Controller('api/users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
async getUser(@Param('id') id: string): Promise<UserResponseDTO> {
const user = await this.usersService.findById(parseInt(id));
if (!user) {
throw new NotFoundException('User not found');
}
// Convert to DTO - only safe fields returned
return UserResponseDTO.fromEntity(user);
}
}
// Global exception filter
// src/common/global-exception.filter.ts - a separate module, which is why the
// @nestjs/common import appears again rather than being merged with the one above
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('ExceptionFilter');
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// A fixed message per status. Do NOT copy exception.message: NestJS
// builds it from whatever the thrower passed, which routinely
// interpolates the value that failed, and a ValidationPipe error
// carries the rejected input in its response object.
const PUBLIC_MESSAGES: Record<number, string> = {
400: 'Invalid request data',
401: 'Authentication required',
403: 'Not permitted',
404: 'Resource not found',
405: 'Method not allowed',
415: 'Unsupported media type',
};
let status = 500;
if (exception instanceof HttpException) {
status = exception.getStatus();
}
const message = PUBLIC_MESSAGES[status] ?? 'Internal server error';
const errorCode = status >= 500 ? 'INTERNAL_ERROR' : 'REQUEST_REJECTED';
const errorId = randomUUID();
// Log full error server-side, with the correlation id the client gets.
// request.url belongs here and not in the response - it carries the
// query string, so echoing it hands back any token in the link.
this.logger.error(
`errorId=${errorId} ${request.method} ${request.url}`,
exception instanceof Error ? exception.stack : exception
);
// Return generic error to client
response.status(status).json({
error: message,
errorCode,
errorId, // correlates to the log line; reveals nothing
timestamp: new Date().toISOString()
});
}
}
Why this works: plainToInstance with excludeExtraneousValues: true copies only the properties carrying @Expose(), so the DTO is an allowlist rather than a filtered entity - a column added to User later is absent from the response until someone adds an @Expose() for it. Without that option the call copies everything it finds and the annotations do nothing, which is the one setting worth checking in a review. Use plainToInstance, not plainToClass: the latter is the old name for the same function and is marked @deprecated from class-transformer 0.5.0.
The exception filter is @Catch() with no argument, so it receives everything, and it reads the status off the exception rather than hard-coding one. That distinction is what keeps a 404 a 404: NestJS raises NotFoundException for an unmatched route and MethodNotAllowedException for a bad verb, both HttpException subclasses, so exception.getStatus() carries the right code through and only genuinely unexpected errors fall to the 500 default. A filter that answered 500 unconditionally would leak nothing and still break every client's ability to tell "not there" from "we are broken".
The status is the only thing taken from the exception. The message is looked up from a fixed table, because exception.message is written by whoever threw and is the normal place for an interpolated value to appear - new NotFoundException(`No user with email ${email}`) is ordinary-looking code that turns the filter into the leak. A ValidationPipe failure is the sharper case: its response object carries the per-field messages, and NestJS's default 400 body is built from them.
request.url goes in the log, not in the response. It is the full request target including the query string, so echoing it as a path field hands back any token that arrived in the URL - a password-reset or magic link being the case that matters. The errorId gives support the same correlation the path was there to provide, and reveals nothing.
Common Pitfalls
- Filtering the response but not the object stored in the request/session:
delete result.passwordHashon the object youres.json()still leaves the original document (with the sensitive field intact) attached toreq.useror a session store that a later middleware, logger, or error handler might serialize. NEXT_PUBLIC_/REACT_APP_prefix added to a variable that shouldn't be public: the prefix doesn't scope a variable to "safe" - it tells the bundler to inline it into every client bundle at build time. Prefixing a real secret by mistake, or by copy-pasting an existing prefixed block, ships it to every visitor's browser.- Mongoose
toJSONtransform defined on the schema, but a route uses.lean()or a raw driver query that bypasses Mongoose document methods: the transform only runs when Mongoose serializes a full document instance - a.lean()query, an aggregation pipeline, or the native MongoDB driver returns plain objects that skip it entirely. - A generic error handler added, but an earlier
res.json({ error: err.message })still exists in an individual route's owncatchblock: Express doesn't route already-sent responses through the global error handler - each route needs to actually callnext(error)instead of responding directly for the centralized handler to apply.