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.

17 min 9 sections

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.

InteractiveExpensa: data flows and STRIDE threatsOpen in Labs
Loading interactive…

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:

CategoryViolatesQuestion to askTypical mitigations
SpoofingAuthenticationCan someone pretend to be another user or system?Strong login, MFA, mutual TLS, signed tokens
TamperingIntegrityCan someone change data or code they shouldn't?Authorization checks, input validation, signatures, immutability
RepudiationNon-repudiationCan someone deny an action because there's no evidence?Tamper-evident audit logs
Information disclosureConfidentialityCan someone read data they shouldn't?Access control, encryption, minimising data
Denial of serviceAvailabilityCan someone stop the system working for others?Rate limits, quotas, timeouts, size limits
Elevation of privilegeAuthorizationCan 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.

IDElementSTRIDEThreatProposed mitigation
T1Flow 1, APISCredential stuffing: attacker logs in with a password leaked from another site.Company SSO with MFA.
T2APIEAn 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.
T3APIIIDOR: GET /expenses/{id} returns any claim, exposing colleagues' receipts.Ownership or approver check in the query; 404 otherwise.
T4APITEmployee edits the amount with PATCH after the claim was approved.State machine: approved claims are immutable; changes create a new claim.
T5PostgresRApprover later claims they never approved a fraudulent payout; there is no record.Append-only audit table: who, what, when, old and new values.
T6Flow 3, workerI, EReceipt 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.
T7Receipt bucketIBucket or objects are publicly readable; receipt URLs are guessable.Private bucket, random object keys, short-lived pre-signed URLs for viewing.
T8WorkerDA 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.
T9WorkerEA 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.
T10Flow 8IVendor keeps receipts (personal data) indefinitely; a leaked API key bills our account.Retention terms in the contract; key in a secret manager.
T11Flow 4TSQL 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:

IDLikelihoodImpactScoreReasoning
T6339Trivial to try; cloud credentials
T2339One request; direct fraud
T3326Easy; all staff's personal data
T5236Needed to investigate any fraud
T1236Common attack; SSO fixes it
T4224Needs an approved claim; fraud
T7133Only if misconfigured; breach
T9133Needs a library exploit
T11133ORM already used; verify search
T8212Outage delays reimbursements
T10122Contract 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.

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.

Show 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]
IDElementSTRIDEThreatMitigation
S1Preview fetcher, flow 5I, ESSRF: target http://169.254.169.254/ or internal admin pages.https only, block non-public IPs after resolution, no redirects, egress proxy.
S2Flow 1TShort links to phishing or malware abuse our trusted domain.Malicious-URL checks, rate limits, abuse takedown.
S3APIDBots create millions of links and fill the database.Per-IP rate limits, CAPTCHA after a threshold, quotas.
S4DashboardIIDOR: /links/{id}/stats shows another owner's analytics.Ownership check in the query; 404 otherwise.
S5Clicks DBIStored IPs are personal data; breach or over-retention.Truncate IPs, 30-day retention.
S6Preview outputTAttacker-controlled page title causes stored XSS in the dashboard.Output-encode titles; CSP.
S7Flow 2SSession 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.

Check yourself

Quiz

0/4 correct · 4 left

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

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

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

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