Skip to content
Node.js nd security 4 min read

Node.js Security Overview

A Node.js application is only as trustworthy as its weakest layer — the request parser, the database query, the third-party package, the way it stores a password. Because Node sits at the boundary between untrusted clients and sensitive data, most breaches come not from exotic exploits but from a handful of well-understood, repeatable mistakes. This page maps the threat landscape — what attackers actually target, why JavaScript and the npm ecosystem create their own risks — and gives you a defensive checklist that the rest of this Security section explains in depth.

The threat landscape

Web application risk has a common vocabulary thanks to the OWASP Top 10, a periodically updated list of the most critical categories. Most of those categories map directly onto choices a Node developer makes every day.

OWASP categoryTypical Node.js manifestationPrimary defense
Broken access controlMissing or wrong req.user checks on a routeAuthorization middleware, deny-by-default
Cryptographic failuresPlaintext passwords, weak hashing, HTTP-only transportscrypt/argon2, TLS, node:crypto
InjectionSQL/NoSQL/command injection from raw inputParameterized queries, validation
Insecure designNo rate limiting, trusting client dataThrottling, server-side validation
Vulnerable componentsOutdated or malicious npm dependenciesnpm audit, lockfiles, --ignore-scripts
Identification & auth failuresGuessable tokens, no expiry, JWT misuseStrong JWT/session config, MFA

The pattern is consistent: untrusted input flows into a sensitive sink, or a control that should exist simply isn’t there. Security work is mostly about closing those two gaps.

Dependency risk

A typical Node project ships far more third-party code than first-party code — a single npm install can pull in hundreds of transitive packages. Each is code you execute with full process privileges. The risks are real: known CVEs in popular libraries, typosquatting (a malicious expresss masquerading as express), and supply-chain attacks where a legitimate package is compromised and ships malware in a postinstall script.

Audit your tree regularly and commit a lockfile so installs are reproducible:

npm audit
npm audit fix
npm ci --ignore-scripts

Output:

# npm audit report

semver  <7.5.2
Severity: moderate
Regular Expression Denial of Service - https://github.com/advisories/GHSA-...
fix available via `npm audit fix`

1 moderate severity vulnerability

Use npm ci (not npm install) in CI and production. It installs strictly from package-lock.json, failing if the lockfile and manifest disagree — which catches tampering and drift before it ships.

Injection

Injection happens whenever you build a command — SQL, a shell line, a NoSQL filter — by concatenating untrusted input as code rather than passing it as data. The classic example is string-built SQL:

// VULNERABLE — never do this
const q = `SELECT * FROM users WHERE email = '${req.body.email}'`;

An input like ' OR '1'='1 turns that into a query that returns every row. The fix is to let the driver bind parameters, keeping data and code strictly separate:

import pg from "pg";

const pool = new pg.Pool();

async function findUser(email) {
  const { rows } = await pool.query(
    "SELECT id, email FROM users WHERE email = $1",
    [email],
  );
  return rows[0];
}

The same discipline applies to shells — prefer execFile/spawn with an argument array over exec with an interpolated string — and to MongoDB, where an attacker-supplied object like { "$ne": null } can bypass a filter unless you validate types first.

Authentication and authorization flaws

Authentication proves who a caller is; authorization decides what they may do. Both fail quietly. Common mistakes include storing passwords with fast hashes (or none), issuing JWTs that never expire or aren’t verified, and checking authentication while forgetting per-resource authorization (so any logged-in user can read any record).

Hash passwords with a slow, salted algorithm. Node’s built-in scrypt is a solid default with no dependencies:

import { scrypt, randomBytes, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt);

export async function hashPassword(password) {
  const salt = randomBytes(16).toString("hex");
  const derived = await scryptAsync(password, salt, 64);
  return `${salt}:${derived.toString("hex")}`;
}

export async function verifyPassword(password, stored) {
  const [salt, key] = stored.split(":");
  const derived = await scryptAsync(password, salt, 64);
  return timingSafeEqual(Buffer.from(key, "hex"), derived);
}

Use timingSafeEqual for the comparison so an attacker can’t infer the secret from response timing — a subtle but real side channel.

A defense checklist

The Security section breaks each control into its own page. At a glance, a hardened Node service should:

  • Validate every input against a schema before it reaches business logic.
  • Parameterize all queries and avoid shelling out with interpolated strings.
  • Hash passwords with scrypt/argon2 and verify in constant time.
  • Sign and expire tokens correctly, verifying every JWT on every request.
  • Lock down CORS to an explicit origin allowlist instead of *.
  • Rate-limit authentication and expensive endpoints to blunt brute force and DoS.
  • Audit dependencies and pin them with a committed lockfile.

Best Practices

  • Adopt a deny-by-default posture: authorize explicitly per resource, and reject unexpected input rather than trying to sanitize it.
  • Run npm audit in CI and treat high-severity findings as build failures, not warnings.
  • Keep secrets out of source control; load them from environment variables or a secrets manager and never log them.
  • Serve everything over HTTPS/TLS so tokens and credentials are never exposed in transit.
  • Set security headers (via helmet or manually) to mitigate clickjacking, MIME sniffing, and XSS.
  • Stay on a supported Node.js LTS line (20 or 22) so you receive security patches.
Last updated June 14, 2026
Was this helpful?