Skip to content
Node.js best practices 4 min read

Security Best Practices

Security in a Node.js application is not a single feature you bolt on at the end — it is a set of habits applied at every layer, from the data that crosses your API boundary to the dependencies you ship and the secrets you deploy with. Most real-world breaches come from boring, preventable mistakes: trusting user input, leaking credentials, or running a package with a known CVE. This page covers the practices that close those gaps on modern Node.js (20/22 LTS), with runnable examples you can adopt today.

Validate and sanitize all input

Treat every byte that crosses a trust boundary — request bodies, query strings, headers, environment variables — as hostile until proven otherwise. Validate shape and type at the edge, reject anything that does not match, and never pass raw input into a query, a shell, or the filesystem. A schema validator like Zod gives you parsing and type narrowing in one step.

import { z } from "zod";

const CreateUser = z.object({
  email: z.string().email(),
  age: z.number().int().min(13).max(120),
  role: z.enum(["user", "admin"]),
});

export function parseCreateUser(body) {
  const result = CreateUser.safeParse(body);
  if (!result.success) {
    throw new Error(`Invalid payload: ${result.error.issues[0].message}`);
  }
  return result.data; // fully typed and trusted from here on
}

Validate on the server even if the client already validates. Client-side checks are for UX; an attacker bypasses them by calling your API directly.

Parameterize every query

String-concatenating user input into SQL is the classic injection vector. Use parameterized queries (bind parameters) so the driver sends data and code separately — the database can never interpret a value as SQL.

import pg from "pg";

const pool = new pg.Pool();

// Unsafe — never do this:
// pool.query(`SELECT * FROM users WHERE email = '${email}'`);

// Safe — values are bound, not interpolated:
async function findUser(email) {
  const { rows } = await pool.query(
    "SELECT id, email FROM users WHERE email = $1",
    [email],
  );
  return rows[0];
}

The same rule applies beyond SQL: never build shell commands with child_process.exec from user input (prefer execFile/spawn with an argument array), and validate paths before reading files to avoid directory traversal.

Hash passwords, never store them in plaintext

Passwords must be hashed with a slow, salted algorithm designed for the job. bcrypt (or the built-in node:crypto scrypt) adds a per-password salt and a tunable work factor so brute-forcing stays expensive even if your database leaks.

import bcrypt from "bcrypt";

const COST = 12; // work factor; raise as hardware improves

export async function hashPassword(plain) {
  return bcrypt.hash(plain, COST);
}

export async function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash); // constant-time comparison
}

Never use a fast general-purpose hash (MD5, SHA-256) for passwords, and never roll your own crypto. For tokens and random IDs, use crypto.randomBytes or crypto.randomUUID().

Manage secrets outside your code

API keys, database URLs, and signing secrets must never be committed to source control. Load them from the environment, keep .env files out of git, and in production read secrets from a managed store (AWS Secrets Manager, Vault, or your platform’s encrypted variables).

const required = ["DATABASE_URL", "JWT_SECRET", "STRIPE_KEY"];

for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required env var: ${key}`);
  }
}

export const config = {
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
};

Add .env to .gitignore and commit a .env.example with empty values so teammates know what is required without exposing real secrets.

Set security headers and rate limit

HTTP response headers are a cheap, high-impact defense. In Express, helmet sets sensible defaults (CSP, X-Content-Type-Options, HSTS, and more) in one line. Pair it with rate limiting to blunt brute-force and denial-of-service attempts.

import express from "express";
import helmet from "helmet";
import rateLimit from "express-rate-limit";

const app = express();

app.use(helmet()); // secure headers
app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    limit: 100,               // per IP per window
    standardHeaders: "draft-7",
    legacyHeaders: false,
  }),
);

Apply a stricter limiter to sensitive routes like login and password reset, and always serve over HTTPS so headers like HSTS can take effect.

HeaderSet byProtects against
Content-Security-PolicyhelmetXSS, injection of remote scripts
Strict-Transport-Securityhelmetprotocol downgrade / SSL stripping
X-Content-Type-OptionshelmetMIME-type sniffing
X-Frame-Optionshelmetclickjacking

Keep dependencies patched

Most of your code is code you did not write. A single vulnerable transitive dependency can compromise the whole app, so audit regularly and update promptly. npm audit flags known CVEs, and a lockfile pins exact versions so builds are reproducible.

npm audit
npm audit fix
npm outdated

Output:

# npm audit report

semver  <7.5.2
Severity: moderate
Regular Expression Denial of Service
fix available via `npm audit fix`

1 moderate severity vulnerability

Automate this in CI and use a tool like Dependabot or Renovate to open update PRs. Avoid installing packages you do not need, and prefer well-maintained ones with recent releases and few dependencies.

Best practices

  • Validate and narrow every input at the trust boundary with a schema; reject anything malformed before it reaches your logic.
  • Parameterize all database queries and avoid building shell commands or file paths from raw user input.
  • Hash passwords with bcrypt/scrypt at a high work factor; use crypto.randomUUID() and randomBytes for tokens.
  • Load secrets from the environment or a managed store, keep .env out of git, and fail fast when required vars are missing.
  • Apply helmet for security headers, rate-limit sensitive routes, and serve only over HTTPS.
  • Run npm audit in CI, keep dependencies current, and minimize the size of your dependency tree.
Last updated June 14, 2026
Was this helpful?