01/12Threat model

LEARN TO PROGRAM · CHAPTER 15 · ARCHITECTURE

Security for programmers

Every program that touches a network has strangers using it, and some of them are trying to make it do something you never meant. Security is the craft of writing code that does only what you meant, even when its input is chosen by someone hostile.

The good news: most real attacks exploit a handful of old, well-understood mistakes. Learn the mistakes and their fixes, and you are ahead of most code on the internet. On the stage is one small app. Each act attacks it twice: once against careless code (red), once against the fix (green).

Every code block runs in your browser. Edit it, break it, press Run.

01 · THREAT MODEL

Think like an attacker: the threat model

You can't defend everything equally, so start by drawing a map. A threat model names what is worth protecting (the assets: the user table, the payment keys), where strangers can reach in (the entry points), and the trust boundaries: the lines where data crosses from someone you don't trust to something you do. On the stage, the violet wall is that line. Everything left of it, including your own users' browsers, is outside your control.

The whole practice fits in 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? For "what can go wrong", Microsoft's STRIDE is a useful checklist: Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege.

threat-model.js

Rank by likelihood × impact, then fix from the top. Change a number and watch the order change. A threat model is a living document: redo it when the feature changes.

One rule follows from the map: never trust anything that crosses the boundary. Form fields, URLs, headers, cookies, uploaded files, even the JavaScript in your own page: the attacker controls their browser completely, so every check that matters must happen again on the server.

02 · OWASP TOP 10

The OWASP Top 10

The OWASP Foundation (the Open Worldwide Application Security Project) publishes a ranked list of the most critical risks to web applications, built from testing data contributed by security teams plus a survey of practitioners. The current edition is 2025; the stage lights its ten categories in turn and points at where each one lives in our app.

owasp-2025.js

Broken access control is still number one. Supply-chain failures climbed to third, and A10 is new: what your code does when something fails.

The list is a map of categories, not a checklist to tick. Several fit together: Injection (A05) is mixing data into code; Cryptographic Failures (A04) includes storing passwords badly; Authentication Failures (A07) covers weak logins and sessions. The rest of the chapter takes the most important ones, one per act.

1, 2, 3the ranks of cross-site scripting, SQL injection and cross-site request forgery on MITRE's 2025 list of the most dangerous software weaknesses. Missing authorization is 4th. They are the next four acts. MITRE 2025 CWE Top 25

03 · SQL INJECTION

SQL injection: when data becomes code

A database speaks SQL, a language. If your program builds a SQL sentence by gluing user input into it, the user gets to write part of your program. The quote character ends your string early, and whatever follows is read as SQL.

To show it safely, each block below carries its own tiny SQL engine (a real lexer and parser, about 60 lines, at the bottom). Watch the "words" line: with the attacker's input, OR stops being part of a name and becomes a keyword.

search.js

The second search dumps every user. On the stage: the red packet, the glued SQL on the server, every row lighting up.

login.js

-- starts a comment in SQL, so the password test is simply cut off.

Here is exactly what the database received, in a console that runs SQL on the course's own little SQL engine (the same one as in Data and databases). Run it, then change the glued-in parts and see what else an attacker could write.

what-the-database-got.sql

Tables: users(id, name, email, password). Each run starts from fresh data.

Both statements are valid SQL. The database can't know which parts the developer meant and which the visitor typed: that is the whole problem.

The fix is not to hunt for dangerous characters. It is to never let input be parsed as SQL at all. A parameterized query (or prepared statement) sends the SQL with ? placeholders, and the values separately; the database parses the SQL first, then fills the holes with values. A value can't end a string that it was never part of.

search-fixed.js

Same inputs, nothing dumped, and a name like O'Brien no longer breaks anything. Fixing the security bug fixed a correctness bug too.

Python · sqlite3
# Python, sqlite3: the driver sends the SQL and the values separately
cur.execute("SELECT name, email FROM users WHERE name = ?", (name,))
 
# Never: cur.execute(f"SELECT ... WHERE name = '{name}'")
Node.js · node-postgres
// Node.js, node-postgres: $1, $2 ... are placeholders
const { rows } = await pool.query(
  "SELECT name, email FROM users WHERE name = $1",
  [name]
);
PostgreSQL · what a prepared statement is
-- What the database sees: a statement prepared once...
PREPARE find_user (text) AS
  SELECT name, email FROM users WHERE name = $1;
-- ...then run with a value. The value is never parsed as SQL.
EXECUTE find_user('x'' OR ''1''=''1');   -- 0 rows

Placeholders can only stand for values, not for table or column names. If a user picks the column to sort by, check it against an allowlist of names you wrote. ORMs and query builders parameterize for you, until you reach for their "raw SQL" escape hatch.

04 · XSS

XSS: the same mistake, in the browser

Cross-site scripting is injection into HTML. If your page puts a user's comment into the page as raw HTML, then a comment containing a <script> tag runs as JavaScript, in every reader's browser, with your site's permissions: it can read the page, act as the user, and send their data away. On the stage, the attacker posts a comment, the server serves it to you, and your session leaks back to evil.example.

The demo below never touches this page. Its "browser" is a toy that only reports what a real one would treat as code.

comments.js

Two ways in: a script tag, and an event-handler attribute like onerror. Blocking the word "script" would miss the second, and dozens more.

The fix is output encoding: when you put text into HTML, turn the characters HTML treats as special into entities, so the browser draws them instead of obeying them. < becomes &lt; and shows as a < on screen.

comments-fixed.js

The comment shows exactly as the attacker typed it, harmlessly. Press Step through to watch each character get replaced.

Encoding depends on context. HTML-escaping is right between tags, but inside a link's href a javascript: URL needs no special characters at all:

links.js

In practice: let your framework do it. React, Vue, Svelte and every modern template engine escape text by default, and the DOM has a safe API. The danger is the escape hatches:

the DOM and React
// In the real DOM, choose the API that treats input as text:
el.textContent = comment;        // ✓ always text
el.innerHTML = comment;          // ✗ parses markup: only for HTML you trust
 
// React escapes {comment} for you. This opts out, so treat it like innerHTML:
<div dangerouslySetInnerHTML={{ __html: comment }} />

Then add a second layer. A Content Security Policy header tells the browser which scripts may run at all, so an injection that slips through still can't execute inline code. Cookies marked HttpOnly can't be read by JavaScript, so a successful XSS can't simply copy them.

HTTP response header
# A response header that tells the browser which scripts may run at all.
# Even if an injection slips through, inline <script> and onerror= are refused.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'
1 millionMySpace profiles infected in about 20 hours in October 2005 by one stored XSS that added its author as a friend and copied itself to every profile that viewed it. Samy worm (2005)

05 · CSRF

CSRF: a request you didn't mean to send

Your browser attaches a site's cookies to every request to that site, automatically, no matter which page started it. So if you are logged in to your bank and visit evil.example, a hidden form there can make your browser send your bank a transfer, with your session cookie on it. The bank sees a perfectly valid, logged-in request. That is cross-site request forgery.

bank.js

Four requests: the real one, the forged one with no defences, and two defences, each enough on its own here. Use both.

The defences, in layers:

  • SameSite cookies. SameSite=Lax (or Strict) tells the browser not to send the cookie on requests started by another site. Chrome treats cookies with no SameSite attribute as Lax.
  • A CSRF token. A random value the server puts in its own forms and checks on every state-changing request. evil.example can make your browser send a request, but it can't read your bank's pages to learn the token.
  • Check Origin. Browsers say which site started a request; refuse ones from elsewhere.
  • Never change anything on GET. Lax cookies still ride along on top-level links, so a link must never move money.
a session cookie, set properly
Set-Cookie: session=sess-71f3; Secure; HttpOnly; SameSite=Lax; Path=/
#  Secure    only over HTTPS
#  HttpOnly  JavaScript cannot read it (an XSS can't simply steal it)
#  SameSite  not sent on cross-site POSTs, fetches or iframes

06 · WHO & WHAT

Authentication vs authorization

Two words that sound alike and fail differently. Authentication answers who are you? (a password, a passkey, a code from your phone). Authorization answers are you allowed to do this? On the stage the gate checks who you are; then the server must still check, at the table, whether this row is yours.

invoices.js

The first call is the bug: alice is properly logged in, and reads bob's invoice just by changing the number in the URL. This is the most common item in OWASP's number-one category.

Rules that prevent most of it:

  • Check on the server, on every request. Hiding a button in the page is not a check; the attacker sends the request without your page.
  • Deny by default. Write rules that grant, so that anything nobody thought about is refused.
  • Put the check in one place (a middleware or a policy function) that every route goes through, so a new endpoint can't forget it.
  • Fail closed. If the permission lookup errors, the answer is no. Exercise 4 is this bug.

For authentication itself: don't build it from scratch if you can avoid it. Use your framework's, offer multi-factor sign-in or passkeys, rate-limit login attempts, and when you must store passwords, store them the way the next act shows.

07 · PASSWORDS

Storing passwords: salt and slow hashes

Never store a password. Store something that can check one. A hash function turns any input into a fixed-size fingerprint and can't be run backwards, so you keep hash(password) and compare hashes at login. But a plain fast hash like SHA-256 fails in two ways. Run this:

fast-hash.js

Same password, same hash, so one cracked hash cracks every user who chose it. And a fast hash is fast for the attacker too: a stolen table can be guessed against at billions of tries a second on GPUs.

The fix has two parts. A salt: a random value, unique per user, mixed in before hashing and stored next to the hash. And a deliberately slow function with a tunable cost, so each guess costs the attacker real time. On the stage, the password and its salt go through the mill 600,000 times before the result reaches the table.

password-storage.js

Each hash takes a noticeable fraction of a second, which nobody notices at login and which makes guessing millions of times slower. The comparison checks every character, so its timing reveals nothing.

The browser only has PBKDF2 built in. On a server, use the OWASP order of preference: Argon2id (memory-hard, so GPUs and custom chips gain little), then scrypt, then bcrypt for older systems, PBKDF2 where FIPS compliance requires it. Use a maintained library; never write your own.

Python · argon2-cffi
# Python: argon2-cffi (Argon2id, the current first choice)
from argon2 import PasswordHasher
ph = PasswordHasher()                 # Argon2id, salted, sensible defaults
stored = ph.hash("correct horse")     # '$argon2id$v=19$m=65536,t=3,p=4$...'
ph.verify(stored, "correct horse")    # True, or raises VerifyMismatchError
Node.js · bcrypt
// Node.js: bcrypt (still fine; note the 72-byte input limit)
import bcrypt from "bcrypt";
const stored = await bcrypt.hash("correct horse", 12);   // cost 12 = 2^12 rounds
const ok = await bcrypt.compare("correct horse", stored); // true
600,000PBKDF2-HMAC-SHA256 iterations OWASP recommends. For Argon2id the minimum is 19 MiB of memory, 2 iterations, 1 degree of parallelism; for bcrypt, a cost of at least 10. OWASP Password Storage Cheat Sheet

To feel how fast guessing is from the other side, try Crack My Password: it shows how long your password would last against a real cracking rig.

08 · SECRETS

Secrets: never in the code

API keys, database passwords and signing keys are secrets: whoever holds one is your app, as far as the other side can tell. Put one in your source code and it is in every copy: every clone, every fork, every laptop, every backup, every CI log, and forever in the git history, even after you delete the line.

Scanners run on every public push, both the good ones (GitHub's push protection) and attackers'. Here is the core of one: known key formats, plus a measure of randomness called entropy for keys with no known format.

scan-secrets.js

Three findings in five files. db.js is the right pattern: the code names the secret, and the value arrives at run time.

read it from the environment
// Read secrets from the environment at run time, and fail loudly if missing.
const key = process.env.PAYMENTS_KEY;
if (!key) throw new Error("PAYMENTS_KEY is not set");
.gitignore and .env.example
# .gitignore: the real values never enter git
.env
*.pem
 
# .env.example IS committed: the names, with no values
DATABASE_URL=
PAYMENTS_KEY=

Better still, keep secrets in a secrets manager (a vault: AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, your platform's encrypted settings) that hands them to the app at run time, logs who read them, and lets you rotate them without a deploy. Install a pre-commit scanner (gitleaks, trufflehog) so a key is caught before it ever leaves your laptop. See how history keeps everything on Git Time Machine.

23.8 millionnew secrets found in public GitHub commits in 2024. Of those leaked in 2022, 70% still worked. GitGuardian, State of Secrets Sprawl 2025

09 · DEPENDENCIES

Dependencies: other people's code, running as you

A typical app is mostly code you didn't write. Every package you install runs with your app's full permissions, and so does every package it installs. The tree on the stage is the one in this program: you chose three packages, and you run thirteen.

deps.js

Press Step through to watch the breadth-first search fill seen. The real command is npm ls tiny-pad: "why is this here?"

Two kinds of risk. A package can have an honest vulnerability, which you fix by updating. Or the package itself can be malicious: a maintainer's account is stolen, a lookalike name is published (typosquatting), or a trusted project is slowly taken over. A lockfile records the exact version and a cryptographic hash of every package, so what you install tomorrow is byte-for-byte what you reviewed today:

lockfile-check.js

The same idea protects scripts loaded from CDNs: Subresource Integrity, an integrity="sha384-…" attribute on the script tag.

habits for npm (other ecosystems have the same)
# Install exactly what the lockfile says, and check every hash
npm ci
 
# Known vulnerabilities in what you depend on
npm audit
 
# Don't run install scripts from packages you haven't reviewed
npm ci --ignore-scripts

Habits: commit the lockfile; install with npm ci in CI; let a bot (Dependabot, Renovate) propose updates and read what changed; prefer fewer, well-maintained dependencies; and treat a new dependency like hiring someone who gets the keys. The checks belong in your CI pipeline, on every change.

10 · LEAST PRIVILEGE

Least privilege: limit the blast radius

Assume something will be broken into one day: a dependency, a stolen key, a bug you haven't found. The question is how much the attacker gets. The principle of least privilege says every program, user and service should have only the permissions its job needs, and no more. On the stage, the red sphere is what a hacked server can reach. Trim its grants and the sphere shrinks.

grants.js

Comparing what is granted with what is actually used is how cloud tools (like AWS IAM Access Analyzer) suggest trimmed policies.

PostgreSQL · a database user that can only read
-- The reporting job's database user can read two tables. That's all.
CREATE ROLE report_service LOGIN;
GRANT SELECT ON orders, users TO report_service;
-- no INSERT, UPDATE, DELETE, no other tables, not the owner, not a superuser

Where it applies: the database user your app connects as (not the owner, never a superuser); cloud roles (no *); API tokens (scoped and short-lived); containers (not root, read-only file system); people (admin rights only when needed). Combined with the earlier acts, it means a SQL injection in a read-only reporting service can't drop tables, and a leaked key can't reach the vault.

11 · HTTPS

HTTPS: protecting data on the move

Between a browser and your server sit Wi-Fi routers, ISPs and networks you don't control (see the internet). With plain HTTP, any of them can read and change everything: passwords, cookies, the page itself. On the stage, the eavesdropper reads the password straight off the pipe. HTTPS is HTTP inside TLS, which gives three things: the data is encrypted, it can't be changed undetected (integrity), and you know you're talking to the real server (authentication, by its certificate).

Here are the first two, with the same algorithms TLS 1.3 uses, built into your browser:

tls-in-miniature.js

Run it twice: the bytes on the wire change every time (a fresh key and a fresh random IV), but the server always reads the same message. Flip one bit and AES-GCM refuses to decrypt at all.

What a real handshake adds is the third part: the server proves who it is with a certificate signed by an authority your browser trusts, so nobody in the middle can swap in their own key. Watch a whole handshake in 3D on HTTPS, and how the ciphers work on Encryption.

As a programmer: serve everything over HTTPS (free certificates from Let's Encrypt), redirect HTTP to it, send HSTS so browsers never try plain HTTP again, mark cookies Secure, and use TLS between your own services too. Never turn off certificate checks to make an error go away.

HTTP response header
# Tell browsers: only ever use HTTPS for this site, for a year
Strict-Transport-Security: max-age=31536000; includeSubDomains
1 round tripis all a full TLS 1.3 handshake needs before encrypted data flows, down from two in TLS 1.2. Security doesn't have to be slow. RFC 8446, TLS 1.3

12 · LOGGING

Logging without leaking

Logs are how you notice an attack (OWASP's A09 is about not having them) and how you investigate one. But logs are copied to many places, read by many people and kept for a long time, so a password written to a log is a password leaked. On the stage the log panel fills with what careless code writes, then with what careful code writes.

logger.js

Two bugs in the naive lines: a password copied into the log, and a newline in a username that forges a fake "admin logged in" line. JSON keeps each entry on one line.

Do log: logins and failed logins, permission denials, input that failed validation, admin actions and changes to permissions, with a timestamp, the user id and a request id. Never log: passwords, session ids and tokens, API keys, full card numbers, or personal data you don't need. Prefer structured logs (one JSON object per line), log fields you chose by name rather than whole request objects, and alert on the patterns that matter, like a burst of failed logins.

PRACTICE

Now you do it

Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.

1

Close the injection

getEmail glues the name into its SQL. Change it to use a ? placeholder so x' OR '1'='1 finds nobody, carol still finds Carol, and a name with a quote in it no longer crashes. The toy engine is at the bottom of the block.

exercise-1.js
2

Escape it

Write escapeHtml so any text is shown as text. It must turn & < > " ' into &amp; &lt; &gt; &quot; &#39;, and it must not mangle text that is already safe.

exercise-2.js
3

Whose invoice is it?

Anyone logged in can read any invoice by changing the id: an IDOR (insecure direct object reference). Make getInvoice throw unless the session's user owns the invoice or is an admin, and also when the invoice doesn't exist.

exercise-3.js
4

Fail closed

When the role service fails, this check says yes. Run it: mallory, who isn't in the system at all, may delete users. Fix it so any failure means no. This is OWASP's new A10: mishandling exceptional conditions.

exercise-4.js
5

Check the request

Write isAllowed(req, session) for the bank: allow a request only if it is a POST, its origin is exactly https://bank.example, and its body's csrf token equals the session's. Anything missing means no.

exercise-5.js
6

Scrub the log

Write redact(obj): return a copy in which the value of every key containing password, token or secret (any case) is "[REDACTED]", at any depth, including inside arrays. Leave everything else as it was.

exercise-6.js

RECALL

Check yourself, from memory

Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.

What are the four questions of threat modelling?

Why does a parameterized query stop SQL injection, when escaping quotes by hand often fails?

XSS and SQL injection are the same mistake. What is it?

Authentication or authorization: which one is missing when a logged-in user can read someone else's invoice?

Name the three things a password hash needs, and why.

You committed an API key and deleted it in the next commit. Are you safe?

GO FURTHER

Resources

  • DOCS · FREEOWASP Top 10:2025OWASP FoundationThe list itself: what each risk is, how it happens, and how to prevent it, with the data behind the ranking.
  • DOCS · FREEOWASP Cheat Sheet SeriesOWASP FoundationShort, practical pages on exactly one thing each: SQL injection prevention, XSS, CSRF, password storage, logging, secrets.
  • PRACTICE · FREEPortSwigger Web Security AcademyPortSwiggerFree interactive labs: deliberately vulnerable apps, built for you to practise on legally. The best place to train this chapter.
  • PRACTICE · FREEOWASP Juice ShopOWASP FoundationAn intentionally insecure web shop you run on your own machine and break in over 100 ways, with a scoreboard.
  • BOOK · FREESecurity Engineering (3rd ed.)Ross AndersonThe classic textbook on building systems that stay dependable under attack. Many chapters free to read.
  • COURSE · FREECS 253: Web SecurityStanford UniversityStanford's web security course: the browser security model, XSS, CSRF, cookies, HTTPS. Slides and videos are online.
  • ARTICLE · FREEThreat Modeling ManifestoOne page of values and principles from the people who practise threat modelling, built around four questions.
  • TOOL · FREEOpenSSF ScorecardOpen Source Security FoundationScores any open-source project on supply-chain habits: pinned dependencies, code review, signed releases, branch protection.

ON THIS SITE

See it running

SOURCES

Where the facts come from

  1. OWASP Top 10:2025 The ten categories and their order; released after a November 2025 release candidate.
  2. OWASP Top 10:2021 The previous edition, for the rank changes.
  3. MITRE: 2025 CWE Top 25 XSS (CWE-79) 1st, SQL injection (CWE-89) 2nd, CSRF (CWE-352) 3rd, missing authorization (CWE-862) 4th.
  4. Microsoft: STRIDE threat categories
  5. Threat Modeling Manifesto The four questions.
  6. Phrack 54: NT Web Technology Vulnerabilities (1998) Among the first public descriptions of SQL injection, by Jeff Forristal.
  7. OWASP: SQL Injection Prevention Cheat Sheet
  8. PostgreSQL docs: PREPARE
  9. Python docs: sqlite3 placeholders
  10. OWASP: Cross Site Scripting Prevention Cheat Sheet
  11. MDN: Content Security Policy
  12. MDN: Node.textContent Why textContent is safe and innerHTML is not.
  13. Samy (computer worm) MySpace, 4 October 2005: over a million profiles in about 20 hours.
  14. OWASP: CSRF Prevention Cheat Sheet
  15. MDN: Set-Cookie (SameSite, Secure, HttpOnly)
  16. web.dev: SameSite cookies explained Chrome treats cookies without SameSite as Lax.
  17. OWASP: Authorization Cheat Sheet Deny by default; check on every request.
  18. OWASP: Password Storage Cheat Sheet Argon2id (19 MiB, 2 iterations, 1 lane minimum), scrypt, bcrypt (cost ≥ 10, 72-byte limit), PBKDF2-HMAC-SHA256 600,000 iterations.
  19. RFC 9106: Argon2
  20. Provos & Mazières: A Future-Adaptable Password Scheme (bcrypt, 1999)
  21. MDN: SubtleCrypto.deriveBits (PBKDF2)
  22. GitGuardian: The State of Secrets Sprawl 2025 23.8 million new secrets on public GitHub in 2024; 70% of secrets leaked in 2022 were still valid.
  23. AWS: Managing access keys (example key AKIAIOSFODNN7EXAMPLE)
  24. The Twelve-Factor App: Config Keep config, including credentials, in the environment, not the code.
  25. NVD: CVE-2024-3094 (xz Utils backdoor)
  26. Andres Freund on oss-security, 29 March 2024 The report that exposed the xz backdoor.
  27. NVD: CVE-2021-44228 (Log4Shell)
  28. FTC: Equifax data breach settlement About 147 million people affected (2017).
  29. npm docs: npm ci
  30. MDN: Subresource Integrity
  31. Saltzer & Schroeder: The Protection of Information in Computer Systems (1975) Least privilege, fail-safe defaults and the other design principles.
  32. RFC 8446: TLS 1.3 A full handshake in one round trip.
  33. RFC 6797: HTTP Strict Transport Security
  34. MDN: SubtleCrypto (ECDH, AES-GCM)
  35. OWASP: Logging Cheat Sheet What to log, and what never to.
  36. MITRE: CWE-117, Improper Output Neutralization for Logs