I can ship product systems. For a long time I could not walk into a messy repo, a noisy scanner report, or a design review and feel what mattered first.
So I studied product security the long way - not to become a full-time red-teamer overnight, but to stop guessing when someone asked:
Is this safe enough to ship?
The surprising lesson was not another list of CVEs. It was this:
Finishing the course and still freezing on the job is usually an indexing failure, not a memory failure.
You stored answers under the chapter they were taught in. Real work does not arrive as "chapter 12: SSRF." It arrives as a situation.
Real work arrives as situations
Courses file knowledge by topic:
- Phase 1: web vulns
- Phase 5: cloud
- Phase 8: LLM stuff
Work files knowledge by trigger:
- "There is an id in the URL"
- "The server fetches a URL the user supplied"
- "A scanner just returned 400 findings"
- "We have one afternoon with this repo"
Experts are not people with more facts. They are people whose facts are clustered around a few deep principles and indexed by the situation that fires them. A doctor does not recall "chapter on sepsis." A presentation walks in and a whole script fires at once.
I needed that for security.
Reading a summary changes almost nothing. The useful version of this material is an instrument you run: scripts, checklists, a recall deck, a drill with a written verdict, and a gap journal. If you only read, you still have a textbook in your head. Textbooks do not triage.
The whole field on one page
Every bug I studied walks the same road. Every defense is a place you stand on it.
attacker-controlled input
query, body, header, cookie, path, filename,
a row a user stored yesterday, a dependency,
a base image, a model's own output
|
v crosses a TRUST BOUNDARY
|
v propagates (assign, concat, pass, return)
|
v arrives at a SINK
SQL / shell / filesystem / HTML /
template / XML / deserializer /
URL fetcher / LLM tool call
|
no matching sanitizer in between -> vulnerability
|
v chained to a second finding
|
v compromise, sized by what you had already permitted
One sentence:
Untrusted data reaches a powerful operation with nothing matching in between, gets chained, and the damage is whatever you had already permitted.
When I have nothing else, I ask the question the page collapses into:
What untrusted thing reaches what powerful thing, and what stands between them?
That question is boring on purpose. Boring is what survives pressure.
Ten principles that generate the rest
I stopped trying to memorize fifty named bugs as fifty separate things. Most of them are generated by a short list of principles. A few that changed how I review code:
- A vulnerability is a path, not a bad character. Source → boundary → sink, with no matching control on the path.
- Data becomes code when data and command share one string. The fix is two channels: parameterized query, argv list, constant template with variables passed apart - not a "cleaner string."
- You cannot enumerate bad input. Prefer describing good input. Best of all, verify the outcome (resolved path, resolved IP, exact-match URL).
- Authentication is not authorization. Knowing who is calling never answers whether this object is theirs.
- A valid value is not proof of intent or ownership. Re-verify at the sink.
- The client runs on the attacker's machine. Hidden fields, disabled
buttons, JS validation,
isAdminin the body - all suggestions. - Kill the class, not the instance. One safe API + one lint rule + one CI gate beats infinite patched lines.
- Severity is not risk. Reachability decides.
- Severity is not additive along a path. It multiplies.
- Assume the boundary fails and size the blast radius in advance. Least privilege is how you choose what it costs when they get in.
Number 4 and number 8 alone would have saved me months of false confidence.
Illness scripts for engineers
The instrument that turned "I read about IDOR" into "I recognise IDOR" was a set of scripts - situations written the way reality presents them:
TRIGGER what you see first
UNDER the mechanism, stated exactly
SIGNS what else you will see if this is really it
MEANS the class of problem
MOVES your first three moves, in order
A recognition table helps more than a syllabus:
| You see | It is probably |
|---|---|
| an id in the URL or body | IDOR / BOLA |
| the server fetches a URL you gave it | SSRF |
| your input comes back on the page | XSS |
a ' changes results or errors | SQLi |
| a role / price / flag in the request | tampering / mass assignment |
| a scanner dumped hundreds of findings | triage overload |
| a CVSS 9.8 in a dependency | reachability question |
an Action: "*" in a policy | over-broad IAM |
| an LLM with tools | prompt injection |
The boring bug that eats companies
Here is the script that shows up constantly in product work:
TRIGGER GET /api/account/123 or {"user_id": 5} in a body
UNDER The server fetches by id after checking you are logged in,
but never that this object is yours. Auth ≠ authz.
SIGNS Sequential ids. Full object in the response.
Change the id by one → someone else's data with a 200.
MEANS Broken access control (IDOR / BOLA). #1 web risk, #1 API risk.
MOVES 1. As user A, fetch A's object, then change the id to B's.
2. If B's data returns, confirmed. No fancy payload needed.
3. Fix: scope the query. Deny by default.
In code, the difference is almost insultingly small:
// looks "authenticated"... still broken
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({
where: { id: req.params.id },
})
return res.json(invoice)
})
// ownership is the actual control
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: {
id: req.params.id,
ownerId: req.user.id,
},
})
if (!invoice) return res.status(404).end()
return res.json(invoice)
})
Same token. Same endpoint shape. Completely different security story.
If your review checklist only asks "is the user logged in?", you are grading authentication and calling it access control. Attackers love that confusion. So do scanners that cannot understand your ownership model.
Checklists are for what falls out of your head
I used to think checklists meant "follow every step like a recipe." The useful version is do-confirm:
- Do the work from your own head.
- At a named pause point, stop.
- Run a short list of killer items - the things that actually get skipped under pressure.
A complete list is a useless list. Past roughly a minute at a pause point, you stop reading it.
Before I call a handler safe, I now ask things like:
- Is every request value treated as untrusted?
- For every id, do we check ownership, not just login?
- Does any client field bind straight onto privileged model fields?
- Can I trace every sink back to a constant or a sanitized source?
Before I file a finding:
- Exact location (file, line, function)
- Mechanism in one sentence
- Concrete impact
- Severity by reachability in this system, not raw CVSS
- Minimal proof against a system I own
A finding without a mechanism is an alarm, not a finding.
The traps that survive knowing the facts
Competent engineers get this wrong in predictable ways. The value is not the label - it is the tell, the observable that exposes it.
| Looks like | Actually is | The tell |
|---|---|---|
"We strip ../ and reject quotes" | Defending spellings you thought of | Fix inspects input patterns instead of verifying outcomes |
| "We set HttpOnly, XSS remediated" | Blast-radius shrink, XSS still runs | Remediation names a cookie flag, not output encoding |
| "Token is valid, so access is controlled" | Authn without authz | Object fetched by request id with no ownership clause |
| "Form validates it / button is disabled" | Client-side theater | Check exists only in the browser |
| "SAST was clean" | Clean for the rules you ran | No manual review, no custom rules for your dangerous helpers |
| "CVSS 9.8, drop everything" | Abstract severity mistaken for risk | Fix order matches raw CVSS with no reachability check |
| Scoring findings one by one | Deleting the chain the attack is made of | Two "mediums" never get considered as one path |
That last one is vicious. Scanners and tired humans score findings in isolation. Attackers do not.
Numbers worth knowing cold
A few constants that now set off alarms before I "do analysis":
- Broken access control is OWASP's #1 web risk. The boring id-in-the-URL bug, not the clever exploit, is the common case.
- BOLA is the same failure at API speed across millions of ids.
- Typical apps are 80-95% third-party code by line. Most of your surface is code you did not write.
- Most cloud breaches are an access / identity story, not a novel zero-day.
- A leaked cloud key on public GitHub has a minutes-scale time-to-exploit.
- Capital One 2019 is still the clearest teaching story: SSRF → metadata → over-broad IAM. A scoped role makes stolen credentials nearly worthless.
Calibration matters. If your mental model of "how breaches happen" is Hollywood zero-days, you will under-invest in ownership checks, secret handling, and IAM shape.
Judgment is a drill, not a vibe
The proof that this is working is not a certificate. It is a drill:
- Get ten mixed findings.
- Rank them yourself.
- Write why.
- Diff against an experienced reviewer's ranking.
- Journal every gap.
The gap journal line is deliberately tiny:
I said X, the reviewer (or reality) said Y, because Z.
Examples that actually teach:
I ranked the CVSS 9.8 near the top; reviewer ranked it near the bottom,
because the vulnerable function is never called. Reachability, not severity.
I scored two mediums separately; reviewer chained them into a payment-token
harvest, because severity multiplies along a path.
I said "delete the committed key"; reviewer said "rotate first",
because git history keeps it forever on every clone.
A fact you got right teaches almost nothing. A gap teaches exactly one thing, precisely.
Then wire if-then triggers so the manual fires without a motivational speech:
- If I see an id in a URL or body, then check ownership before anything else.
- If the server fetches a URL I can influence, then think SSRF and metadata endpoints before I argue about feature design.
- If a scanner hands me a pile, then rank by reachability, not CVSS.
- If I find a committed secret, then rotate before I tidy the file.
What changed in how I build products
I still care about performance, clarity, and shipping. Product security did not replace those. It gave them a sharper edge:
- I design ownership into queries and domain models early, not as an afterthought ticket.
- I treat client-supplied privileged fields as hostile by default.
- I ask for blast radius when someone says "just make this service role temporary admin."
- I triage scanner noise without pretending a green pipeline means a safe product.
- I write findings (and PR review comments) with mechanism and proof, not vibes.
The operating manual I keep now is not a second brain stuffed with every CVE name. It is a small set of principles, situation scripts, short checklists, and a habit of writing down where my judgment was wrong.
That is what makes the knowledge survive contact with a real codebase.
If you are an engineer studying product security: do not only collect chapters. Re-file what you learn under the situations that will actually walk into your day. Then run a drill. Then keep a gap journal.
That is the difference between finishing a course and being able to do the work.






