Node.js Security Best Practices: 2025 Checklist
A comprehensive Node.js security checklist covering HTTP headers, input validation, prototype pollution, dependency scanning, and more — everything you need to harden your Node.js application in 2025.
Node.js powers a significant portion of the web, from startup APIs to enterprise platforms. Its non-blocking I/O and massive npm ecosystem make it productive to build with — but that same ecosystem, combined with JavaScript's dynamic nature, creates a distinct security attack surface. This checklist covers the most impactful Node.js security practices you should apply in 2025.
1. Set Security Headers with Helmet.js
HTTP response headers are your first line of defense against a range of client-side attacks. Helmet.js is a middleware collection for Express (and other frameworks) that sets sensible defaults for you.
npm install helmet
import express from 'express';
import helmet from 'helmet';
const app = express();
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-{NONCE}'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
})
);
Without Helmet, Express applications respond with no X-Frame-Options, no X-Content-Type-Options, and no Content-Security-Policy. These omissions allow clickjacking, MIME sniffing, and cross-site scripting attacks that a few lines of middleware would have prevented.
2. Validate Input with Zod or Joi
Never trust data arriving from clients — validate it with a schema library before it touches your business logic or database.
Using Zod (TypeScript-first)
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
age: z.number().int().min(13).max(120).optional(),
role: z.enum(['user', 'admin']).default('user'),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
app.post('/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Invalid input', details: result.error.flatten() });
}
const validated: CreateUserInput = result.data;
// safe to use
});
Using Joi
import Joi from 'joi';
const schema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required(),
});
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(400).json({ error: error.details.map(d => d.message) });
}
Always validate on the server side. Client-side validation is a UX improvement, not a security control.
3. Avoid eval() and Child Process Calls with User Input
Passing user-controlled data to eval(), new Function(), or child_process.exec() is effectively remote code execution.
// DANGEROUS — never do this
const result = eval(req.body.expression);
// DANGEROUS — shell injection
import { exec } from 'child_process';
exec(`ffmpeg -i ${req.body.filename} output.mp4`); // attacker sends: "; rm -rf /"
If you need to run calculations, use a sandboxed evaluator like vm2 (and check its security advisories). If you must invoke subprocesses, use execFile or spawn with an argument array — never shell string interpolation:
import { execFile } from 'child_process';
// Safe: arguments passed as an array, not concatenated into a shell string
execFile('ffmpeg', ['-i', sanitizedFilename, 'output.mp4'], (err, stdout, stderr) => {
// handle result
});
4. Prevent Prototype Pollution
JavaScript's prototype chain means that an attacker who can write to __proto__ or constructor.prototype can affect every object in your process. This typically enters via JSON.parse, Object.assign, or deep-merge functions.
// Vulnerable deep merge
function merge(target: any, source: any) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object') {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// Attack payload: {"__proto__": {"admin": true}}
merge({}, JSON.parse(req.body));
Mitigations:
- Use
Object.create(null)for objects that store arbitrary user keys. - Validate input with a schema that rejects
__proto__,constructor, andprototypekeys. - Use the
--frozen-intrinsicsNode.js flag (Node 18+) in critical services. - Prefer well-audited libraries like
lodash.merge(patched) ordeepmerge.
// Reject dangerous keys in schema
const schema = z.record(
z.string().refine(k => !['__proto__', 'constructor', 'prototype'].includes(k)),
z.unknown()
);
5. Run npm audit and Scan Dependencies
The npm registry is the largest software repository in the world — and also the most targeted. Run npm audit as part of every CI pipeline:
# Fail the build if any high-severity vulnerability is found
npm audit --audit-level=high
# Automatically fix safe upgrades
npm audit fix
For more visibility, integrate a dedicated SCA tool:
# Snyk
npx snyk test
npx snyk monitor
# OWASP Dependency-Check
npx @continuous-security/dependency-check
Set up Dependabot or Renovate to open PRs automatically when vulnerable versions are detected. Review transitive dependencies too — npm ls <package> shows you who pulled in a dependency.
6. Never Expose Secrets Through Environment Variables to the Client
In Node.js server processes, process.env is only accessible server-side. But in frameworks that bundle code (like Next.js, Create React App, or Vite), environment variables can accidentally end up in the client bundle.
# .env
DATABASE_URL=mongodb+srv://... # Server only — safe
STRIPE_SECRET_KEY=sk_live_... # Server only — safe
NEXT_PUBLIC_STRIPE_KEY=pk_live_... # ⚠ This gets bundled into the browser
Rules:
- Never prefix secrets with
NEXT_PUBLIC_,REACT_APP_, orVITE_. - Audit your build output periodically:
grep -r "sk_live" .next/should return nothing. - Use
server-onlyimports in Next.js to hard-fail if a server module is accidentally imported on the client.
// lib/stripe.ts
import 'server-only'; // throws at build time if imported client-side
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
7. Prevent SSRF in HTTP Clients
Server-Side Request Forgery (SSRF) happens when your server makes HTTP requests to URLs controlled by an attacker, allowing them to reach internal services.
// VULNERABLE: fetching a user-supplied URL
app.post('/proxy', async (req, res) => {
const data = await fetch(req.body.url); // attacker sends: http://169.254.169.254/latest/meta-data/
res.json(await data.json());
});
Mitigations:
import { URL } from 'url';
import { isIPv4, isIPv6 } from 'net';
function isPrivateAddress(hostname: string): boolean {
// Block localhost and private ranges
const privatePatterns = [
/^localhost$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./, // link-local (AWS metadata)
/^::1$/, // IPv6 localhost
];
return privatePatterns.some(p => p.test(hostname));
}
async function safeFetch(rawUrl: string) {
const parsed = new URL(rawUrl);
if (parsed.protocol !== 'https:') throw new Error('Only HTTPS allowed');
if (isPrivateAddress(parsed.hostname)) throw new Error('Private addresses not allowed');
return fetch(rawUrl);
}
Additionally, consider running your outbound HTTP calls from a separate process with egress firewall rules that block RFC-1918 ranges at the network level.
8. Harden Express Configuration
Several Express defaults are worth changing:
// Remove X-Powered-By header (fingerprinting)
app.disable('x-powered-by');
// Use a production session store (not MemoryStore)
import session from 'express-session';
import connectRedis from 'connect-redis';
const RedisStore = connectRedis(session);
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
},
}));
9. Enforce HTTPS and Redirect HTTP
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(301, `https://${req.hostname}${req.originalUrl}`);
}
next();
});
Also set HSTS via Helmet as shown above, so browsers remember to use HTTPS even on the first visit after the initial redirect.
10. Use a Security-Focused Linter
eslint-plugin-security catches many common Node.js security issues at development time:
npm install --save-dev eslint-plugin-security
// .eslintrc.json
{
"plugins": ["security"],
"extends": ["plugin:security/recommended"]
}
It flags dangerous patterns like eval, child_process.exec with dynamic strings, fs calls with user-controlled paths, and regex denial-of-service vulnerabilities.
Summary Checklist
| Control | Tool / Method |
|---|---|
| Security headers | helmet |
| Input validation | zod or joi |
| Dependency vulnerabilities | npm audit, Snyk, Dependabot |
| Prototype pollution | Schema validation, Object.create(null) |
| eval / exec with user input | Avoid; use execFile with arg arrays |
| Secret exposure | Never use NEXT_PUBLIC_ for secrets |
| SSRF | URL allowlist + private IP blocklist |
| HTTPS enforcement | Redirect middleware + HSTS header |
| Static analysis | eslint-plugin-security |
Node.js security is not a one-time task — it requires consistent habits: validating every input, auditing dependencies on each merge, reviewing error messages before they reach users, and running automated scans in CI. Apply this checklist to your project today and schedule quarterly reviews to catch drift.