Web Security

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.

August 5, 20257 min readShipSafer Team

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, and prototype keys.
  • Use the --frozen-intrinsics Node.js flag (Node 18+) in critical services.
  • Prefer well-audited libraries like lodash.merge (patched) or deepmerge.
// 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_, or VITE_.
  • Audit your build output periodically: grep -r "sk_live" .next/ should return nothing.
  • Use server-only imports 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

ControlTool / Method
Security headershelmet
Input validationzod or joi
Dependency vulnerabilitiesnpm audit, Snyk, Dependabot
Prototype pollutionSchema validation, Object.create(null)
eval / exec with user inputAvoid; use execFile with arg arrays
Secret exposureNever use NEXT_PUBLIC_ for secrets
SSRFURL allowlist + private IP blocklist
HTTPS enforcementRedirect middleware + HSTS header
Static analysiseslint-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.

nodejs
express
security
input validation
dependency scanning

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.