Node.js Security Basics

Node.js gives you a huge amount of power — direct access to the filesystem, the network, child processes, and a giant ecosystem of npm packages — and every one of those is also an attack surface. “Security” in Node isn’t one API, it’s a set of habits: how you store secrets, how you hash passwords, how you build database queries, how you configure your HTTP server, and how carefully you vet the third-party code you depend on. This lesson covers the practical basics every Node developer should know before shipping a server to production.

We won’t turn this into a general application-security course — instead we’ll focus on the Node-specific tools (node:crypto, environment variables, helmet, parameterized drivers) and the mistakes that are unique to how Node programs are built and deployed.

Overview: Where Node.js Apps Get Attacked

Most real-world Node.js incidents fall into a handful of categories, and it helps to know them before looking at code:

Category What happens Typical fix
Injection User input is concatenated into a SQL query, shell command, or HTML page Parameterized queries, avoid child_process.exec with user input, escape output
Weak credential storage Passwords hashed with fast, unsalted algorithms like MD5/SHA-1, or stored in plain text Salted, slow hashing with scrypt, bcrypt, or argon2
Secret leakage API keys and database passwords hardcoded in source or committed to git Environment variables, .env files kept out of version control
Supply-chain risk A dependency (or a dependency’s dependency) contains malicious or vulnerable code npm audit, lockfiles, npm ci, minimal dependencies
Missing HTTP hardening No security headers, permissive CORS, oversized request bodies helmet, careful CORS config, body size limits
Denial of service A single expensive synchronous call (or a giant JSON body) blocks the event loop for everyone Async/streaming APIs, request size limits, rate limiting

Notice that Node’s single-threaded event loop model actually raises the stakes on some of these. A blocking, CPU-heavy operation — like hashing a password with a synchronous call, or parsing a huge JSON payload — doesn’t just slow down the one request that triggered it; it freezes every connection the process is handling until that call returns. Security and performance are connected here: an attacker who can trigger a slow synchronous path has found a cheap denial-of-service vector.

Syntax: The Core Building Blocks

Most of Node’s built-in security tooling lives in the node:crypto module, plus environment variables for configuration. The shapes you’ll use constantly:

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

// derive a key from a password + salt (CPU-intensive, runs on the libuv thread pool)
scrypt(password, salt, keylen, (err, derivedKey) => { /* ... */ });

// generate cryptographically secure random bytes (for salts, tokens, session ids)
const salt = randomBytes(16).toString("hex");

// compare two buffers in constant time to avoid timing attacks
timingSafeEqual(bufferA, bufferB);
  • scrypt — a slow, memory-hard key derivation function designed to resist brute-force and GPU cracking. Never use it synchronously (scryptSync) inside a request handler.
  • randomBytes — a cryptographically secure random source, unlike Math.random(), which must never be used for tokens, salts, or session IDs.
  • timingSafeEqual — compares two buffers without leaking timing information about where they first differ; use it instead of === when comparing secrets or derived keys.
  • process.env — the standard place to read configuration and secrets at runtime instead of hardcoding them.

Examples

Example 1: Hashing and Verifying Passwords with scrypt

Never store passwords in plain text, and never hash them with a fast general-purpose hash like MD5 or SHA-256 alone — those are designed to be fast, which makes brute-forcing cheap. scrypt is intentionally slow and memory-hard, and combining it with a random per-user salt defeats precomputed rainbow-table attacks.

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

const scryptAsync = promisify(scrypt);

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

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

async function main() {
  const hash = await hashPassword("correct horse battery staple");
  console.log("Stored hash:", hash);

  console.log("Correct password:", await verifyPassword("correct horse battery staple", hash));
  console.log("Wrong password:", await verifyPassword("wrong password", hash));
}

main().catch((err) => {
  console.error("Error:", err.message);
  process.exit(1);
});

Output:

Stored hash: 9f2b7a1c4e8d0f3a6b5c2d1e0a9f8b7c:6a1e4f... (128 hex chars)
Correct password: true
Wrong password: false

The salt is generated fresh for every password and stored alongside the derived key (separated by a colon here), so two users with the same password get completely different stored hashes. Verification recomputes the derived key with the same salt and compares it with timingSafeEqual rather than ===, so an attacker can’t use response-time differences to guess the hash byte by byte.

Example 2: Hardening an Express Server with helmet

By default, a plain HTTP or Express server sends none of the headers that modern browsers use to reduce attack surface (clickjacking protection, MIME-sniffing prevention, and so on). The helmet package sets sane defaults for all of these in one line.

npm install express helmet
import express from "express";
import helmet from "helmet";

const app = express();

app.use(helmet());
app.use(express.json({ limit: "10kb" }));

app.get("/api/status", (req, res) => {
  res.json({ status: "ok" });
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: "Internal server error" });
});

const port = process.env.PORT ?? 3000;
app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

Output:

Server listening on port 3000

helmet() adds headers like X-Content-Type-Options: nosniff, Strict-Transport-Security, and a conservative Content-Security-Policy, and removes the default X-Powered-By: Express header that otherwise advertises your framework to attackers. The express.json({ limit: "10kb" }) call caps request body size, which blunts a cheap denial-of-service attack where a client sends gigabytes of JSON to exhaust memory. The four-argument error handler ensures raw stack traces never leak to clients — only a generic message does.

Example 3: Preventing SQL Injection with Parameterized Queries

SQL injection remains one of the most common — and most preventable — vulnerabilities. The fix is never to build query strings by hand; let the driver bind parameters safely.

npm install mysql2
import mysql from "mysql2/promise";

async function findUserByEmail(pool, email) {
  const [rows] = await pool.execute(
    "SELECT id, email, created_at FROM users WHERE email = ?",
    [email]
  );
  return rows[0] ?? null;
}

async function main() {
  const pool = mysql.createPool({
    host: process.env.DB_HOST ?? "localhost",
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
  });

  try {
    const user = await findUserByEmail(pool, "alice@example.com");
    console.log("User:", user);
  } finally {
    await pool.end();
  }
}

main().catch((err) => {
  console.error("Query failed:", err.message);
  process.exit(1);
});

The ? placeholder tells mysql2 to send the email as a separate, typed parameter instead of splicing it into the SQL text. Even if email contains something like ' OR '1'='1, it’s treated purely as data, never as SQL syntax. Notice the connection credentials come entirely from process.env — nothing is hardcoded.

Under the Hood: Why Sync Crypto and String-Built Queries Are Dangerous

scrypt‘s asynchronous form runs its CPU-heavy work on libuv’s thread pool (the same pool used for filesystem operations), not on the main thread, so the event loop stays free to handle other requests while the hash is computed. scryptSync, by contrast, runs directly on the main thread and blocks the entire event loop — every other in-flight request waits until it finishes. In a login endpoint hit by even a moderate number of concurrent requests, that’s enough to create a self-inflicted denial-of-service.

String-built SQL queries fail for a related structural reason: the database driver has no way to distinguish “code” from “data” once they’re merged into one string — it just sees SQL text and executes it. Parameterized queries keep the query structure and the values in separate channels all the way to the database engine, so user input can never change what the query does, only the values it operates on.

Common Mistakes

Mistake 1: Building SQL with String Concatenation

const query = "SELECT * FROM users WHERE email = '" + email + "'";
const [rows] = await pool.query(query);

If email is user-supplied and contains ' OR '1'='1, the resulting query returns every row in the table. Always use placeholders instead, as shown in Example 3.

const [rows] = await pool.execute(
  "SELECT * FROM users WHERE email = ?",
  [email]
);

Mistake 2: Hashing Passwords with MD5 or a Bare Hash

import { createHash } from "node:crypto";
const hash = createHash("md5").update(password).digest("hex");

MD5 (and SHA-1, and unsalted SHA-256) are fast by design — great for checksums, terrible for passwords, because an attacker with a leaked database can try billions of guesses per second on commodity hardware. Use a slow, salted KDF like scrypt instead (Example 1).

Mistake 3: Hardcoding Secrets in Source Code

const jwtSecret = "my-super-secret-key-12345";
const dbPassword = "hunter2";

Hardcoded secrets end up in your git history forever, even if you delete them later, and they’re identical across every environment. Load them from the environment instead — either with dotenv or Node’s native --env-file flag (stable since Node 20.6):

node --env-file=.env app.js
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
  throw new Error("JWT_SECRET is not set");
}

Mistake 4: Disabling TLS Certificate Validation

https.get(url, { rejectUnauthorized: false }, (res) => { /* ... */ });

Setting rejectUnauthorized: false to silence a certificate error defeats the entire point of TLS — it accepts connections from anyone, including an attacker performing a man-in-the-middle attack. Fix the actual certificate problem (missing CA, expired cert, wrong hostname) instead of disabling the check.

Best Practices

  • Run npm audit regularly and keep dependencies updated; treat a growing list of known vulnerabilities as a build-blocking issue, not noise.
  • Commit a lockfile and deploy with npm ci (not npm install) so production always gets the exact, audited dependency versions.
  • Never commit secrets to git; load them from process.env via .env files (kept in .gitignore) or your platform’s secret manager.
  • Hash passwords with scrypt, bcrypt, or argon2, always with a per-user salt, and use timingSafeEqual to compare derived keys or tokens.
  • Always use parameterized queries or a query builder/ORM — never concatenate user input into SQL, shell commands, or HTML.
  • Add helmet() to Express apps and set request body size limits to reduce header-based attacks and memory-exhaustion DoS.
  • Configure CORS explicitly — never combine a wildcard origin (*) with credentials: true.
  • Serve everything over HTTPS/TLS, and never set rejectUnauthorized: false to work around a certificate error.
  • Run the Node process as a non-root, least-privileged user, especially in containers.
  • Rate-limit authentication and other sensitive endpoints to slow down brute-force and credential-stuffing attempts.
  • Keep secrets and stack traces out of logs and API error responses sent to clients.

Practice Exercises

  • Exercise 1: Write a small module exporting hashPassword(password) and verifyPassword(password, storedHash) using scrypt and timingSafeEqual, then write a few test calls that confirm a correct password verifies and an incorrect one does not.
  • Exercise 2: Take a hypothetical Express route that looks up a user with pool.query("SELECT * FROM users WHERE username = '" + username + "'") and rewrite it to use a parameterized query with mysql2. Explain in a comment why the original version was exploitable.
  • Exercise 3: Add helmet and the express-rate-limit package to a simple login route so that no more than 5 attempts per minute are allowed per IP address; confirm the 6th rapid request receives a 429 response.

Summary

  • Node security is mostly about disciplined habits, not a single API: secrets management, safe hashing, parameterized queries, and HTTP hardening.
  • Use node:crypto‘s scrypt (async, never sync) with a random salt and timingSafeEqual for password storage and comparisons.
  • Never build SQL, shell commands, or HTML by concatenating user input — always use parameterized queries and proper escaping.
  • Keep secrets in environment variables, never in source code, and never commit .env files to version control.
  • Add helmet and sensible body size limits to Express apps; never disable TLS certificate validation.
  • Run npm audit and use npm ci with a committed lockfile to manage supply-chain risk.
  • Remember that blocking, synchronous operations don’t just slow one request — they can freeze the entire event loop, which is itself a security concern.