Express gives you almost nothing by default - no security headers, no rate limiting, no CORS policy - which is exactly why Node.js APIs vary so much in security posture between teams that know to add these and teams that ship the bare minimum that makes requests work. This guide covers the gaps that show up most often in real Express applications.
1. Security Headers: Helmet, Not Manual Headers
Express sets no security headers on its own. The helmet middleware applies sane defaults for the full set (CSP, HSTS, X-Content-Type-Options, and more) in one line, and is far less error-prone than setting each header manually and forgetting one.
import helmet from 'helmet';
app.use(helmet());
// CSP needs app-specific tuning - helmet's default is a reasonable starting point
app.use(helmet.contentSecurityPolicy({
directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"] },
}));
2. NoSQL Injection: MongoDB Operator Injection
If a MongoDB query object is built directly from req.body or req.query without validation, an attacker can inject MongoDB query operators instead of plain values - {"$ne": null} as a "password" value matches any non-null password, bypassing authentication entirely.
// Vulnerable: req.body.password could be { "$ne": null }
const user = await User.findOne({
username: req.body.username,
password: req.body.password,
});
// Fixed: validate types before the query ever runs
if (typeof req.body.password !== 'string') return res.status(400).end();
// Or strip operator keys from user-controlled input entirely
import mongoSanitize from 'express-mongo-sanitize';
app.use(mongoSanitize());
3. Prototype Pollution
A recursive merge function (a hand-rolled one, or certain versions of popular utility libraries) that processes an attacker-controlled object can be tricked into setting __proto__ or constructor.prototype properties, polluting Object.prototype globally for the entire running process - every object in the application inherits the polluted property afterward, which has been used in real-world Node.js apps to bypass authorization checks or achieve remote code execution depending on what downstream code trusts.
// Vulnerable pattern: merging user input without checking key names
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') merge(target[key], source[key]);
else target[key] = source[key];
}
}
merge(config, req.body); // req.body = {"__proto__": {"isAdmin": true}}
// Fixed: reject dangerous keys explicitly
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (DANGEROUS_KEYS.includes(key)) continue;
// ... proceed with the merge
}
}
Keep dependencies patched specifically for this class of issue - several widely-used npm packages have shipped and later fixed prototype pollution vulnerabilities in their merge/clone utilities; npm audit in CI catches known cases, but doesn't catch the same bug in first-party code.
4. Command Injection via child_process
exec() runs its argument through a shell, so any user input concatenated into the command string can inject shell metacharacters. execFile() or spawn() with an argument array never invoke a shell, which eliminates this class of injection entirely for that call.
// Vulnerable: shell interprets ; && | etc. in the filename
const { exec } = require('child_process');
exec(`convert ${req.body.filename} output.png`);
// Fixed: arguments passed as an array, no shell involved
const { execFile } = require('child_process');
execFile('convert', [req.body.filename, 'output.png']);
5. Rate Limiting
import rateLimit from 'express-rate-limit';
app.use('/api/login', rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
}));
Apply this per-route with tighter limits on authentication and password-reset endpoints specifically - a single global limit tuned for normal API usage is usually too permissive to meaningfully slow down credential stuffing against a login endpoint.
6. CORS: Wildcard Origin Plus Credentials Is the Dangerous Combination
The cors package's default is permissive. As with the Laravel-specific version of this issue covered in our Laravel security guide, the risky configuration is a wildcard origin combined with credentials: true - browsers block that exact combination for credentialed requests, but a config that lists explicit origins instead needs each one audited, since every origin added widens what can make authenticated cross-origin calls.
app.use(cors({
origin: ['https://app.yoursite.com'],
credentials: true,
}));
7. Session Cookies and express-session
app.use(session({
secret: process.env.SESSION_SECRET, // never hardcoded, never committed
cookie: {
secure: true, // HTTPS only
httpOnly: true, // no JS access
sameSite: 'strict',
},
}));
Building This Into Your API Security Checklist
Most of these gaps exist at the framework configuration layer, not in application logic, which means they're visible from outside the same way for Express as for any other backend. Our API Security Checklist covers the framework-agnostic checklist this maps onto, and Shieldome's API security scanner checks for missing rate limiting, CORS misconfiguration, and security headers automatically. Create a free account to run your first scan.