Suggest an editImprove this articleRefine the answer for “What are the key security best practices in Node.js?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Node.js security best practices** cover input validation, injection prevention, password hashing, and dependency auditing. Three lines protect most Express routes: `app.use(helmet())` for security headers, `express.json({ limit: '10kb' })` against oversized payloads, and `rateLimit()` against brute force. Validate at the boundary; never trust raw `req.body`. **Key principle:** sanitize inputs first, run as non-root, audit dependencies in CI.Shown above the full answer for quick recall.Answer (EN)Image**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 audit` before every deploy - `helmet()` sets 7+ security headers in one line; skip it and XSS, clickjacking, and MIME-sniffing are open - Prototype pollution via `__proto__` mutates `Object.prototype` globally and persists until process restart - Never run as root, limit request body size, and rate-limit every public endpoint ### Quick example ```js // 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. ```js // 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. ```js // 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. ```js // 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. ```js // 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. ```js 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](/questions/nodejs-redis-session) 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. ```bash npm audit --audit-level=high npm ci --only=production # dev deps stay out of prod ``` Snyk 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. ```js // 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 ```js // 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()`.** ```js // 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 ```js 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 limit ``` `helmet()` 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) ```js 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 ```js // 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 check ``` `Object.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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.