Step 05 of 25
Threat modeling
Draw a data-flow diagram with trust boundaries, find threats systematically with STRIDE, rank them, and turn them into backlog items.
Scanners find known bad patterns in existing code. They can't tell you that your design lets an employee approve their own expense claim, or that a "fetch from URL" feature can reach your cloud metadata service. Threat modeling finds those problems while they're still on a whiteboard, when a fix costs an hour instead of a rewrite. You'll model a realistic app end to end: draw its data flows and trust boundaries, find threats with STRIDE, rank them, and turn them into tickets.
Four questions
Threat modeling sounds formal, but it reduces to four questions you ask about a system:
- What are we working on? A model of the system: its parts, its data and how they connect.
- What can go wrong? Threats, found systematically rather than by whoever is most paranoid.
- What are we going to do about it? A decision per threat: mitigate, eliminate, transfer or accept.
- Did we do a good job? Check the model matches reality, and that the mitigations shipped.
You need no special software: a shared document, a diagram and an hour with the people who know the system (developers, product, ideally a security champion). Do it when designing a new service or feature, and revisit it when data or trust moves: a new integration, role or public endpoint.
The example: an expense app
You'll model Expensa, a small internal expense-reimbursement service:
- Employees log in, create an expense claim, and attach a receipt by uploading an image or pasting a link to an online receipt.
- A background worker downloads the receipt if needed, stores it in an object-storage bucket, and sends it to a third-party OCR (text recognition) API, which returns the merchant and amount.
- Approvers (managers in finance) approve or reject claims. Approved claims are paid out in the next payroll run.
Step 1: the data-flow diagram
A data-flow diagram (DFD) uses four kinds of element:
- External entities: people or systems outside your control (users, third-party APIs).
- Processes: code you run (services, workers, functions).
- Data stores: where data rests (databases, buckets, queues, caches).
- Data flows: data moving between the others, numbered so you can refer to them.
On top you draw trust boundaries: lines where trust changes, so data crossing them must be checked, such as internet to your network, your system to a vendor's, or one privilege level to another.
Legend: [entity] (process) =store= --> data flow ==== trust boundary
[Employee] [Approver]
| |
| 1: expense + receipt | 2: approve / reject
| (upload or URL) |
=========|===========================|========== TB1: internet | company
v v
+---------------------------------------+
| (API server) |<-4: SQL-> =Postgres=
+---------------------------------------+ ^
| |
| 5: job {expense_id, image key or URL} | 7: OCR result
v |
=Job queue= --5--> (OCR worker) -----------------------+
| | |
| | +--6: image--> =Receipt bucket=
| |
=========================|=======|============== TB2: company | 3rd parties
| |
3: fetch | | 8: image + API key
receipt v v
[Any web host] [OCR vendor]The same diagram is interactive below. Select any element, flow or trust boundary to see which STRIDE categories (Step 2) apply to that kind of element and the threats this session found there, or highlight the flows that cross a trust boundary.
Try the trust-boundary crossings highlight, then select flow 3: the worker fetches whatever URL the user supplies, from inside the company network.
Notice flow 3: it crosses TB2 to any web host, because the user chooses the URL, while the worker sits inside the company network. That is exactly the shape of a server-side request forgery (SSRF) problem, and drawing the diagram raised it before anyone wrote code.
Step 2: find threats with STRIDE
STRIDE is a mnemonic for six threat categories, each the violation of a security property:
| Category | Violates | Question to ask | Typical mitigations |
|---|---|---|---|
| Spoofing | Authentication | Can someone pretend to be another user or system? | Strong login, MFA, mutual TLS, signed tokens |
| Tampering | Integrity | Can someone change data or code they shouldn't? | Authorization checks, input validation, signatures, immutability |
| Repudiation | Non-repudiation | Can someone deny an action because there's no evidence? | Tamper-evident audit logs |
| Information disclosure | Confidentiality | Can someone read data they shouldn't? | Access control, encryption, minimising data |
| Denial of service | Availability | Can someone stop the system working for others? | Rate limits, quotas, timeouts, size limits |
| Elevation of privilege | Authorization | Can someone gain rights they weren't given? | Least privilege, role and ownership checks, sandboxing |
Apply it per element: walk through every process, store, flow and boundary crossing and ask the questions that fit. External entities mostly face spoofing and repudiation; data stores mostly tampering, disclosure and denial of service; processes can suffer all six.
Step 3: a worked STRIDE analysis
Here is the result of a one-hour session on Expensa, with IDs for later reference.
| ID | Element | STRIDE | Threat | Proposed mitigation |
|---|---|---|---|---|
| T1 | Flow 1, API | S | Credential stuffing: attacker logs in with a password leaked from another site. | Company SSO with MFA. |
| T2 | API | E | An approver calls POST /expenses/{id}/approve on their own claim; the endpoint checks "is approver" but not "is not the submitter". | Enforce role and approver_id != submitter_id; tests for both. |
| T3 | API | I | IDOR: GET /expenses/{id} returns any claim, exposing colleagues' receipts. | Ownership or approver check in the query; 404 otherwise. |
| T4 | API | T | Employee edits the amount with PATCH after the claim was approved. | State machine: approved claims are immutable; changes create a new claim. |
| T5 | Postgres | R | Approver later claims they never approved a fraudulent payout; there is no record. | Append-only audit table: who, what, when, old and new values. |
| T6 | Flow 3, worker | I, E | Receipt URL http://169.254.169.254/latest/meta-data/... makes the worker fetch cloud credentials from the metadata service (SSRF). | Allow only https, block private and link-local ranges after DNS resolution, route fetches through an egress proxy, require IMDSv2. |
| T7 | Receipt bucket | I | Bucket or objects are publicly readable; receipt URLs are guessable. | Private bucket, random object keys, short-lived pre-signed URLs for viewing. |
| T8 | Worker | D | A 2 GB file or a "decompression bomb" image exhausts worker memory and stalls the queue. | Size limit at upload and download, pixel-count limit, per-job timeout and memory limit. |
| T9 | Worker | E | A crafted image exploits a bug in the image-parsing library, giving code execution in the worker. | Patch image libraries, run the worker as non-root with no cloud admin rights, validate file type. |
| T10 | Flow 8 | I | Vendor keeps receipts (personal data) indefinitely; a leaked API key bills our account. | Retention terms in the contract; key in a secret manager. |
| T11 | Flow 4 | T | SQL injection in the expense search filter. | Parameterised queries; database role without DDL rights. |
The most dangerous items (T2, T3, T6) are design and logic problems no scanner would flag, and some threats span categories: SSRF (T6) discloses credentials and lets the attacker act as the worker's cloud role.
Step 4: rank the risks
You can't fix eleven things at once. Score likelihood (how easy and attractive to exploit) and impact (how bad the outcome), each 1 to 3, and multiply:
| ID | Likelihood | Impact | Score | Reasoning |
|---|---|---|---|---|
| T6 | 3 | 3 | 9 | Trivial to try; cloud credentials |
| T2 | 3 | 3 | 9 | One request; direct fraud |
| T3 | 3 | 2 | 6 | Easy; all staff's personal data |
| T5 | 2 | 3 | 6 | Needed to investigate any fraud |
| T1 | 2 | 3 | 6 | Common attack; SSO fixes it |
| T4 | 2 | 2 | 4 | Needs an approved claim; fraud |
| T7 | 1 | 3 | 3 | Only if misconfigured; breach |
| T9 | 1 | 3 | 3 | Needs a library exploit |
| T11 | 1 | 3 | 3 | ORM already used; verify search |
| T8 | 2 | 1 | 2 | Outage delays reimbursements |
| T10 | 1 | 2 | 2 | Contract and key handling |
The matrix is deliberately rough: its job is an order of work, not a precise number. For consistency across teams, map scores to your severity levels so they line up with the remediation SLAs you use for scanner findings.
Step 5: turn threats into backlog items
A threat model that lives only in a document changes nothing. Decide one response per threat:
- Mitigate: add a control that reduces likelihood or impact (most items).
- Eliminate: remove the feature that creates the risk. Dropping "paste a receipt URL" eliminates T6 entirely.
- Transfer: make someone else carry it, for example via a vendor contract (part of T10).
- Accept: consciously live with it, with a named owner and a review date.
Write mitigations as ordinary tickets with testable acceptance criteria, so they flow through normal planning and review:
EXP-142 Prevent self-approval of expense claims [security, threat: T2, score 9]
As an organisation, we need approvals to be made by someone other than the claimant,
so that a single person cannot pay themselves.
Acceptance criteria
- POST /expenses/{id}/approve returns 403 when the caller is the claim's submitter,
even if the caller has the approver role.
- Returns 403 when the caller lacks the approver role.
- Each approval and rejection writes an audit row (see EXP-145).
- Integration tests cover: approver approves other's claim (200), approver approves
own claim (403), employee approves any claim (403).EXP-143 Block SSRF in receipt URL import [security, threat: T6, score 9]
Acceptance criteria
- Only https URLs are accepted; others return 400.
- The worker resolves the hostname and refuses private, loopback and link-local
addresses (including 169.254.169.254), and does not follow redirects.
- Worker egress goes through the proxy, which denies internal ranges.
- EC2 instances require IMDSv2 (session tokens).
- Tests: http://169.254.169.254/, http://localhost/, https://10.0.0.5/ are rejected.Track accepted risks too:
RISK-031 Worker outage from oversized images (T8) Accepted until 2027-03-31
Owner: expenses team lead. Rationale: internal tool, upload size already capped at 10 MB,
outage impact is delayed reimbursement only. Revisit if the app opens to contractors.Keeping it lightweight
Threat models fail more often from being too heavy than too shallow. Scope tightly: ninety minutes on the feature being changed beats a two-day workshop nobody repeats. Store the model in the repository (for example docs/threat-model.md with the ASCII diagram) so it's reviewed in pull requests, revisit it when the design changes, and never stop at the list: a threat without a ticket or an explicit acceptance is just anxiety in a table.
Practice
Threat-model Shorty, a URL shortener:
- Anyone can create a short link (
POST /linkswith a target URL) without an account. - On creation, a preview service fetches the target page for its title and thumbnail.
- Visiting
/{code}redirects to the target and records the visitor's IP and user agent. - Logged-in owners see an analytics dashboard for their links.
Draw the DFD with trust boundaries, write a STRIDE table of at least six threats, rank them, and turn the top two into backlog items.
Show solution
[Visitor / creator] [Link owner]
| |
| 1: create link, follow /{code} | 2: dashboard (session)
============|==================================|===== TB1: internet | Shorty
v v
(Shorty API) <---3: SQL---> =Links + clicks DB=
|
| 4: preview job (target URL)
v
(Preview fetcher) ---6: title, thumbnail---> =Thumbnail store=
|
============|========================================= TB2: Shorty | internet
| 5: GET target URL
v
[Any web host]| ID | Element | STRIDE | Threat | Mitigation |
|---|---|---|---|---|
| S1 | Preview fetcher, flow 5 | I, E | SSRF: target http://169.254.169.254/ or internal admin pages. | https only, block non-public IPs after resolution, no redirects, egress proxy. |
| S2 | Flow 1 | T | Short links to phishing or malware abuse our trusted domain. | Malicious-URL checks, rate limits, abuse takedown. |
| S3 | API | D | Bots create millions of links and fill the database. | Per-IP rate limits, CAPTCHA after a threshold, quotas. |
| S4 | Dashboard | I | IDOR: /links/{id}/stats shows another owner's analytics. | Ownership check in the query; 404 otherwise. |
| S5 | Clicks DB | I | Stored IPs are personal data; breach or over-retention. | Truncate IPs, 30-day retention. |
| S6 | Preview output | T | Attacker-controlled page title causes stored XSS in the dashboard. | Output-encode titles; CSP. |
| S7 | Flow 2 | S | Session theft gives access to an owner's links. | HttpOnly, Secure, SameSite cookies; MFA option. |
Ranking (likelihood x impact): S1 9, S2 9, S6 6, S4 6, S3 4, S5 4, S7 3.
Backlog items:
SHORT-21 Block SSRF in preview fetcher [threat S1]
- Only https targets fetched; resolved IPs must be public (reject 10/8, 172.16/12,
192.168/16, 127/8, 169.254/16, ::1, fc00::/7, fe80::/10).
- Redirects not followed; 5 s timeout; 2 MB response cap.
- Tests prove http://169.254.169.254/ and http://localhost:8080/ are rejected.
SHORT-22 Prevent phishing and malware links [threat S2]
- New targets checked against a malicious-URL list before the link is activated.
- Anonymous creation limited to 10 links per IP per hour.
- /{code} shows an interstitial warning for links reported as abusive.Key takeaways
- Threat modeling answers four questions: what are we building, what can go wrong, what will we do, did we do a good job.
- A data-flow diagram of entities, processes, stores and numbered flows, with trust boundaries marked, is the foundation; most insights come from drawing it.
- Apply STRIDE per element: spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege.
- Rank threats by likelihood times impact to get an order of work, mapped to your normal severity levels.
- Every threat ends as a ticket with testable acceptance criteria, an elimination, a transfer, or an explicit acceptance with an owner and a date.
- Keep models small, stored with the code, and updated when the design changes.
Check yourself
Quiz
0/4 correct · 4 left
1.In a data-flow diagram, what is a trust boundary?
2.An approver denies having approved a fraudulent claim, and there is no record of who approved it. Which STRIDE category is this?
3.The team decides to remove the "import receipt from URL" feature because uploads cover the need. Which risk response is this?
4.Why should mitigations be written as tickets with testable acceptance criteria?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.