Full-Stack Questions

Security in Depth for Full-Stack Developers

SSRF, path traversal, prototype pollution, ReDoS, unsafe deserialization, injection, password hashing, headers, rate limits, secrets, supply chain, uploads and a threat-model routine.

Lesson 3132 min read#security#owasp#nodejs

On this page

  1. Words used in this lesson
  2. Part 1. The map: what the OWASP Top 10 says
  3. Part 2. Injection recap: SQL and NoSQL
  4. Part 3. SSRF: when your server is the attacker's browser
  5. Part 4. Path traversal
  6. Part 5. Prototype pollution
  7. Part 6. ReDoS
  8. Part 7. Unsafe deserialization
  9. Part 8. Password storage
  10. Part 9. Security headers
  11. Part 10. Rate limiting and brute-force defence
  12. Part 11. Secrets handling
  13. Part 12. Dependency and supply-chain risk
  14. Part 13. File upload safety
  15. Part 14. Logging without leaking secrets
  16. Part 15. A short threat-modelling routine
  17. Common mistakes
  18. Interview questions
  19. Key takeaways
  20. What's next

In one line: most server-side attacks have the same root: data from outside is trusted too much. It becomes a URL the server fetches (SSRF), a file path, an object key (prototype pollution), a regex input (ReDoS), a serialized value, or a query. Fix it with allowlists, safe APIs and limits, then add layers, so that one mistake is not the end.

This lesson is the server side of web security for a full-stack developer: the attacks interviewers ask about, a small attack and a small fix for each, and the "boring" controls (password storage, headers, rate limits, secrets, dependencies, uploads, logs) that decide whether an incident is small or large.

The browser side is already covered, so I link to it instead of repeating it. For the frontend checklist (CSP, dangerouslySetInnerHTML, clickjacking, Subresource Integrity) see Browser, Lesson 07. For where tokens live, XSS and CSRF, see Browser, Lesson 03. For login, OAuth, sessions and passkeys, see Full-Stack Q&A, Lesson 22. For authorization basics on the server see Backend, Lesson 06.

What I ran. Node 22.22 with TypeScript run by tsx. Packages: argon2 0.45.1, bcryptjs 3.0.3, helmet 8.3.0, express 5.3.0, express-rate-limit 8.7.1, pino 10.4.0, zod 4.6.5, sift 17.1.4 (evaluates MongoDB-style filters in memory, so my NoSQL test did not use a real MongoDB server), and the built-in node:sqlite. Every attack is a small program I wrote, aimed only at servers I started on 127.0.0.1 in the same script. I read the OWASP Cheat Sheet Series and the OWASP Top 10 2025 text from their GitHub repositories. Not possible here: npm audit signatures failed in my sandbox (a 403 while downloading the signing keys), so I only describe it. I did not run a secret scanner, a real MongoDB, a real file upload through a web framework, or any scan of a real system. Timings are from my machine and will differ on yours.

Words used in this lesson

  • Attack surface is every place where outside data or outside callers can reach your system.
  • A trust boundary is a line where data moves from something you do not control (a user, another service, a file) to something you do. Validate at the line.
  • Allowlist means you list what is allowed and reject the rest. A denylist lists what is forbidden. Allowlists fail safe; denylists miss new tricks.
  • Injection means data is treated as code or as part of a command: SQL, NoSQL operators, shell, log lines.
  • SSRF (server-side request forgery) means an attacker makes your server send a request to a place the attacker cannot reach, such as an internal service or a cloud metadata address.
  • Path traversal means a user-given file name like ../../secret walks out of the folder you meant.
  • Prototype pollution means an attacker adds properties to Object.prototype, so every object in the program suddenly has them.
  • ReDoS (regular expression denial of service) means an input that makes a badly written regex take exponential time.
  • Deserialization means turning stored or received bytes or text back into objects. It is unsafe when that process can create objects or run code chosen by the attacker.
  • Hash is a one-way function. A salt is random data added per password so equal passwords get different hashes. Memory-hard means the function needs a lot of RAM, which makes mass guessing on GPUs costly. Work factor is the cost setting.
  • Brute force means trying many passwords for one account. Credential stuffing means trying leaked email and password pairs from other sites.
  • Supply chain means all the code and tools you did not write: packages, build tools, CI actions.
  • Provenance is a signed record of where and how a package was built.
  • Threat model is a written answer to "what can go wrong here, and what do we do about it?".

Part 1. The map: what the OWASP Top 10 says

OWASP (a non-profit security community) publishes a list of the most common serious web risks. The 2025 edition is the current one in its repository. The items that matter for this lesson:

2025 itemIn this lesson
A01 Broken Access ControlSSRF is rolled into this item in 2025 (the list says so). Auth: Lesson 22
A02 Security MisconfigurationHeaders, defaults, secrets in the wrong place
A03 Software Supply Chain Failuresnpm audit, lockfiles, provenance
A04 Cryptographic FailuresPassword hashing
A05 InjectionSQL, NoSQL, log injection
A07 Authentication FailuresBrute force, rate limits
A08 Software or Data Integrity FailuresUnsafe deserialization
A09 Security Logging and Alerting FailuresLogging well, without leaking
A10 Mishandling of Exceptional ConditionsError bodies that leak details

Use the list as a vocabulary, not a checklist. It tells you what to ask, not what is true for your app.

Part 2. Injection recap: SQL and NoSQL

You know the story: never build a query by joining strings. Here it is run, with node:sqlite and two users.

JavaScript
const input = "' OR '1'='1";db.prepare(`SELECT email FROM users WHERE email = '${input}'`).all();   // attackdb.prepare('SELECT email FROM users WHERE email = ?').all(input);       // fix
TEXT
concatenated SQL returns 2 rows: [ 'a@x.com', 'boss@x.com' ]parameterised returns 0 rows

The ? placeholder sends the value separately from the command, so it can only ever be data. This works the same in pg, mysql2 and Prisma's tagged $queryRaw. Two limits to remember:

  • You cannot parameterise names (table, column, ORDER BY field, sort direction). Allowlist them: sortable.has(col) ? col : 'id'.
  • ORMs help but are not magic. $queryRawUnsafe or string-built LIKE clauses bring the problem back.

NoSQL injection is different in form. In MongoDB the query is an object, so an attacker sends JSON where you expected a string. If the login handler does User.findOne({ email: req.body.email, password: req.body.password }), the body {"email":"boss@x.com","password":{"$ne":null}} means "password not null", which is true for anyone. I tested the filter shape with sift:

TEXT
Mongo-style filter from raw body matches: [ 'boss@x.com' ]

The fix is to validate the type at the edge, so an object is rejected before it reaches the database:

JavaScript
const Login = z.object({ email: z.email(), password: z.string().min(1).max(200) });Login.safeParse({ email: 'boss@x.com', password: { $ne: null } }).success  // false

Also keep server-side JavaScript operators in queries (like $where) off. And a password is never compared in the query anyway: you fetch the user, then verify the hash (Part 8). See Backend, Lesson 03 for validation at the edge.

Part 3. SSRF: when your server is the attacker's browser

Any feature that fetches a URL for the user is a risk: "import avatar from URL", webhooks, link previews, PDF generators, "test my endpoint". The attacker gives an address that only your server can reach: http://127.0.0.1/admin, a private network host, or a cloud metadata address such as 169.254.169.254 (OWASP lists it for AWS, GCP and Azure). On a cloud machine that address can hand out credentials, which is why SSRF is a classic way into cloud accounts. OWASP also advises moving AWS machines to IMDSv2 as an extra layer.

The attack and the first (weak) fix

I started an "internal" server on 127.0.0.1 that returns INTERNAL-SECRET-TOKEN, and a vulnerable helper fetch(url). Every one of these URLs reached it:

URL the "user" sendsVulnerable helper
http://127.0.0.1:PORT/leaked the token
http://localhost:PORT/leaked (the name resolves to loopback)
http://2130706433:PORT/leaked (that number is 127.0.0.1 written as one integer)
http://127.0.0.1:PORT2/ where PORT2 answers 302 to the internal oneleaked (fetch follows redirects)

new URL() normalises odd forms: http://2130706433/, http://0x7f.1/ and http://127.1/ all gave the hostname 127.0.0.1, while http://[::ffff:7f00:1]/ kept the IPv6 form. So string checks like "does it start with 127." are weak. Parse first, and decide on the resolved address.

The fix, in layers

JavaScript
const blocked = new net.BlockList();            // 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10, 0/8, ::1, fc00::/7, fe80::/10function safeFetch(raw) {  const u = new URL(raw);  if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('scheme not allowed');  if (u.username || u.password) throw new Error('credentials in url');  const bare = u.hostname.replace(/^\[|\]$/g, '');  if (net.isIP(bare) && isBlocked(bare)) throw new Error('blocked address');   // IP literals  return http.request(u, { lookup: guardedLookup, timeout: 3000 }, onResponse); // names: checked at connect time  // onResponse: a 3xx reply is an error, we do not follow redirects}// guardedLookup: dns.lookup(host, { all: true }) and reject if ANY address is blocked;// connect to the address that was checked, so there is no second lookup.

Results of the fixed helper on the same list, plus a few more:

TEXT
http://127.0.0.1:PORT/                  blocked address 127.0.0.1http://localhost:PORT/                  blocked address 127.0.0.1http://2130706433:PORT/                 blocked address 127.0.0.1http://[::ffff:127.0.0.1]:PORT/         blocked address ::ffff:7f00:1http://[::1]:PORT/                      blocked address ::1http://169.254.169.254/latest/...       blocked address 169.254.169.254file:///etc/hostname                    scheme file: not allowed302 redirect to the internal server     redirect (302) not followed   (first run; see note)

My first fix had a hole, and the test caught it. I only checked addresses inside a custom DNS lookup function. Node does not call lookup when the host is already an IP address, so 127.0.0.1 and 2130706433 went straight through (my first run printed LEAK for them). The extra net.isIP check above closes it. Test your guard with IP literals, not only names. (In the final run my redirecting test server was itself on 127.0.0.1, so it was blocked even earlier; the redirect (302) not followed message came from the first run.)

What the OWASP cheat sheet adds, which a demo cannot show:

  • Allowlist when you can. If you only call known services, list their hosts and compare exactly. Do not accept "any URL".
  • DNS rebinding. A name can resolve to a safe address when you check and to an internal one when you connect. The fix is the one above: validate the address you actually connect to, and apply the same rule to retries and fallbacks. I did not simulate rebinding.
  • Do not follow redirects blindly. Each hop must pass the same check.
  • Network layer. Put the fetching code in a network zone with no route to internal services, and use IMDSv2 on AWS. The application check alone is one layer.
  • Limit the response: size, time, and content type. Never return the fetched body or error text to the user as-is.

Part 4. Path traversal

A user-given name is joined to a folder. ../ walks out of it. I made uploads/, a secret file beside it, and a symlink inside uploads/ that points to the secret:

JavaScript
const vulnerable = name => fs.readFileSync(path.join(uploads, name), 'utf8');function safe(name) {  if (name.includes('\0')) throw new Error('null byte');  const full = path.resolve(uploads, name);  if (!full.startsWith(uploads + path.sep)) throw new Error('outside base directory');  const real = fs.realpathSync(full);                          // follows symlinks  if (!real.startsWith(fs.realpathSync(uploads) + path.sep)) throw new Error('symlink points outside');  return fs.readFileSync(real, 'utf8');}
TEXT
"report.txt"      vulnerable: public report              safe: public report"../secret.txt"   vulnerable: TOP SECRET (outside ...)   safe: BLOCKED: outside base directory"link.txt"        vulnerable: TOP SECRET (outside ...)   safe: BLOCKED: symlink points outside"a\u0000b"        vulnerable: ERR_INVALID_ARG_VALUE      safe: BLOCKED: null bytenaive startsWith(uploads) says inside? true | with sep: false

Lessons from the run:

  • Resolve to an absolute path, then check it is inside the base folder. Do not search for .. in the string.
  • Compare with the base plus a path separator. Without it, /data/uploads-evil/x.txt "starts with" /data/uploads. My test showed true for the naive check and false with the separator.
  • A symlink can point out of the folder, so also check realpath. (Node did throw on a null byte by itself, but do not depend on that.)
  • Better still: do not use user names as paths at all. Store files under an id you generate, and keep the display name in the database (Part 13).
  • Express's res.sendFile and static middleware have their own root protection; the danger is code that builds paths by hand.

Part 5. Prototype pollution

In JavaScript, objects inherit from Object.prototype. If an attacker can write to it, every object gets the property. The usual route is a recursive merge of attacker JSON into a config object. Note that JSON.parse makes __proto__ a normal own key; the merge then follows it.

JavaScript
function merge(target, source) {            // a typical deep-merge helper  for (const key of Object.keys(source)) {    if (source[key] && typeof source[key] === 'object') { target[key] ??= {}; merge(target[key], source[key]); }    else target[key] = source[key];  }  return target;}merge({}, JSON.parse('{"theme":"dark","__proto__":{"isAdmin":true}}'));const session = { user: 'bob' };if (session.isAdmin) { /* ... */ }
TEXT
JSON.parse makes __proto__ an OWN key: [ 'theme', '__proto__' ]after merge, a brand-new object has isAdmin = trueif (session.isAdmin) -> ADMIN ACCESS GRANTEDconstructor.prototype path polluted too: yes

An object that never had isAdmin now answers true. The same thing works through constructor.prototype, so blocking only __proto__ is not enough. Fixes, from the OWASP cheat sheet and my runs:

FixWhat I saw
Skip the three keys __proto__, constructor, prototype in merges, and copy only own properties (Object.hasOwn)After the safe merge: isAdmin = undefined, polluted = undefined
Use Map / Set, or Object.create(null) for dictionaries of user keysObject.create(null) kept __proto__ as a plain key and 'isAdmin' in dict was false
Validate input with a strict schema, so unknown keys are rejectedZod .strict() rejects extra keys
Run Node with --disable-proto=delete (defence in depth)The __proto__ path stopped (isAdmin was undefined), but the constructor path still worked: yes
Use Object.freeze on built-in prototypes (test first; some libraries change them)Not tested by me
Update packagesMany real cases are bugs in popular merge, parse and clone libraries; npm audit listed several for old lodash and minimist (Part 12)

Also: check authorization with explicit data (user.role === 'admin' read from your database), never if (obj.isAdmin) on an object that might inherit.

Part 6. ReDoS

A regex with nested or overlapping repeats can try an exponential number of paths when the input almost matches. Node runs it on the main thread, so one request can freeze the server. I tried a common shape, "words separated by an optional space":

JavaScript
const bad = /^(\w+\s?)*$/;bad.test('a'.repeat(n) + '!');       // fails at the end, so it backtracks
TEXT
words n=18:  21 mswords n=20:  14 mswords n=22:  48 mswords n=24: 134 mswords n=26: 585 ms

The time grows very fast: about 4 times more for each two added characters between n=24 and n=26 (the first rows are noisy). If that trend continued, 40 characters would take hours (my extrapolation, not a measurement). A second pattern, /^(a+)+$/, took 2334 ms at n=28 while /^a+$/ took 0 ms. An attacker's input is small, so this is a cheap attack.

Fixes:

  1. Rewrite so each character can match only one way. /^\w+(?:\s\w+)*\s?$/ matched the same kind of text; on 50,000 characters plus ! it took 0.5 ms.
  2. Cap the length before the regex (s.length <= 254 for an email). It is the simplest rule.
  3. Do not write your own regex for emails, URLs and dates. Use a parser or a maintained schema (z.email()).
  4. For regexes you must run on user data, use a linear-time engine (for example RE2 through a binding; I did not test one) or run the check in a worker you can stop. I know of no built-in timeout for RegExp in Node 22, so do not count on one.

Look for the pattern in code review: a group with + or * inside another + or *, or alternation where both sides match the same text, such as (a|a)*.

Part 7. Unsafe deserialization

Parsing data is safe. Reviving objects or functions from attacker input is not. In Node the famous cases are libraries that rebuild functions from strings. I did not install one; I wrote the same idea in six lines so you can see the mechanism:

JavaScript
function unsafeDeserialize(text) {                       // looks for a marker and calls eval  return JSON.parse(text, (k, v) => (typeof v === 'string' && v.startsWith('_FUNC_') ? eval('(' + v.slice(6) + ')') : v));}unsafeDeserialize('{"name":"bob","onLoad":"_FUNC_(function(){ globalThis.attackRan = true; return 1 })()"}');
TEXT
unsafe: attacker code ran during parse -> true | onLoad = 1safe parse: attackRan = false | onLoad is just a string: stringschema (strict) result: rejected (unknown key onLoad)

The payload ran while parsing, before any validation could happen. My harmless payload only set a flag, but the same code could do anything the process can do. The safe version is JSON.parse (data only, no code) followed by a schema check.

Rules:

  • Use JSON (or a safe format) for data from outside, and validate the shape afterwards.
  • Never pass untrusted input to eval, new Function, or vm (the vm module is not a security sandbox). Do not build a "restore from a cookie or message" feature on a serializer that can create arbitrary objects.
  • YAML and XML need care. Use the safe loaders of your library, and turn off external entities in XML (XXE can also cause SSRF).
  • Sign what must round-trip. If a cookie or token carries data, sign it (HMAC or a JWT with a checked algorithm, see Browser, Lesson 02) and still validate after you read it.
  • Limit size and depth of input at the edge. Large or deep JSON is a denial-of-service tool. (In my run, a 200,000-level nested array still parsed on Node 22.22; do not rely on the parser to stop it, use a body-size limit.)

Part 8. Password storage

Never store a password, and never store a fast hash. A fast hash like SHA-256 lets an attacker with a leaked database try guesses very quickly. I measured: on one core my machine did about 266,000 SHA-256 guesses per second in Node, and about 24 Argon2id hashes per second (at the OWASP minimum below). Real attackers use GPUs, so the gap is only a rough idea, but the direction is clear: use a slow, salted, memory-hard function.

What OWASP's Password Storage Cheat Sheet says now

AlgorithmMinimum settings from the cheat sheet
Argon2id (first choice)One of: m=47104 (46 MiB), t=1, p=1 or m=19456 (19 MiB), t=2, p=1 or m=12288, t=3, p=1 or m=9216, t=4, p=1 or m=7168, t=5, p=1. They give an equal level of defence: a trade between CPU and RAM. m is memory in KiB, t is passes, p is parallelism
scrypt (if Argon2id is not available)One of: N=2^17 (128 MiB), r=8, p=1 or N=2^16, r=8, p=2 or N=2^15, r=8, p=3 or N=2^14, r=8, p=5 or N=2^13, r=8, p=10
bcrypt (legacy only)Work factor 10 or more, and a 72-byte password limit
PBKDF2 (when FIPS-140 is required)Work factor 600,000 or more with HMAC-SHA-256

It also says to tune for your server, benchmark, and consider a pepper (a secret shared by all hashes and kept outside the database, for example in a secrets vault, so a stolen database alone is not enough). Changing a pepper means every user must reset their password, so plan for it.

What I ran

JavaScript
const opts = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };const h = await argon2.hash('correct horse', opts);// $argon2id$v=19$m=19456,p=1,t=2$<salt>$<hash>await argon2.verify(h, 'correct horse');                    // trueargon2.needsRehash(h, { ...opts, timeCost: 3 });            // true: raise the cost later, rehash on login
CheckResult
Argon2id, m=19456, t=2, p=1206 ms per hash; the output string carries the parameters and the salt
Same password hashed twiceDifferent strings (random salt)
scrypt N=2^17, r=8, p=1 through crypto.scrypt1431 ms. It failed with ERR_CRYPTO_INVALID_SCRYPT_PARAMS until I raised maxmem above the default (it needs about 128 MiB)
bcrypt cost 10 (bcryptjs, pure JavaScript)162 ms
bcrypt and a 72-character password with extra text after itcompare(first72, hashOfLonger) was true: bcrypt ignores everything after 72 bytes

The bcrypt limit is in bytes, not characters: 19 key emoji are 19 characters but 76 bytes. If you must keep bcrypt, enforce a 72-byte maximum (the cheat sheet says so) or, if you pre-hash, follow its warning: use a keyed hash and encode the result (for example bcrypt(base64(hmac-sha384(password, pepper)))), because a plain pre-hash has known traps (null bytes, "shucking").

Good habits:

  • The library generates the salt. Do not invent your own.
  • Store the whole encoded string. It includes the algorithm and parameters, so you can upgrade later with needsRehash and rehash on the next successful login.
  • Compare with the library's verify, not ===. For other secrets (API keys, tokens) use crypto.timingSafeEqual. It throws if the lengths differ (ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH in my run), so hash both sides first, or compare fixed-length digests.
  • Hashing is CPU and RAM heavy. Put limits on password length (for example 128 characters or less for Argon2id) so nobody can send a 10 MB "password", and rate limit logins (Part 10).
  • Server-side steps for sessions and tokens are in Lesson 22.

Part 9. Security headers

Headers tell the browser how to treat your pages. In Express, helmet() sets a group of them. I printed the headers of a bare Express app and of one with helmet():

TEXT
bare Express:  x-powered-by: Expresshelmet():  content-security-policy: 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  cross-origin-opener-policy: same-origin  cross-origin-resource-policy: same-origin  origin-agent-cluster: ?1  referrer-policy: no-referrer  strict-transport-security: max-age=31536000; includeSubDomains  x-content-type-options: nosniff  x-dns-prefetch-control: off  x-download-options: noopen  x-frame-options: SAMEORIGIN  x-permitted-cross-domain-policies: none  x-xss-protection: 0

Helmet also removed x-powered-by. What each important one does, with OWASP's recommendation:

HeaderWhyNotes
Strict-Transport-Security (HSTS)Browser uses HTTPS only for your siteOWASP's example is max-age=63072000; includeSubDomains; preload. Helmet's default is one year with includeSubDomains and no preload. Be careful: a long HSTS plus an expired certificate locks users out. Read the preload rules before you add preload
Content-Security-PolicyLimits where scripts, styles and frames can load from; a strong second line against XSSHard to tune. See Browser, Lesson 07. Helmet's default fits a simple app and will break pages that use inline scripts or third-party CDNs
X-Content-Type-Options: nosniffStops the browser from guessing a type, so an uploaded text file is not run as a scriptAlways on. Also set the right Content-Type
frame-ancestors (CSP) / X-Frame-OptionsStops other sites from putting your page in a frame (clickjacking)OWASP says frame-ancestors replaces X-Frame-Options in modern browsers
Referrer-PolicyControls what URL leaks in the Referer headerOWASP suggests strict-origin-when-cross-origin; helmet sets no-referrer. Both are fine; know the difference
X-XSS-Protection: 0Turns off the old, harmful browser XSS filterOWASP: do not enable it; rely on CSP
Cache-Control: no-storeKeeps private responses out of cachesFor sensitive pages. no-cache still allows storing
Permissions-Policy, COOP/CORPLimit browser features and cross-origin sharingOptional; test before use

Headers are a seatbelt, not a fix. They reduce the damage of a bug. A site with SQL injection and perfect headers is still hacked. Also: Access-Control-Allow-Origin (CORS) is not a firewall; see Browser, Lesson 04. Expect-CT is obsolete; do not add it.

Part 10. Rate limiting and brute-force defence

Without limits, login is a free password-guessing machine, and expensive endpoints (search, export, password hashing, email sending) are a free way to bring you down. A rate limiter counts requests per key and answers 429 Too Many Requests after the limit.

I used express-rate-limit on a login route, keyed on IP plus the account name, counting only failed attempts, with a limit of 3 in a minute:

JavaScript
const loginLimiter = rateLimit({  windowMs: 60_000, limit: 3, standardHeaders: 'draft-7', legacyHeaders: false,  keyGenerator: req => `${req.ip}|${String(req.body?.email ?? '').toLowerCase()}`,  skipSuccessfulRequests: true,});app.post('/login', express.json(), loginLimiter, handler);
TEXT
1 401 ratelimit: limit=3, remaining=2, reset=60  policy: 3;w=602 401 ratelimit: limit=3, remaining=1, reset=603 401 ratelimit: limit=3, remaining=0, reset=604 429 ratelimit: limit=3, remaining=0, reset=60  retry-after: 605 429 ...different account, same IP -> 401

After the third failure, the fourth request got 429 with a Retry-After header. A different account from the same IP was still allowed. Notes:

  • Use more than one key. Per IP stops one machine. Per account stops many machines trying one account. Per IP only is weak against a botnet, and per account only lets an attacker lock a victim out on purpose (a denial of service), so combine them and prefer slowing (growing delays, a CAPTCHA step) over a hard permanent lock.
  • Credential stuffing tries one password per account across many accounts. A per-account limit never trips. Add per-IP limits, checks against breached-password lists, and alerts on a high failure rate overall.
  • Same message for "no such user" and "wrong password", and similar response time, so attackers cannot learn which accounts exist.
  • Behind a proxy, req.ip is the proxy's address unless you configure Express trust proxy correctly. A wrong setting lets attackers fake their IP with X-Forwarded-For, or puts all users in one bucket.
  • Memory store is per process. With many servers you need a shared store such as Redis. See Lesson 18 for counters and rate limiting in Redis.
  • Limit cost, not only count. Also cap request body size, query depth, page size and upload size.
  • Add MFA or passkeys for important accounts: Lesson 22.

Part 11. Secrets handling

A secret is anything that gives access: database passwords, API keys, signing keys, tokens. The OWASP secrets cheat sheet says to centralise and standardise them, limit access with least privilege, audit use, and follow a lifecycle: creation, rotation, revocation, expiration. Rotation matters because a stolen secret then works only for a short time.

Practical rules:

  • Never in the repository, not even once. Removing it in the next commit does not remove it from history. If it leaked, rotate it, then clean up.
  • Not in client code. Anything in a browser bundle (including NEXT_PUBLIC_ variables in Next.js) is public.
  • Keep dev, staging and production secrets different, with the smallest permissions each.
  • Load from the environment or a secret manager, and fail fast at start if one is missing or too weak. I ran this check and printed only the names, never the values:
JavaScript
const Env = z.object({  DATABASE_URL: z.string().startsWith('postgres'),  SESSION_SECRET: z.string().min(32, 'must be at least 32 characters'),});// output: [ 'SESSION_SECRET: must be at least 32 characters' ]
  • Short-lived credentials (cloud roles, OIDC from CI) are better than long-lived keys in CI settings.
  • Scan for leaks in CI and with a pre-commit hook (tools like gitleaks or git-secrets exist; I did not run any). Turn on your Git host's secret scanning too.
  • Do not print them. Not in logs (Part 14), not in error pages, not in console.log(process.env).
  • Compare tokens in constant time (timingSafeEqual, Part 8).

Part 12. Dependency and supply-chain risk

Most of your running code is other people's. Risks: a known bug in a package you use, a malicious new version, a stolen maintainer account, a typo-squatted package name, or a build script that runs when you install.

npm audit

I installed deliberately old versions (lodash@4.17.15, minimist@1.2.0) in a scratch project and ran npm audit. It reported 2 vulnerabilities (1 high, 1 critical), with links to GitHub advisories (GHSA ids): prototype pollution and command injection for lodash, and prototype pollution for minimist, with "fix available via npm audit fix". Read it as follows: audit tells you about known problems in the versions you have. It does not find new ones, and "0 vulnerabilities" is not "safe". Not every finding is reachable in your code, so triage by severity and by whether you call the vulnerable function.

Lockfiles and npm ci

A lockfile (package-lock.json) records exact versions and hashes. In CI use npm ci: it installs exactly the lockfile and stops if package.json and the lockfile disagree. I changed lodash to 4.17.21 in package.json without updating the lock, and npm ci stopped with:

TEXT
npm error `npm ci` can only install packages when your package.json and package-lock.json ... are in sync.npm error Invalid: lock file's lodash@4.17.15 does not satisfy lodash@4.17.21

Commit the lockfile and review its diff in pull requests; an unexpected new package or a changed registry URL is a warning.

Install scripts

A package can run a script when installed (postinstall). I made a harmless local package whose postinstall writes a file in my home folder:

TEXT
npm i ../evil --ignore-scripts   -> file not creatednpm i ../evil                    -> "a package script ran on install"

OWASP advises installing with --ignore-scripts, or setting ignore-scripts=true in .npmrc, and allowing scripts only for the few packages that need them (native modules). Note this protects the install step; code can still do harm when you run it.

Provenance, signatures and publishing

  • Provenance (a signed statement of which source and CI built a package) is created automatically when a maintainer publishes with trusted publishing, which uses OIDC from the CI provider instead of a long-lived npm token. OWASP recommends it. As a consumer, npm audit signatures checks registry signatures and provenance attestations. It failed in my sandbox (403 while fetching the signing keys), so I have not seen its output.
  • Maintainers: turn on 2FA, use granular, short-lived tokens or trusted publishing, and do not publish secrets inside the package.
  • Consumers: be careful with new, rarely used packages. Check the name for typos (typosquatting), and be careful with names suggested by a code assistant that may not exist (slopsquatting: an attacker registers a name that an AI tool made up). Avoid running copied install commands you did not read.
  • Pin and update on purpose. Use a bot (Dependabot or Renovate) so updates arrive as reviewable pull requests, and read the release notes of big jumps. Some teams add a short waiting period before adopting a brand-new release.
  • Dependency confusion: if you use private package names, make sure the public registry cannot supply a package with the same name (use scoped names and registry settings).
  • Keep an inventory (an SBOM) if your customers ask for it. The frontend side of this (Subresource Integrity, third-party scripts) is in Browser, Lesson 07.

Part 13. File upload safety

An upload takes attacker bytes and puts them on your disk, often to be served back to other users. OWASP's file upload list is: allow only needed extensions, check the real content (not the Content-Type header, which can be faked), generate your own file name, set size and name-length limits, require login, store files outside the web root or on another server, scan if you can, and protect the route from CSRF.

I wrote the checks as one function and ran it on eight inputs:

JavaScript
const TYPES = { png: { magic: [0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a] }, jpg: { magic: [0xff,0xd8,0xff] } };function checkUpload({ originalName, bytes }) {  if (bytes.length === 0 || bytes.length > MAX) return { ok: false, why: 'size' };  const ext = path.extname(originalName).slice(1).toLowerCase();  const rule = TYPES[ext];                                         // allowlist of extensions  if (!rule) return { ok: false, why: `extension .${ext} not allowed` };  if (!startsWith(bytes, rule.magic)) return { ok: false, why: 'content does not match extension' };  return { ok: true, storedAs: `${randomUUID()}.${ext}` };         // our own name}
UploadResult
avatar.png with real PNG bytesaccepted, stored as <uuid>.png
avatar.PNGaccepted (extension is lower-cased)
shell.php with a lying image/png typeextension .php not allowed
shell.png whose bytes are a scriptcontent does not match extension
evil.png.php (double extension)extension .php not allowed
../../etc/x.pngaccepted, but the stored name is a new UUID, so the path is ignored
page.svg with a script insideextension .svg not allowed
a PNG over the 2 MiB limitsize

Also remember: a file-signature check proves only the first bytes. Re-encoding images (decode, then save again, stripping metadata) is stronger, and OWASP says ZIP files are not recommended, because they can hold anything. SVG can carry script, so allow it only if you sanitise it or serve it as a download. Serve user files from a separate domain with Content-Disposition: attachment where possible, and X-Content-Type-Options: nosniff. For large files, uploading directly to object storage with a short-lived signed URL keeps the bytes off your server (Lesson 23).

Part 14. Logging without leaking secrets

Logs are how you find an attack, and also a place where secrets end up. OWASP says tokens, session ids, passwords, database connection strings, encryption keys, card data and sensitive personal data should be removed, masked, hashed or encrypted before logging. It also says to sanitise event data against log injection (CR, LF and delimiter characters).

Log injection, run:

JavaScript
const user = 'bob\n2026-10-11 INFO login ok user=admin';console.log(`2026-10-11 WARN login failed user=${user}`);
TEXT
2026-10-11 WARN login failed user=bob2026-10-11 INFO login ok user=admin          <- a forged line

A structured logger (JSON) escapes the newline. With pino and a redact list:

JavaScript
const log = pino({ redact: { paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', 'body.token'], censor: '[Redacted]' } });log.warn({ user, req: { headers: { authorization: 'Bearer abc.def.ghi', cookie: 'sid=123' } }, body: { token: 't0k3n', email: 'bob@x.com' }, input: { password: 'hunter2' } }, 'login failed');
TEXT
{"level":40,...,"user":"bob\n2026-10-11 INFO login ok user=admin","req":{"headers":{"authorization":"[Redacted]","cookie":"[Redacted]"}},"body":{"token":"[Redacted]","email":"bob@x.com"},"input":{"password":"[Redacted]"},"msg":"login failed"}

The newline is now inside a JSON string (\n), and the secrets are replaced. But redaction only works on paths you list. My next line, log.warn({}, 'password was hunter2'), printed the password in the message text, because it is not a field. So:

  • Log fields, not interpolated strings that may hold secrets.
  • Keep an allowlist of fields to log for requests; do not log whole request or response bodies, headers or process.env.
  • Log the event (login failed, user id, request id, source IP), not the secret. For a session id, log a hash.
  • Error pages for users: a generic message and a trace id. Details only in logs. See Backend, Lesson 05 for structured logging and request ids.
  • Treat log storage as sensitive: restrict access, set retention, and make sure logs cannot be changed by the app that writes them.
  • Do alert on important events (many failed logins, blocked SSRF attempts, 429 spikes). Logging with no alerting is how breaches go unseen for months (the OWASP item A09).

Part 15. A short threat-modelling routine

Threat modelling is thinking about attacks before writing code. The Threat Modeling Manifesto, quoted in the OWASP cheat sheet, uses four questions:

  1. What are we working on?
  2. What can go wrong?
  3. What are we going to do about it?
  4. Did we do a good enough job?

A routine that fits a 30-minute design review:

StepWhat you doExample for "import avatar from URL"
1. Draw itBoxes for components, arrows for data, a dashed line at every trust boundaryBrowser -> API -> (fetcher) -> internet; API -> database; API -> internal services
2. List assetsWhat would hurt to lose?User data, cloud credentials, internal admin endpoints
3. Walk STRIDE over each arrow and boxSpoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilegeFetcher: tampering (redirect), info disclosure (metadata address), DoS (slow or huge response)
4. RankLikelihood times impact, in words: high, medium, lowSSRF on a cloud host: high
5. DecideFix, reduce, move, or accept. Each risk gets an ownerBlock private ranges, no redirects, size and time limits, put fetcher in a network zone with no internal route
6. CheckAdd a test or a CI check for each fix; recheck when the design changesThe SSRF tests from Part 3

STRIDE is the one OWASP recommends to start with for a technical design, and it says to review the model with the whole team, not only security staff. Keep the result in the repository as a short document next to the code.

Common mistakes

  • Trusting a request field because the frontend "already validated it".
  • Checking a URL string with startsWith or a regex instead of checking the resolved IP address, and forgetting that IP literals skip DNS.
  • Following redirects in server-side fetches.
  • Blocking __proto__ only, or checking a path with includes('..').
  • Using startsWith(base) without a trailing separator.
  • A regex with a group repeated inside a group repeated, run on user text with no length limit.
  • Using eval, new Function or vm on data from outside.
  • Hashing passwords with SHA-256, using bcrypt with passwords longer than 72 bytes, or rolling your own salt.
  • Setting helmet() defaults and never testing that the CSP does not break the app, or setting HSTS preload without reading the rules.
  • A login limiter keyed only on IP (botnets) or only on account (lock-out attacks).
  • A secret in a commit "just for a minute", or a secret in a NEXT_PUBLIC_ variable.
  • Treating npm audit as a verdict, or running npm install instead of npm ci in CI.
  • Trusting the upload's Content-Type and file name.
  • Logging whole request bodies, or building log lines from user text.

Interview questions

What is SSRF and how do you defend against it? (Must know)

SSRF makes my server send a request that the attacker could not send, for example to 127.0.0.1, an internal service or a cloud metadata address. I defend in layers: an allowlist of hosts when I only call known services; otherwise parse the URL, allow only http and https, and block private, loopback and link-local ranges by checking the resolved IP, at connect time. I do not follow redirects blindly, I set time and size limits, and I never echo the response back. I also run the fetcher where the network has no route to internal systems, and use IMDSv2 on AWS. In my test, the vulnerable helper fetched the internal service through localhost, a decimal IP and a redirect.

Why can a "block private IPs" check still be bypassed? (Trap)

Several ways. The same address can be written as 2130706433, 0x7f.1 or [::ffff:7f00:1]. A hostname can resolve to a private address. DNS can answer differently at check time and connect time (rebinding). A redirect can lead inside. And in my own code, a guard placed in a custom DNS lookup function never ran for IP literals, because Node skips lookup when the host is already an IP. So I parse with URL, check literals, check every resolved address, connect to the address I checked, and refuse redirects.

What is path traversal, and what is the right check? (Must know)

A user-supplied file name walks out of the intended folder with ../. I resolve the path to an absolute path with path.resolve(base, name), then check that it starts with base + path.sep, and check realpath for symlinks. In my test ../secret.txt and a symlink both read a secret file in the vulnerable version and were blocked in the safe one. The trailing separator matters: /data/uploads-evil starts with /data/uploads. Better still, I avoid user names as paths and store files under generated ids.

Explain prototype pollution with an example. (Must know)

If an attacker can set a property on Object.prototype, every object inherits it. The common route is a recursive merge or "set by path" function applied to attacker JSON. I merged {"__proto__":{"isAdmin":true}} into {}, and then a new object had isAdmin === true, so if (session.isAdmin) passed. The constructor.prototype route works too. I fix it with a strict schema, skipping those keys and copying only own properties, using Map or Object.create(null) for dictionaries, and updating libraries. --disable-proto=delete helps, but in my run the constructor path still worked.

What is ReDoS and how do you find and fix it? (Must know)

A regex with ambiguous repeats, like (\w+\s?)*, can take exponential time on an input that almost matches. Node runs it on the main thread, so one request can block everything. I measured about 585 ms at 26 characters, growing about 4 times per two added characters; (a+)+ took 2.3 seconds at 28. I fix it by rewriting the pattern so each character matches one way (my fixed version did 50,000 characters in 0.5 ms), capping input length first, using a maintained validator instead of my own regex, and for untrusted regex work using a linear-time engine or a worker.

Why is eval or reviving functions from input dangerous? What is unsafe deserialization? (Must know)

Deserialization turns data back into objects. If the format can create arbitrary objects or functions, the attacker's payload runs during parsing, before my validation. I showed a six-line reviver that called eval on a marked string: the attacker's function ran inside JSON.parse. The safe pattern is data-only parsing (JSON.parse), then a strict schema check, no eval/new Function/vm on outside data, safe loaders for YAML, and signing anything that must round-trip through the client.

How do you stop SQL injection, and what do parameters not cover? (Must know)

Parameterised queries: the value travels separately from the SQL, so it cannot change the command. My concatenated query returned both users; the ? version returned none. Parameters do not cover identifiers, so for column names and sort fields I use an allowlist. ORMs help, but raw-query escape hatches such as $queryRawUnsafe bring the risk back. I also give the database user the least privilege.

What is NoSQL injection in MongoDB? (Senior signal)

The query is an object, so JSON from the client can contain operators. {"password":{"$ne":null}} as the password matches any user. I reproduced the filter shape with sift, not with a real server. The fix is to validate types at the edge (z.string() rejects an object), never pass a request body straight into a query, avoid $where, and verify password hashes in application code rather than inside a query.

How should passwords be stored? Which parameters? (Must know)

With a slow, salted, memory-hard hash: Argon2id first. The OWASP minimum is, for example, 19 MiB of memory, 2 passes, 1 lane (m=19456, t=2, p=1), and other equal-strength combinations. If Argon2id is not available, scrypt with N=2^17, r=8, p=1. bcrypt only for legacy, with work factor 10 or more and a 72-byte limit. PBKDF2 with 600,000 or more iterations and HMAC-SHA-256 when FIPS is needed. I store the full encoded string, so I can raise the cost later and rehash on login. In my run Argon2id at that setting took about 206 ms. I benchmark on my own hardware and choose the strongest setting that keeps login fast enough.

Why not SHA-256 for passwords? And what is the bcrypt trap? (Trap)

SHA-256 is designed to be fast, so an attacker with a leaked database can try huge numbers of guesses (about 266,000 per second on one core in my test, against about 24 for Argon2id; GPUs are far faster). A password hash must be slow and memory-hard, with a per-password salt. The bcrypt trap: it uses only the first 72 bytes. In my test, a 72-character password and the same one with extra text produced a match. Multi-byte characters use more bytes. I enforce a 72-byte limit, or use Argon2id.

Follow-up: "What is a pepper?" A secret shared by all hashes, kept outside the database (vault or HSM), so a stolen database alone cannot be cracked. Rotating it requires all users to reset passwords, so I plan for that. It adds defence in depth but does not replace a good hash.

Which security headers do you set, and what does each prevent? (Must know)

HSTS (force HTTPS), CSP (limit script sources, a second line against XSS; frame-ancestors for clickjacking), X-Content-Type-Options: nosniff (no type guessing), Referrer-Policy, and Cache-Control: no-store for sensitive pages. X-XSS-Protection should be 0 or absent, and Expect-CT is obsolete. Helmet sets a good group; I printed its defaults. I test the CSP so it does not break the app, and I read the HSTS preload rules first. Headers reduce damage; they do not fix the bug.

How do you design login rate limiting? (Senior signal)

Several layers: per IP+account, per IP, and per account with growing delays, not a permanent lock. I also add alerting on global failure rates for credential stuffing. In my test, with a limit of 3 failures per minute for one IP+email, the 4th attempt got 429 with Retry-After: 60, while another account on the same IP got through. I make error messages and timing the same for "unknown user" and "wrong password", use a shared store (Redis) with multiple servers, configure trust proxy correctly, and offer MFA or passkeys.

What goes wrong with rate limiting keyed only on account? (Trap)

An attacker can lock any user out by failing logins for their name on purpose. That is a denial of service on a real person. Only-by-IP fails against many IPs. So I combine keys, prefer slowing and extra checks (a CAPTCHA, an email challenge) over hard lockouts, and tell the real user by email when something suspicious happens.

How do you handle secrets in a Next.js or Node app? (Must know)

Never in the repository or the client bundle (NEXT_PUBLIC_ values are public). Load from environment or a secret manager, validate at startup and fail fast, and print names but never values. Separate secrets per environment, with least privilege, and rotate on a schedule and after any exposure. If one is committed, rotating it is step one, because history keeps it. I add a secret scanner in CI and a pre-commit hook, and I use short-lived credentials (OIDC from CI, cloud roles) over long-lived keys.

A teammate says "npm audit says 0 vulnerabilities, so we are safe". Respond. (Trap)

Audit reports only known, published advisories for the exact versions in the lockfile. It cannot see a malicious new release, a typosquatted name, an install script, or a bug nobody has reported. I would also use npm ci with a committed lockfile, ignore-scripts with an allowlist, review the lockfile diff, use update bots with reviewed pull requests, check provenance where available (npm audit signatures; I could not run it in my sandbox), and trim the dependency list. And I triage findings, since many are not reachable in my code.

What do lockfiles and npm ci give you? (Must know)

Repeatable installs. The lockfile pins exact versions and integrity hashes, and npm ci installs exactly that and fails if package.json and the lock disagree. I changed lodash in package.json without the lock and got "lock file's lodash@4.17.15 does not satisfy lodash@4.17.21". In CI that failure is the point: nobody gets a surprise version.

How do you handle file uploads safely? (Senior signal)

Allowlist extensions, check the actual bytes (magic numbers) because Content-Type can be faked, set size and name limits, require auth and CSRF protection, and never use the user's file name: I store <uuid>.<ext> and keep the original name in the database. I store files outside the web root or in object storage, serve them from another domain with nosniff and Content-Disposition: attachment when I can, avoid SVG and ZIP unless handled carefully, and re-encode images. In my test the double extension, the PHP file with an image type, and the script with a .png name were all rejected.

How do you log safely? (Senior signal)

I use structured logging so user text cannot forge lines (my newline attack made a fake "login ok" line in plain text and was harmless in JSON). I log fields from an allowlist, redact known sensitive paths, and avoid logging bodies, headers and process.env. Redaction only covers listed paths: a password inside a message string was printed in my test. I log events with request ids, restrict access to logs, set retention, and alert on abnormal events, because logging without alerting does not catch breaches.

Walk me through a quick threat model for a new feature. (Senior signal)

I ask the four questions: what are we building, what can go wrong, what do we do about it, did we do enough. I draw the data flow with trust boundaries, list the assets, and walk STRIDE (spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege) over each arrow. I rank the risks, assign an owner and a mitigation for each, and add a test or CI check. For a "fetch a URL" feature, that gives me SSRF, size and time limits, and network isolation before I write code. I keep the result as a short file next to the code.

You find an API key in a public commit. What do you do? (How would you debug)

First, revoke or rotate the key, because it is already public and history keeps it. Then check the provider's logs for use of that key during the exposure window, and look for damage. Then remove it from the code, move it to the secret manager, and add a scanner and a pre-commit check. I may rewrite history, but only after rotation, since copies and forks already exist. Last, write a short blameless note and fix the process that allowed it.

Key takeaways

  • Treat every outside value as hostile at the trust boundary: validate with strict schemas and allowlists.
  • SSRF: parse the URL, allow http(s) only, check resolved and literal IPs, connect to the checked address, refuse redirects, limit size and time, isolate the network.
  • Paths: resolve, then check inside the base with a trailing separator, and check symlinks. Prefer generated ids.
  • Prototype pollution: strict schemas, skip dangerous keys, Map or Object.create(null), update libraries.
  • ReDoS: no repeated groups inside repeated groups, cap input length, prefer maintained validators.
  • Never eval outside data. Parse data, then validate.
  • Passwords: Argon2id (for example m=19456, t=2, p=1 as a minimum), scrypt or bcrypt (72-byte limit) as fallbacks; store the encoded string and rehash when you raise the cost.
  • Headers and rate limits are layers, not fixes. Use several keys for login limits and avoid easy lock-out attacks.
  • Secrets stay out of the repo and the browser. Rotate after exposure. Fail fast on bad config.
  • Use npm ci, a committed lockfile, ignore-scripts with an allowlist, audit as one input, and provenance where possible.
  • Uploads: allowlist, check bytes, generate the name, limit size, serve safely.
  • Log fields, redact by path, and alert. Threat model before you build, with STRIDE.

What's next

Practise by attacking your own small project: write the vulnerable version of each demo, break it, then fix it and keep the test. For the browser half of this topic read Browser, Lesson 07 and Browser, Lesson 03, and for sign-in design read Full-Stack Q&A, Lesson 22.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series