L5 · Security
The code works for every honest user. Now read it as someone who wants what isn't theirs.
The story
In 2019 the title-insurance company First American Financial had a document viewer for customers. A customer received a link to their own mortgage documents, with a number in the URL. Changing that number showed someone else's documents: bank account numbers, tax records, driving licences. Reports at the time put the number of documents reachable this way at around 885 million, going back about 16 years.
There was no clever exploit. No injection, no stolen password. The server checked that a document existed, and never checked that it belonged to the person asking.
That's the most common serious security bug in web applications, and it's invisible to every test that uses honest inputs. Security review is a change of viewpoint: stop asking "does this work?" and start asking "what happens if I send something I'm not supposed to?"
Part 1: Three questions for every input
List every input that crosses a trust boundary: path params, query strings, bodies, headers, cookies, uploaded files, webhook payloads, URLs the server fetches, even data you stored earlier from a user. Then ask three questions of each one:
- Who is allowed? Authentication says who you are. Authorization says what you may touch. Most serious bugs are a missing authorization check on one route.
- Where does it end up? Every place where data becomes code (SQL, shell commands, HTML, file paths, regex,
eval) needs the data kept separate from the code. - How much? Bodies, uploads, arrays, guesses per minute: anything unbounded is a denial-of-service or brute-force opportunity.
Part 2: Authorization
Check authorization on the server, for the specific object, on every request:
| Wrong | Right |
|---|---|
| "The button is hidden for non-admins" | The route checks the role |
| "The user is an admin" | The user is an admin of this workspace |
| "The order exists" | The order exists and belongs to the caller |
update(id, req.body) | Update only an allow-listed set of fields |
Drill 1: Who's allowed?
Part 3: Injection — keep data out of code
Every injection bug has the same shape: user data is pasted into something that will be interpreted as code. The fix always has the same shape too: pass the data through a channel that can't be interpreted.
| Where it lands | Pasted (vulnerable) | Separated (safe) |
|---|---|---|
| SQL values | `WHERE id = ${id}` | WHERE id = $1 with parameters |
SQL identifiers (ORDER BY, columns) | `ORDER BY ${sort}` | An allow-list mapping to known columns |
| Shell | exec(`convert ${file}`) | execFile('convert', [file]), no shell |
| File paths | path.join(dir, name) | Resolve, then check it's still inside dir |
| HTML | innerHTML = text | textContent, or a sanitiser |
| Mongo filters | find({ email: body.email }) | Validate email is a string |
| JavaScript | new Function(expr) | A restricted expression parser |
Drill 2: Injection
Part 4: The browser
Browser-side attacks use the victim's own logged-in browser against them:
- XSS: the attacker's script runs on your page, with the user's session.
- CSRF: another site makes the user's browser send a request to yours, with their cookies.
- Open redirect: your trusted domain forwards users to the attacker's page.
- CORS misconfiguration: you tell browsers that any site may read your API's responses as the user.
Drill 3: Browser-facing code
Part 5: Tokens, secrets and abuse
Tokens must be unguessable, verified, expiring and, where relevant, single-use. Secrets must never reach logs, the client bundle or the repository. Abuse controls (rate limits, size limits, generic error messages) stop attackers from simply trying until something works.
Drill 4: Tokens
Drill 5: Abuse
Drill 6: Fetching and uploads
Drill 7: Secrets and leaks
Drill 8: Subtle code execution and DoS
Cheat sheet
| Signal | Risk | Fix |
|---|---|---|
:id loaded without the caller in the query | IDOR | Scope every lookup to the caller or tenant |
| Role check without the resource | Cross-tenant access | Check role for this resource |
update(…, req.body) | Mass assignment | Allow-list fields with a schema |
${…} inside SQL, shell, paths | Injection | Parameters, execFile, resolve-and-check |
ORDER BY ${…} | Identifier injection | Allow-list mapping |
innerHTML, dangerouslySetInnerHTML | XSS | Text, or a sanitiser |
res.redirect(req.query…) | Open redirect | Relative, same-site paths only |
| Origin echoed + credentials | CORS bypass | Explicit origin allow-list |
| Cookies without flags | Theft, CSRF | httpOnly, secure, sameSite |
jwt.decode, no exp, Math.random tokens | Forged or guessable tokens | verify, expiry, crypto.randomBytes |
=== on signatures | Timing leak | timingSafeEqual |
No limit on login/OTP, trusting X-Forwarded-For | Brute force | Rate limits on the real client IP, attempt caps |
| Different messages for "no user" / "wrong password" | Enumeration | One generic message |
fetch(userUrl) | SSRF | Allow-list hosts, block private ranges |
Client mimetype / filename | Malicious uploads | Check bytes, generate names, serve as attachment |
Logging headers/bodies, stack traces in responses, NEXT_PUBLIC_* secrets | Leaks | Redact, generic errors, server-only secrets |
Deep merge of user JSON, nested regex quantifiers, new Function | Pollution, ReDoS, RCE | Schema validation, safe regex, no eval |
Say it in the interview
"The download route loads the document by ID and never checks it belongs to the caller, and the IDs are sequential, so any logged-in user can download every customer's documents by counting. I'd rank that first. The fix is to scope the query by owner and return 404 otherwise; the prevention is a data-access layer where every lookup takes the caller, plus a test that tries another user's ID on every route."
Go deeper
- FE-11 · Browser Security Model: XSS, CSRF, CORS and token storage in depth.
- SD 1 · Auth Internals: how sessions and tokens work.
- SD 7 · Rate Limiting: limits that actually hold.
- Next: L6 · Breaks in Production.