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 auditregularly 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(notnpm install) so production always gets the exact, audited dependency versions. - Never commit secrets to git; load them from
process.envvia.envfiles (kept in.gitignore) or your platform’s secret manager. - Hash passwords with
scrypt,bcrypt, orargon2, always with a per-user salt, and usetimingSafeEqualto 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 (
*) withcredentials: true. - Serve everything over HTTPS/TLS, and never set
rejectUnauthorized: falseto 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)andverifyPassword(password, storedHash)usingscryptandtimingSafeEqual, 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 withmysql2. Explain in a comment why the original version was exploitable. - Exercise 3: Add
helmetand theexpress-rate-limitpackage 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‘sscrypt(async, never sync) with a random salt andtimingSafeEqualfor 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
.envfiles to version control. - Add
helmetand sensible body size limits to Express apps; never disable TLS certificate validation. - Run
npm auditand usenpm ciwith 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.
