What are the key security best practices in Node.js?
Node.js security best practices - a set of defensive patterns that block the most common attack vectors: injection, prototype pollution, brute force, and dependency exploits.
Theory
TL;DR
- Node runs JS on the server, so unsanitized input can execute code directly, not just break a UI
- Validate every input at the boundary, hash passwords with bcrypt (12+ rounds), and run
npm auditbefore every deploy helmet()sets 7+ security headers in one line; skip it and XSS, clickjacking, and MIME-sniffing are open- Prototype pollution via
__proto__mutatesObject.prototypeglobally and persists until process restart - Never run as root, limit request body size, and rate-limit every public endpoint
Quick example
// Express signup with three layers of protection
const helmet = require('helmet');
const { body, validationResult } = require('express-validator');
const rateLimit = require('express-rate-limit');
app.use(helmet()); // CSP, HSTS, X-Frame-Options in one call
app.use(express.json({ limit: '10kb' })); // blocks oversized payloads
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
app.post('/signup', limiter, [
body('email').isEmail().normalizeEmail(),
body('password').isStrongPassword({ minLength: 12, minNumbers: 1 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors });
// "admin' OR 1=1" fails isEmail(); brute force hits the rate limit
});Three lines before the route handler do most of the work. The validation middleware runs before any database call, so malformed input never reaches the query layer.
Why Node.js requires server-side validation
Browsers have CSP and sandboxing. Node has neither. An unsanitized string from req.body can reach eval(), exec(), or a MongoDB operator directly. The event loop processes every request synchronously in that moment, so a single regex with catastrophic backtracking can freeze the entire process for all concurrent users.
One request. Wrong input. Process hangs.
Injection: three variants
SQL injection - use parameterized queries, never string concatenation.
// Bad - attacker sends id = "' OR '1'='1"
const query = `SELECT * FROM users WHERE id = '${req.params.id}'`;
// Good
const result = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);NoSQL injection (MongoDB) - { "password": { "$gt": "" } } bypasses password checks if you pass raw req.body into a query. Cast to string or run a schema validator before the query runs.
// Bad - attacker sends { "password": { "$gt": "" } }
db.users.find({ username: req.body.username, password: req.body.password });
// Good - cast and hash
db.users.find({
username: String(req.body.username),
password: await bcrypt.hash(req.body.password, 12)
});Command injection - exec() passes its first argument to the shell. execFile() with an array never invokes a shell at all.
// Bad - dir = "; cat /etc/passwd" runs fine
exec(`ls ${req.query.dir}`);
// Good - no shell, array args are not interpolated
execFile('ls', [req.query.dir], { encoding: 'utf8' });Prototype pollution
V8 parses JSON input as plain objects. An attacker who sends { "__proto__": { "isAdmin": true } } can mutate Object.prototype globally. Every object in the process inherits that property until restart. This is how CVE-2019-10744 worked against lodash below 4.17.21 in production apps.
// Block the three dangerous keys
function safeMerge(target, source) {
if (typeof source !== 'object' || source === null) return target;
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
target[key] = typeof source[key] === 'object'
? safeMerge(target[key] || {}, source[key])
: source[key];
}
return target;
}
// Even safer: Object.create(null) has no prototype chain to pollute
req.user = safeMerge(Object.create(null), req.body.userData || {});From what I see in code reviews, prototype pollution is the one that catches teams off guard most. The mutation is global and there is no error, so it goes unnoticed until something behaves wrong across the whole app.
Authentication and password storage
bcrypt with 12+ salt rounds is the standard. MD5 and SHA-1 are not acceptable for passwords.
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 12);
const isValid = await bcrypt.compare(inputPassword, hash);JWT: short expiry (15 minutes for access tokens), httpOnly cookies instead of localStorage, refresh tokens in a Redis session store with a revocation list. RS256 over HS256 lets you rotate the signing key without redeploying every service, because the verification key is public.
Dependency security
Run npm audit --audit-level=high in CI. A single outdated transitive dependency can open the whole app. Snyk catches more than npm audit does, particularly in transitive deps.
npm audit --audit-level=high
npm ci --only=production # dev deps stay out of prodSnyk reports that 70% of Node.js vulnerability cases trace back to dev dependencies shipped to production. Set production=true in .npmrc or use npm ci --only=production.
DoS protection
Rate limiting is the first line. Body size limits are the second. ReDoS (regex backtracking) is the quiet third.
// 5 login attempts per 15 minutes per IP
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
app.use('/login', loginLimiter);
// Body size limit before any parsing
app.use(express.json({ limit: '10kb' }));
// This regex hangs the process on "aaaaaaaaaaaaaX"
const dangerous = /^(a+)+$/;
// re2 guarantees linear time evaluation
const RE2 = require('re2');
const safe = new RE2('^[a-z]+$');Environment variables and secrets
// Never
const secret = 'hardcoded-jwt-secret';
// Always
const secret = process.env.JWT_SECRET;
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required');Never commit .env to git. Rotate secrets when a developer leaves the team. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault) for production deployments.
Common mistakes
1. JSON.parse(req.body) without schema validation.
Allows { "constructor": { "prototype": { "isAdmin": true } } } to corrupt Object.prototype globally. Fix: validate with Joi or express-validator before touching the parsed object.
2. Template literals inside exec().
// dir = "; rm -rf /" runs without error
exec(`ls ${dir}`);
// Fix: no shell, no interpolation
execFile('ls', [dir]);3. Running as root.
app.listen(3000) as root means a successful RCE hands the attacker root access to the machine. Fix: sudo -u www-data node app.js or call process.setuid('nobody') before listening.
4. Dev dependencies in production.
npm install in prod pulls in jest, nodemon, and their full dependency trees. Each is an attack surface. Use npm ci --only=production.
5. Missing body size limit.
express.json() without limit accepts any payload size. A 500 MB JSON body parses synchronously and exhausts memory. Add { limit: '10kb' } or a size that fits your use case.
Real-world usage
- Netflix, PayPal APIs -
app.use(helmet()); app.use(express.json({ limit: '10kb' }))as the first two middleware calls - NestJS (Uber microservices) -
@UseGuards(RateLimitGuard)combined with class-validator decorators - Hapi (Walmart e-commerce) -
server.auth.strategy('jwt', 'jwt', { key: privateKey }) - Express sessions (Discord bots) -
express-session({ store: new RedisStore(), secret: process.env.SESSION_SECRET }) - Fastify (Vercel edge) -
fastify.register(require('@fastify/rate-limit'))
Follow-up questions
Q: How does prototype pollution propagate across modules in Node.js?
A: It mutates Object.prototype through the JSON parsing path. Every for...in loop and Object.keys() call in every module sees the polluted property until the process restarts. Object.freeze(Object.prototype) stops new mutations but is generally too aggressive for production.
Q: What is the difference between bcrypt and scrypt for password hashing?
A: bcrypt works well on multi-core Node (fits worker threads model); scrypt requires more memory per operation, which resists GPU-based cracking better. Both are acceptable. scrypt is the newer NIST recommendation if you are starting a project from scratch.
Q: How would you secure file uploads in an Express app?
A: Multer middleware with a fileSize limit and MIME type filter: fileFilter: (req, file, cb) => /^image\//.test(file.mimetype) ? cb(null, true) : cb(new Error('Invalid type')). Scan uploaded files with ClamAV via clamscan before moving them to permanent storage.
Q: How do you prevent ReDoS without rewriting every regex in the codebase?
A: Use the safe-regex package in CI to flag catastrophic patterns, then replace flagged ones with the re2 library, which guarantees linear-time evaluation regardless of input.
Q: How do you secure WebSockets at a production level?
A: In the ws library, use the verifyClient callback for per-connection auth. Validate the Origin header on upgrade, rate-limit connection attempts, enforce TLS 1.3, and reject fragmented frames larger than 16 KB to block amplification attacks.
Q: How do you integrate dependency auditing into CI/CD?
A: npm audit --audit-level=high exits with code 1 on high or critical vulnerabilities. In GitHub Actions pipe the JSON output to jq '.metadata.vulnerabilities.high > 0' and fail the build. Snyk and Sonatype provide deeper transitive dependency analysis.
Examples
Basic: Helmet and rate limiting on a public API
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const app = express();
app.use(helmet()); // X-Content-Type-Options, HSTS, CSP, X-Frame-Options and more
app.use(express.json({ limit: '10kb' }));
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: { error: 'Too many requests' }
});
app.use('/api/', apiLimiter);
// Every /api/* route now has seven security headers and a rate limithelmet() with no arguments activates seven headers that cover the most common browser-based attacks. The body limit stops memory exhaustion before any controller code runs.
Intermediate: Full input validation on user signup (production pattern)
const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
app.post('/signup', [
body('email').isEmail().normalizeEmail(),
body('password').isStrongPassword({ minLength: 12, minNumbers: 1, minSymbols: 1 }),
body('username').trim().escape().isLength({ min: 3, max: 20 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const hash = await bcrypt.hash(req.body.password, 12);
await db.users.create({
email: req.body.email,
username: req.body.username,
password: hash
});
res.status(201).json({ message: 'Created' });
// "admin' OR 1=1" fails isEmail(); "password" fails isStrongPassword()
});The validation chain runs before the async function body. If any field fails, the request ends at line 6. The database never sees raw user input.
Advanced: Prototype pollution prevention in a merge middleware
// Attack vector: attacker sends { "userData": { "__proto__": { "isAdmin": true } } }
// Used against lodash < 4.17.21 in production (CVE-2019-10744)
function safeMerge(target, source) {
if (typeof source !== 'object' || source === null) return target;
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
target[key] = typeof source[key] === 'object'
? safeMerge(target[key] || {}, source[key])
: source[key];
}
return target;
}
app.use(express.json());
app.use((req, res, next) => {
// Object.create(null) has no prototype chain - nothing to pollute
req.user = safeMerge(Object.create(null), req.body.userData || {});
next();
});
// After the attack attempt:
console.log({}.isAdmin); // undefined - Object.prototype is clean
console.log(req.user.isAdmin); // undefined - blocked by the key checkObject.create(null) produces an object with no prototype chain at all. Even if the block list misses a key, there is nothing to inherit from. Pair this with npm audit fix --force for any lodash version below 4.17.21 to close the underlying dependency vulnerability.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.