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.
On this page
- Words used in this lesson
- Part 1. The map: what the OWASP Top 10 says
- Part 2. Injection recap: SQL and NoSQL
- Part 3. SSRF: when your server is the attacker's browser
- Part 4. Path traversal
- Part 5. Prototype pollution
- Part 6. ReDoS
- Part 7. Unsafe deserialization
- Part 8. Password storage
- Part 9. Security headers
- Part 10. Rate limiting and brute-force defence
- Part 11. Secrets handling
- Part 12. Dependency and supply-chain risk
- Part 13. File upload safety
- Part 14. Logging without leaking secrets
- Part 15. A short threat-modelling routine
- Common mistakes
- Interview questions
- Key takeaways
- 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
../../secretwalks 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 item | In this lesson |
|---|---|
| A01 Broken Access Control | SSRF is rolled into this item in 2025 (the list says so). Auth: Lesson 22 |
| A02 Security Misconfiguration | Headers, defaults, secrets in the wrong place |
| A03 Software Supply Chain Failures | npm audit, lockfiles, provenance |
| A04 Cryptographic Failures | Password hashing |
| A05 Injection | SQL, NoSQL, log injection |
| A07 Authentication Failures | Brute force, rate limits |
| A08 Software or Data Integrity Failures | Unsafe deserialization |
| A09 Security Logging and Alerting Failures | Logging well, without leaking |
| A10 Mishandling of Exceptional Conditions | Error 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.
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); // fixconcatenated SQL returns 2 rows: [ 'a@x.com', 'boss@x.com' ]parameterised returns 0 rowsThe ? 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 BYfield, sort direction). Allowlist them:sortable.has(col) ? col : 'id'. - ORMs help but are not magic.
$queryRawUnsafeor string-builtLIKEclauses 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:
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:
const Login = z.object({ email: z.email(), password: z.string().min(1).max(200) });Login.safeParse({ email: 'boss@x.com', password: { $ne: null } }).success // falseAlso 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" sends | Vulnerable 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 one | leaked (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
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:
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:
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');}"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: falseLessons 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 showedtruefor the naive check andfalsewith 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.sendFileand static middleware have their ownrootprotection; 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.
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) { /* ... */ }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: yesAn 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:
| Fix | What 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 keys | Object.create(null) kept __proto__ as a plain key and 'isAdmin' in dict was false |
| Validate input with a strict schema, so unknown keys are rejected | Zod .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 packages | Many 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":
const bad = /^(\w+\s?)*$/;bad.test('a'.repeat(n) + '!'); // fails at the end, so it backtrackswords n=18: 21 mswords n=20: 14 mswords n=22: 48 mswords n=24: 134 mswords n=26: 585 msThe 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:
- 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. - Cap the length before the regex (
s.length <= 254for an email). It is the simplest rule. - Do not write your own regex for emails, URLs and dates. Use a parser or a maintained schema (
z.email()). - 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
RegExpin 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:
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 })()"}');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, orvm(thevmmodule 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
| Algorithm | Minimum 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
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| Check | Result |
|---|---|
| Argon2id, m=19456, t=2, p=1 | 206 ms per hash; the output string carries the parameters and the salt |
| Same password hashed twice | Different strings (random salt) |
scrypt N=2^17, r=8, p=1 through crypto.scrypt | 1431 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 it | compare(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
needsRehashand rehash on the next successful login. - Compare with the library's
verify, not===. For other secrets (API keys, tokens) usecrypto.timingSafeEqual. It throws if the lengths differ (ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHin 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():
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: 0Helmet also removed x-powered-by. What each important one does, with OWASP's recommendation:
| Header | Why | Notes |
|---|---|---|
Strict-Transport-Security (HSTS) | Browser uses HTTPS only for your site | OWASP'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-Policy | Limits where scripts, styles and frames can load from; a strong second line against XSS | Hard 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: nosniff | Stops the browser from guessing a type, so an uploaded text file is not run as a script | Always on. Also set the right Content-Type |
frame-ancestors (CSP) / X-Frame-Options | Stops other sites from putting your page in a frame (clickjacking) | OWASP says frame-ancestors replaces X-Frame-Options in modern browsers |
Referrer-Policy | Controls what URL leaks in the Referer header | OWASP suggests strict-origin-when-cross-origin; helmet sets no-referrer. Both are fine; know the difference |
X-XSS-Protection: 0 | Turns off the old, harmful browser XSS filter | OWASP: do not enable it; rely on CSP |
Cache-Control: no-store | Keeps private responses out of caches | For sensitive pages. no-cache still allows storing |
Permissions-Policy, COOP/CORP | Limit browser features and cross-origin sharing | Optional; 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:
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);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 -> 401After 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.ipis the proxy's address unless you configure Expresstrust proxycorrectly. A wrong setting lets attackers fake their IP withX-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:
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:
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.21Commit 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:
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 signatureschecks registry signatures and provenance attestations. It failed in my sandbox (403while 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:
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}| Upload | Result |
|---|---|
avatar.png with real PNG bytes | accepted, stored as <uuid>.png |
avatar.PNG | accepted (extension is lower-cased) |
shell.php with a lying image/png type | extension .php not allowed |
shell.png whose bytes are a script | content does not match extension |
evil.png.php (double extension) | extension .php not allowed |
../../etc/x.png | accepted, but the stored name is a new UUID, so the path is ignored |
page.svg with a script inside | extension .svg not allowed |
| a PNG over the 2 MiB limit | size |
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:
const user = 'bob\n2026-10-11 INFO login ok user=admin';console.log(`2026-10-11 WARN login failed user=${user}`);2026-10-11 WARN login failed user=bob2026-10-11 INFO login ok user=admin <- a forged lineA structured logger (JSON) escapes the newline. With pino and a redact list:
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');{"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,
429spikes). 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:
- What are we working on?
- What can go wrong?
- What are we going to do about it?
- Did we do a good enough job?
A routine that fits a 30-minute design review:
| Step | What you do | Example for "import avatar from URL" |
|---|---|---|
| 1. Draw it | Boxes for components, arrows for data, a dashed line at every trust boundary | Browser -> API -> (fetcher) -> internet; API -> database; API -> internal services |
| 2. List assets | What would hurt to lose? | User data, cloud credentials, internal admin endpoints |
| 3. Walk STRIDE over each arrow and box | Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege | Fetcher: tampering (redirect), info disclosure (metadata address), DoS (slow or huge response) |
| 4. Rank | Likelihood times impact, in words: high, medium, low | SSRF on a cloud host: high |
| 5. Decide | Fix, reduce, move, or accept. Each risk gets an owner | Block private ranges, no redirects, size and time limits, put fetcher in a network zone with no internal route |
| 6. Check | Add a test or a CI check for each fix; recheck when the design changes | The 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
startsWithor 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 withincludes('..'). - 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 Functionorvmon 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 auditas a verdict, or runningnpm installinstead ofnpm ciin CI. - Trusting the upload's
Content-Typeand 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,
MaporObject.create(null), update libraries. - ReDoS: no repeated groups inside repeated groups, cap input length, prefer maintained validators.
- Never
evaloutside data. Parse data, then validate. - Passwords: Argon2id (for example
m=19456, t=2, p=1as 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-scriptswith 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
- Securing LLM Applications: The OWASP Top 10 for LLM Apps in Code · AI Engineering
- Security and Auth Scenario Bank · Scenario Debugging
- The Security Mindset: Threats, Attack Surface and the OWASP Top 10 · Security for Developers
Part of the study guide
- Full-Stack Developer Interview Guide: Questions and Answers
- Web Security Guide for Developers: OWASP Top 10 Basics
Related series