# Threat modeling

> Draw a data-flow diagram with trust boundaries, find threats systematically with STRIDE, rank them, and turn them into backlog items.

DevSecOps Engineer roadmap · Secure the code · 17 min read · https://levelcode.org/roadmaps/devsecops/threat-modeling

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:

1. **What are we working on?** A model of the system: its parts, its data and how they connect.
2. **What can go wrong?** Threats, found systematically rather than by whoever is most paranoid.
3. **What are we going to do about it?** A decision per threat: mitigate, eliminate, transfer or accept.
4. **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.

```text
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.

> **Interactive: Expensa: data flows and STRIDE threats.** Try the trust-boundary crossings highlight, then select flow 3: the worker fetches whatever URL the user supplies, from inside the company network. Try it: https://levelcode.org/labs/threat-model

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.

> [!TIP]
> If people disagree about the diagram ("wait, the worker writes to Postgres directly?"), stop and resolve it. A wrong diagram produces confident, wrong threat lists.

## 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 |
|---|---|---|---|
| **S**poofing | Authentication | Can someone pretend to be another user or system? | Strong login, MFA, mutual TLS, signed tokens |
| **T**ampering | Integrity | Can someone change data or code they shouldn't? | Authorization checks, input validation, signatures, immutability |
| **R**epudiation | Non-repudiation | Can someone deny an action because there's no evidence? | Tamper-evident audit logs |
| **I**nformation disclosure | Confidentiality | Can someone read data they shouldn't? | Access control, encryption, minimising data |
| **D**enial of service | Availability | Can someone stop the system working for others? | Rate limits, quotas, timeouts, size limits |
| **E**levation 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:

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

```text
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:

```text
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.
```

> [!IMPORTANT]
> Finally, answer question four: check every threat ID has a merged change or a recorded acceptance, and keep the security tests in CI so mitigations can't silently regress.

### 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 /links` with 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.

**Solution**

```text
     [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:

```text
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.

## Quiz

1. In a data-flow diagram, what is a trust boundary?

   A. The boundary of the database schema
   B. A line where the level of trust changes, so data crossing it must be validated or authenticated
   C. The edge of the diagram
   D. A firewall rule that blocks traffic

   **Answer: B.** Trust boundaries mark where data moves between parties or privilege levels with different trust, such as internet to your network or your system to a vendor. Threats cluster at these crossings.

2. An approver denies having approved a fraudulent claim, and there is no record of who approved it. Which STRIDE category is this?

   A. Spoofing
   B. Tampering
   C. Elevation of privilege
   D. Repudiation

   **Answer: D.** Repudiation threats are about being able to deny an action because there's no trustworthy evidence. The mitigation is a tamper-evident audit log.

3. The team decides to remove the "import receipt from URL" feature because uploads cover the need. Which risk response is this?

   A. Eliminate
   B. Mitigate
   C. Transfer
   D. Accept

   **Answer: A.** Removing the feature removes the risk (SSRF) entirely rather than reducing it. It's often the cheapest and strongest response when a feature isn't essential.

4. Why should mitigations be written as tickets with testable acceptance criteria?

   A. So the threat model document can be deleted
   B. Because STRIDE requires a ticket per category
   C. So they are planned, reviewed and verified like any other work, and tests stop them regressing
   D. So the security team can close them without developers

   **Answer: C.** Threats that stay in a document rarely get fixed. Tickets enter the normal workflow, and acceptance criteria turned into tests make the mitigation permanent.
