Most “threat modeling” I see in the wild is a one-off whiteboard session that produces a photo nobody looks at again. That is not threat modeling; it is theater. Done properly, threat modeling is the cheapest security control you own: it finds design flaws before a single line of code is written, when the cost of changing your mind is a sticky note rather than a re-architecture. The method below is the one I run with platform and application teams. It is deliberately mechanical so a mid-level engineer can drive it without a security PhD, and it produces artifacts you can diff, track, and re-run when the system changes.
The whole exercise hangs on four questions — the frame Adam Shostack popularized: What are we building? What can go wrong? What are we going to do about it? Did we do a good enough job? Every technique in this article slots into exactly one of those. Data-flow diagrams answer the first. STRIDE and attack trees answer the second. Risk rating and the mitigation catalog answer the third. Coverage checks and tests answer the fourth. If a “threat modeling” activity does not advance one of those four questions, it is ceremony, and you should cut it.
By the end you will be able to take an architecture you have never seen, decompose it into a diagram that is actually worth analyzing, enumerate its threats exhaustively rather than by intuition, rank them with a scheme that survives an audit, map each one to a concrete testable control, and wire the whole thing into your delivery pipeline so it stays alive. We finish with a full worked example — a three-tier web app modeled end to end, DFD to ranked threats to mitigations — because the method only clicks when you watch it grind through a real system.
What problem this solves
The economic argument is the entire case. A SQL injection caught in a design review is a one-line note: “parameterize this query.” The same flaw caught in penetration testing is a bug ticket, a code review, a redeploy, and a regression test. Caught in production it is an incident, a forensics bill, a customer-notification letter, and possibly a regulator. Studies that get quoted to death (IBM’s System Science Institute, the NIST cost-of-defect curves) all say the same directional thing: the cost of fixing a defect climbs by roughly an order of magnitude at each stage it survives — requirements, design, code, test, production. Threat modeling is the one activity that operates at the design stage, where the multiplier is smallest.
What breaks without it is subtle. Teams do not skip security entirely; they buy scanners. But a SAST or DAST tool finds implementation bugs — the injection, the missing output encoding, the hardcoded secret. It cannot find design flaws: the microservice that trusts a header any client can set, the object store whose IAM role reads every tenant’s data, the reset-password flow that lets you enumerate accounts, the queue that no one authenticates to. Roughly half of real-world security defects are design flaws, not code bugs, and no amount of scanning surfaces them because the code is doing exactly what the flawed design told it to. Threat modeling is the only widely practiced technique that catches that class.
Who hits this: everyone building anything that stores, moves, or trusts data across a boundary. It bites hardest on multi-tenant SaaS (cross-tenant isolation is a design property, not a library), on systems integrating third parties (every integration is a new trust boundary), on anything handling regulated data (PII, PCI, PHI, where a design flaw is a reportable breach), and on fast-moving teams who ship features weekly and never revisit the assumptions the original design baked in.
To frame the field before the deep dive, here is the whole method on one line per phase, mapped to Shostack’s four questions and to the artifact each produces:
| Phase | Shostack question | Technique | Artifact it produces | Lives in |
|---|---|---|---|---|
| Scope & model | What are we building? | Data-flow diagram with trust boundaries | DFD (as text/code) | Repo, next to the code |
| Enumerate | What can go wrong? | STRIDE per element + attack trees | Threat list; goal trees | threats/*.yaml, attack-trees/*.txt |
| Decide | What do we do about it? | Risk rating + response choice | Ranked, owned threats | Same YAML, status/owner fields |
| Trace | (still: what do we do) | Mitigation → control mapping | Control catalog + tests | controls/*.yaml, tests/security/ |
| Validate | Did we do well enough? | Coverage & orphan checks in CI | Green build or a failing gate | CI pipeline |
Learning objectives
By the end of this article you can:
- Run the four-question frame end to end and explain which technique answers each question and why.
- Draw a data-flow diagram with the correct four element types plus trust boundaries, choose the right decomposition level, and keep it as diffable text in version control.
- Apply STRIDE per element using the full element-to-threat grid, and generate a concrete, non-generic threat for every applicable cell.
- Build attack trees with correct AND/OR semantics, prune them by adversary feasibility, and use them to find high-leverage controls that per-element STRIDE misses.
- Rate threats with a defensible scheme — knowing why DREAD is deprecated, when CVSS fits, and why a calibrated likelihood x impact matrix is usually the right default.
- Map every threat to a control from a STRIDE mitigation catalog, choose one of the four risk responses explicitly, and make “accept” auditable.
- Embed threat modeling into Agile and DevSecOps — story-level vs system-level, when to re-model, and how to keep a model alive instead of letting it rot.
- Drive real tooling: OWASP Threat Dragon, the Microsoft Threat Modeling Tool, and threat-model-as-code with pytm and Threagile.
Prerequisites & where this fits
You should be comfortable reading an architecture diagram, know the basic security properties (authentication, authorization, confidentiality, integrity, availability, non-repudiation), and have deployed at least one real system with a database, an API, and some external dependency. Familiarity with cloud primitives — IAM roles, object storage, load balancers, queues, secrets managers — makes the examples land, since most of them are drawn from AWS/Azure/GCP designs. No prior security-team experience is assumed; the entire point of the method is that a developer can run it.
This sits at the front of the secure-SDLC. Threat modeling is upstream of everything: it defines the security requirements that your code, your tests, and your scanners then enforce. It pairs directly with Zero Trust Architecture Blueprint: Identity, Network, and Data Pillars, which supplies the control patterns your mitigations reach for, and with Shift-Left Testing and Quality Gates in CI/CD, which is the pipeline machinery that keeps the model honest. When a threat you find turns real, Engineering Incident Response: Runbooks, Tabletop Exercises, and Cloud Forensics is the downstream discipline that handles it. Where threat modeling ends — a ranked list of design risks — is exactly where your control catalog and your test suite begin.
Here is who typically owns what during a modeling session, so you invite the right people and do not stall waiting on the wrong one:
| Role | What they bring to the model | Without them you miss |
|---|---|---|
| Application engineer | How the code actually flows, real dependencies | Hidden flows, undocumented integrations |
| Architect / tech lead | Trust boundaries, intended design | The “why” behind a boundary; over-modeling |
| Security engineer | Threat intuition, control catalog, ratings | Realistic attacker paths; consistent scoring |
| Product owner | Data classification, business impact | Correct impact ratings; which threats to accept |
| SRE / platform | Deployment topology, IAM, network reality | Infra-level threats (SSRF, over-broad roles) |
| QA / test | What is testable, existing coverage | Whether a control is actually verified |
Core concepts
Six ideas carry the entire method. Get these and the rest is mechanics.
Threat modeling is structured anticipation, not prediction. You are not guessing which CVE will drop next year. You are reasoning about your own design’s assumptions — every place you decided to trust something — and asking what happens when that trust is misplaced. The output is a list of things that could go wrong given how you built it, ranked so you fix the ones that matter.
A threat is not a vulnerability, and neither is a risk. A threat is a potential negative event (“an attacker reads another tenant’s invoices”). A vulnerability is the specific weakness that makes the threat possible (“the object key is client-supplied and unvalidated”). A risk is the threat weighted by likelihood and impact (“high — trivially exploitable, exposes regulated PII”). A control (or mitigation) is what you add to reduce the risk (“derive the key prefix server-side from the token”). Keeping these four words distinct stops most of the confused arguments in a modeling session.
STRIDE is a mnemonic that makes enumeration exhaustive by construction. Each letter is the negation of one security property, so walking all six against an element guarantees you considered every property that can be violated. It was invented at Microsoft in 1999 by Loren Kohnfelder and Praerit Garg, and it is the closest thing the field has to a checklist that does not miss whole categories:
| STRIDE | Threat | Security property it violates | One-line example |
|---|---|---|---|
| S | Spoofing | Authentication | Forge a JWT and act as another user |
| T | Tampering | Integrity | Modify a price field in transit |
| R | Repudiation | Non-repudiation | Delete an action with no audit trail |
| I | Information disclosure | Confidentiality | Read another tenant’s data via IDOR |
| D | Denial of service | Availability | Zip-bomb the file converter |
| E | Elevation of privilege | Authorization | Escape a sandbox to run as root |
A data-flow diagram is the substrate everything runs on. You cannot enumerate threats against a system you cannot see. The DFD is a deliberately impoverished picture — four element types and a boundary line — because the constraint forces you to represent how data moves rather than how the code is organized. Threats live on the diagram, specifically on the boundaries.
Trust boundaries are where the threats cluster. A trust boundary is any line across which the privilege or trust level changes: internet to your edge, DMZ to app tier, app to database, tenant A to tenant B, your process to a third-party API. An attacker’s input meeting your assumptions happens at these lines. If you only had time to model the boundary crossings and nothing else, you would still catch most of the serious flaws.
The model must be an asset, not an event. The single biggest failure mode is a beautiful model produced once and never touched again. A model that lives in the repo as text, gets diffed in pull requests, and is gated in CI is worth ten whiteboard photos. Everything below is designed to keep the model diffable and re-runnable.
Pin the vocabulary side by side before the deep sections — the glossary repeats these for lookup:
| Term | One-line definition | Why it matters |
|---|---|---|
| Asset | Something worth protecting (data, function, reputation) | Threats target assets; impact is measured in asset terms |
| Threat | A potential negative event against an asset | The thing STRIDE enumerates |
| Vulnerability | A weakness that enables a threat | What a pen test finds; what you patch |
| Attack / exploit | The act of realizing a threat via a vulnerability | What the attack tree’s leaves describe |
| Trust boundary | A line where trust/privilege level changes | Where threats concentrate |
| Control / mitigation | A measure that reduces a risk | What every open threat must trace to |
| Risk | Threat weighted by likelihood x impact | What you rank and prioritize by |
| Attack surface | The sum of points an attacker can reach | Shrinking it eliminates threats outright |
| DFD | Data-flow diagram: elements + flows + boundaries | The model you analyze |
| Actor / entity | A person or system outside your control | A source of untrusted input |
Drawing a data-flow diagram worth modeling
The DFD uses exactly four element types, and the discipline is to use only these. Anything you want to draw is one of them:
| Element | Classic shape | Meaning | Examples |
|---|---|---|---|
| External entity | Rectangle | An actor or system outside your control | End user, admin, third-party API, another team’s service, a mobile app |
| Process | Circle | Code that transforms data | A microservice, a Lambda, a cron job, a browser SPA, an nginx that does auth |
| Data store | Two parallel lines | Where data rests | RDBMS, S3 bucket, Redis, a queue, a log, a config file |
| Data flow | Directed arrow | Data in motion between the above | An HTTPS request, a SQL query, a Kafka message, a file read |
The fifth thing on the page — and the most important — is the trust boundary, drawn as a dashed line that cuts across flows. When a flow crosses a boundary, you are handing data from one trust level to another, and that is where you owe an analysis. Common boundaries you should always look for:
| Trust boundary | Where it appears | The trust that changes | Threats it invites |
|---|---|---|---|
| Internet edge | Client to your WAF/LB/CDN | Anonymous public to your perimeter | Spoofing, DoS, injection |
| Process boundary | One service to another | One codebase’s assumptions to another’s | Tampering, spoofing (unauth internal calls) |
| Machine/network boundary | App subnet to data subnet | Compute tier to data tier | Lateral movement, info disclosure |
| Tenant boundary | Tenant A’s data to tenant B | One customer to another | IDOR, cross-tenant disclosure (I, E) |
| Third-party boundary | Your app to Stripe/Twilio/etc. | Your control to a vendor’s | Repudiation, tampering, supply-chain |
| Privilege boundary | User context to admin/root | Low privilege to high | Elevation of privilege |
| Kernel/sandbox boundary | Container to host | Sandboxed to unsandboxed | E (container escape), RCE blast radius |
DFDs come in levels, and knowing when to stop drilling is the skill that separates a useful model from a two-week time sink:
| Level | Name | What it shows | When it is enough |
|---|---|---|---|
| Level 0 | Context diagram | One process (the whole system), external entities, major data stores | Always start here; often enough for a small service |
| Level 1 | High-level | The system decomposed into its major processes and stores | The default working level for most systems |
| Level 2 | Detailed | One process expanded into its sub-processes | Only where new threats emerge on drill-down |
| Level 3+ | Low-level | Function-level detail | Almost never; you are over-modeling |
The rule that saves weeks: decompose only until the threats stop changing. If expanding a process reveals no new trust boundary and no new class of threat, you have gone too deep. Conversely, if a “simple” process actually talks to three data stores across two boundaries, it deserves a Level 2.
Keep the DFD as text so it diffs. A picture in a wiki rots; a text model in the repo gets reviewed in pull requests like code. I use pytm (Python) or a Threat Dragon JSON model; here is a minimal DFD-as-code for a document-upload service that we will reuse:
# tm_upload.py — run: python tm_upload.py --dfd | dot -Tpng -o dfd.png
from pytm import TM, Server, Datastore, Actor, Boundary, Dataflow
tm = TM("Document Upload Service")
internet = Boundary("Internet")
vpc = Boundary("VPC / private")
user = Actor("Browser user")
waf = Server("CDN / WAF"); waf.inBoundary = internet
api = Server("Upload API"); api.inBoundary = vpc
conv = Server("Converter (untrusted input)"); conv.inBoundary = vpc
s3 = Datastore("Object store (S3)"); s3.inBoundary = vpc
db = Datastore("Metadata DB"); db.inBoundary = vpc
q = Datastore("Convert queue"); q.inBoundary = vpc
Dataflow(user, waf, "HTTPS multipart upload")
Dataflow(waf, api, "Forwarded request + auth token")
Dataflow(api, s3, "PUT object (SSE-KMS)")
Dataflow(api, db, "INSERT metadata")
Dataflow(api, q, "Enqueue {objectKey}")
Dataflow(q, conv, "Dequeue job")
Dataflow(conv, s3, "GET object / PUT rendered")
Dataflow(conv, db, "UPDATE status")
tm.process()
Two labeling rules that pay off later. First, every flow that crosses a trust boundary carries a label describing protocol, authentication, and data sensitivity — HTTPS + OIDC token, PII not just an arrow. Unlabeled crossings are where teams hand-wave, and hand-waving is where breaches hide. Second, name your data. “Data” on an arrow tells you nothing; “customer PAN (card number)” tells you the impact rating before you even run STRIDE.
STRIDE per element: the complete grid
STRIDE’s superpower is applied through STRIDE-per-element: walk every element in the DFD, and for each, ask which STRIDE categories can apply to that element type. Not every threat applies to every element — an external entity you do not control cannot suffer your denial of service in a way you model, a data flow cannot itself be spoofed. Microsoft’s canonical mapping is the grid you start from:
| Element type | S | T | R | I | D | E |
|---|---|---|---|---|---|---|
| External entity (actor) | yes | — | yes | — | — | — |
| Process | yes | yes | yes | yes | yes | yes |
| Data store | — | yes | yes* | yes | yes | — |
| Data flow | — | yes | — | yes | yes | — |
(*Repudiation against a data store applies specifically when it is a log or audit store — someone tampering with the evidence.)
Read the grid as a worklist, not a truth. A process is the richest target: all six apply, because code authenticates callers (S), can be fed bad input (T), records actions ®, holds secrets (I), can be exhausted (D), and runs with privilege (E). A data flow is narrower: it can be tampered or read in transit (T, I) or flooded (D), but it does not authenticate and does not run code. The discipline is to walk each element and, for every applicable cell, either write a concrete threat or explicitly mark it “N/A because…”. An empty cell you reasoned about is worlds apart from one you never saw.
The value is in the concreteness. “Spoofing on the API” is useless. Here is the per-cell catalog I use to force specificity — element type, STRIDE letter, a concrete threat phrased as an attacker would, and the control family it points to:
| Element | STRIDE | Concrete threat (say it like an attacker) | Control family |
|---|---|---|---|
| External entity | S | “I phish a user’s credentials and log in as them” | Strong auth, MFA, phishing-resistant creds |
| External entity | R | “I place an order then deny I ever did” | Signed receipts, audit log of principal + action |
| Process | S | “I forge/replay a token and the service accepts it” | Validate signature, audience, expiry; mTLS |
| Process | T | “I send ../ in a path and write outside my prefix” |
Input validation, canonicalization, allow-lists |
| Process | R | “I trigger an admin action that leaves no trace” | Structured audit logging with actor identity |
| Process | I | “I request another tenant’s ID and read their data” | Server-side authorization on every object |
| Process | D | “I send a 10 GB body and pin the worker’s CPU” | Size limits, timeouts, rate limits, quotas |
| Process | E | “A crafted file triggers RCE and I run as the role” | Sandboxing, least-privilege role, drop root |
| Data store | T | “I edit rows directly via leaked DB creds” | Least-privilege DB users, integrity checks |
| Data store | R | “I delete log entries to cover the intrusion” | Append-only/WORM logs, off-box shipping |
| Data store | I | “I read an unencrypted backup from the bucket” | Encryption at rest, bucket policy, KMS |
| Data store | D | “I fill the disk/table to take the store down” | Quotas, autoscaling storage, throttling |
| Data flow | T | “I MITM the internal call and change the amount” | TLS everywhere, HMAC/signing, mutual TLS |
| Data flow | I | “I sniff an unencrypted internal flow for tokens” | Encrypt in transit even inside the VPC |
| Data flow | D | “I flood the flow/connection pool to starve it” | Connection limits, backpressure, circuit breakers |
Two refinements worth knowing. STRIDE-per-interaction is the alternative the newer Microsoft tooling favors: instead of walking elements, you walk each (source, flow, destination) tuple and ask STRIDE of the interaction. It generates more threats and fewer false "N/A"s, at the cost of a longer list — good for high-assurance systems, overkill for a small service. And LINDDUN is STRIDE’s privacy-focused sibling (Linkability, Identifiability, Non-repudiation, Detectability, Disclosure, Unawareness, Non-compliance) — reach for it when the system’s main risk is privacy harm rather than classic security, or run both.
Capture threats in a structured, diffable format that lives in the same pull request as the code:
# threats/upload-api.yaml
- id: TM-UPLOAD-007
element: "Upload API (process)"
stride: I # Information disclosure
interaction: "Upload API -> Metadata DB"
title: "IDOR allows cross-tenant metadata read"
description: >
objectKey is client-supplied and used directly in the metadata
lookup. A user can request another tenant's key and read status,
filename, and size.
likelihood: high # trivially reproducible, no special access
impact: high # exposes regulated PII across tenants
risk: critical
response: mitigate # mitigate | eliminate | transfer | accept
status: open # open | mitigated | accepted | transferred
mitigation_ref: CTRL-AUTHZ-OBJ-SCOPE
owner: platform-security
Attack trees: reasoning from the adversary’s goal
STRIDE enumerates bottom-up, element by element. Attack trees reason top-down from an adversary’s goal, and the two are complementary: STRIDE finds the threats, attack trees show you the paths to a goal and therefore where one control breaks many paths at once. Reach for an attack tree when a threat is serious enough that you need to understand the full set of ways to reach it — usually your top three or four risks, not all sixty.
The structure is a tree of goals. The root is the attacker’s objective. Each node is refined into children joined by boolean logic:
| Node type | Semantics | How to read it | How to defend |
|---|---|---|---|
| OR node | Any one child suffices | Attacker picks the easiest path | You must block every child |
| AND node | All children required together | Attacker needs the whole set | Break any one child to stop the path |
| Leaf | A concrete, atomic action | The thing you actually mitigate | Attach a control here |
That asymmetry is the whole point. Under an OR, the defender must cover all branches — the attacker only needs one. Under an AND, the defender is lucky: killing a single child collapses the branch. So when you build the tree, you are hunting for AND nodes where one cheap control breaks the chain. Here is the upload service’s top goal as a text tree (text, because it diffs and needs no tool):
GOAL: Exfiltrate another tenant's documents
|
+-- OR 1. Read objects directly from the store
| +-- AND
| +-- 1a. Obtain the object key
| | (OR: IDOR on API; guess UUID; find it in a leaked log)
| +-- 1b. Bypass store authorization
| (OR: over-permissive bucket policy; stolen IAM creds;
| SSRF to the instance metadata service)
|
+-- OR 2. Abuse the converter
| +-- AND
| +-- 2a. Upload a malicious file that triggers RCE in the converter
| +-- 2b. Converter IAM role can read all tenants' objects
|
+-- OR 3. Compromise the metadata DB
+-- OR
+-- 3a. SQL injection on a query path
+-- 3b. Stolen DB credential from env / secrets store
Reading it pays off immediately. Notice that “converter IAM role can read all tenants’ objects” (2b) is an AND-child on path 2 — and it is also, in effect, the enabling condition behind the “bypass store authorization” branch of path 1. Scoping that role to a single per-job tenant prefix breaks the AND on path 2 and removes a major OR-child on path 1. That is a single high-leverage control the per-element STRIDE pass would not have highlighted, because STRIDE looks at one element at a time while the tree shows you the chain.
The second job of the tree is pruning by feasibility. Not every leaf is worth defending equally; annotate each with the attacker cost, skill, and detectability, then prune paths that are absurd for your threat model:
| Attribute | Cheap / easy (prioritize) | Expensive / hard (deprioritize) |
|---|---|---|
| Cost | Free (a curl loop) | Requires buying a 0-day |
| Skill | Script-kiddie / OWASP Top 10 | Nation-state cryptanalysis |
| Access needed | Anonymous internet | Physical datacenter access |
| Time | Seconds, automatable | Months of patient effort |
| Detectability | Silent, no logs tripped | Trips every alert you have |
You can carry a single value per leaf — the classic is cheapest cost to achieve, propagated up the tree (an OR node takes the minimum of its children, an AND node the sum). The cost at the root is your cheapest total attack path, and the leaf that dominates it is where your next control belongs. For most commercial systems you prune anything that needs a 0-day or physical access and focus on the internet-reachable, low-skill, low-cost leaves — because that is who actually shows up. A model that spends its energy defending against a nation-state while leaving an IDOR open has its priorities inverted.
Rating and prioritizing threats
A list of sixty threats with no priority is noise. You need a consistent score so the riskiest items rise and the team fixes them in the right order. Three schemes dominate, and choosing well matters.
DREAD, and why it is deprecated
DREAD scores each threat 1–3 (or 1–10) across five dimensions and averages them:
| DREAD dimension | The question | The problem with it |
|---|---|---|
| Damage | How bad if exploited? | The one dimension that is genuinely useful |
| Reproducibility | How reliably can it be repeated? | Overlaps heavily with Exploitability |
| Exploitability | How hard to pull off? | Subjective; varies wildly by rater |
| Affected users | How many are hit? | Often just a proxy for Damage |
| Discoverability | How easy to find the flaw? | Rewards security by obscurity |
Microsoft themselves deprecated DREAD because it fails in practice. The averaging hides high-damage/low-everything-else threats (a catastrophic but “hard to find” flaw scores medium). The dimensions overlap and are not independent. And the Discoverability axis actively encourages the worst instinct in security — assuming attackers will not find something because it is hidden. If you inherit a DREAD process, at minimum drop Discoverability and pin down a written rubric so “Damage = 3” means the same thing to every rater. But prefer to move off it.
CVSS, and where it fits
The Common Vulnerability Scoring System is the industry standard for scoring known vulnerabilities (it is what every CVE carries). CVSS 4.0 (released late 2023) computes a 0–10 base score from metrics that describe the vulnerability’s intrinsic character:
| CVSS 4.0 base metric | Values | What it captures |
|---|---|---|
| Attack Vector (AV) | Network / Adjacent / Local / Physical | How remote the attacker can be |
| Attack Complexity (AC) | Low / High | Conditions outside attacker control |
| Attack Requirements (AT) | None / Present | Preconditions the attack needs |
| Privileges Required (PR) | None / Low / High | Access the attacker must already have |
| User Interaction (UI) | None / Passive / Active | Whether a victim must act |
| Vuln. Confidentiality/Integrity/Availability (VC/VI/VA) | High / Low / None | Impact on the vulnerable system |
| Subsequent Conf./Integrity/Avail. (SC/SI/SA) | High / Low / None | Impact beyond it (replaces old “Scope”) |
CVSS is precise and comparable across organizations, which is exactly why it is the wrong tool for design-stage threat modeling. It scores a concrete vulnerability with a known exploit path; a threat is a potential flaw you have not built yet. Use CVSS when a threat model finding maps to a real CVE in a dependency, or to communicate severity to teams that already speak CVSS. Do not contort your abstract design threats into a CVSS vector — you will invent the metric values, and invented precision is worse than honest coarseness.
Likelihood x impact — the default
For design threats, a calibrated likelihood x impact matrix is almost always the right choice. It speaks the language your risk register and your leadership already use, it avoids DREAD’s pseudo-precision, and it forces the two questions that actually matter — how easy is this, and how bad is it:
| Likelihood \ Impact | Low | Medium | High |
|---|---|---|---|
| High | Medium | High | Critical |
| Medium | Low | Medium | High |
| Low | Low | Low | Medium |
The trick that makes it defensible is anchoring each axis with a written rubric so two engineers rate the same threat the same way:
| Rating | Likelihood anchor | Impact anchor |
|---|---|---|
| High | Anonymous internet, automatable, no special skill | Regulated data breach, funds loss, full compromise |
| Medium | Needs some access or moderate skill | Single-tenant exposure, degraded service |
| Low | Needs insider access or a chained 0-day | Minor info leak, cosmetic, self-DoS only |
Whichever scheme you pick, three rules hold: be consistent within a model, record the rationale next to the score, and remember the score is an input to a decision, not the decision. Here is how the three compare so you can choose deliberately:
| Scheme | Best for | Strength | Weakness |
|---|---|---|---|
| DREAD | (legacy only) | Simple to teach | Subjective; deprecated; obscurity bias |
| CVSS 4.0 | Known CVEs, dependency findings | Standard, comparable, precise | Wrong altitude for design threats |
| Likelihood x impact | Design-stage threats (default) | Business-aligned, fast, defensible | Only as good as your rubric |
From threats to mitigations
This is where threat modeling earns its keep — a threat you rate but never fix is just anxiety. Every rated threat gets exactly one of four responses, chosen explicitly:
| Response | What it means | When to use it | The catch |
|---|---|---|---|
| Mitigate | Add a control that cuts likelihood or impact | The default for High/Critical | Costs engineering effort; must be tested |
| Eliminate | Remove the feature/flow that creates the threat | When the risk outweighs the feature | Sometimes the feature is the point |
| Transfer | Push the risk elsewhere (managed service, insurance, contract) | When someone else does it better/cheaper | You still own the residual and the vendor risk |
| Accept | Sign off and live with it | Low risk, or cost of fix > cost of loss | Requires a named owner and an expiry date |
“Accept” without a name and a date is just “ignore,” and it is how findings quietly die. Make acceptance auditable: a person, a reason, and a review date, recorded in the same YAML as the threat.
When you choose mitigate, STRIDE hands you the control family for free — each letter maps to a well-known class of defense. This is the mitigation catalog, the single most useful reference in the whole practice:
| STRIDE | Property to restore | Mitigation family | Concrete controls (cloud reality) |
|---|---|---|---|
| S Spoofing | Authentication | Prove identity | MFA, phishing-resistant passkeys, OIDC/SAML, mTLS, signed tokens with audience+expiry checks, managed identities |
| T Tampering | Integrity | Detect/prevent change | TLS in transit, HMAC/digital signatures, hashes, input validation, WORM storage, DB constraints, least-privilege writes |
| R Repudiation | Non-repudiation | Prove who did what | Structured audit logs with actor identity, append-only/immutable logs shipped off-box, signed receipts, timestamps |
| I Info disclosure | Confidentiality | Keep secrets secret | Encryption at rest (KMS) and in transit, least-privilege authz on every object, tokenization/masking, private networking |
| D Denial of service | Availability | Keep it up | Rate limiting, quotas, size/timeout limits, autoscaling, CDN/edge caching, WAF, circuit breakers, backpressure |
| E Elevation of privilege | Authorization | Contain privilege | RBAC/ABAC, least-privilege IAM roles, input validation, sandboxing, drop root, network segmentation, JIT access |
The output is traceability: keep a control catalog and reference each control by ID so one control can cover many threats, and every threat points back to a testable assertion:
# controls/catalog.yaml
- id: CTRL-AUTHZ-OBJ-SCOPE
title: "Per-tenant object authorization on every store access"
detail: >
Resolve the caller's tenant from the validated token, derive the
allowed key prefix server-side, and reject any objectKey outside it.
Never trust a client-supplied key for authorization.
verifies: [TM-UPLOAD-007]
test: "tests/security/test_cross_tenant.py::test_idor_returns_403"
owner: platform-security
status: implemented
For the upload service, the STRIDE pass and the attack tree converge on a short, high-value control set — and the scoped-role control is worth showing in real IaC, because “least privilege” is a slogan until it is an IAM condition. The policy lets the converter role touch only keys under its job’s tenant prefix, resolved from a principal tag:
data "aws_iam_policy_document" "converter_scoped" {
statement {
sid = "ReadWriteTenantPrefixOnly"
effect = "Allow"
actions = ["s3:GetObject", "s3:PutObject"]
# $${aws:PrincipalTag/tenant} is resolved at request time from the
# session tag set per job -- the role cannot read another prefix.
resources = ["${aws_s3_bucket.docs.arn}/tenants/$${aws:PrincipalTag/tenant}/*"]
}
}
The point is the chain: open the threat, follow mitigation_ref to the control, follow the control’s test to the assertion that proves it still works. No orphan threats, no orphan controls.
Threat modeling in Agile and DevSecOps
The classic objection is “we ship every week, we cannot do a two-day modeling workshop per feature.” Correct — so you do not. You right-size the model to the change, and you split system-level from story-level work:
| Cadence | Scope | Effort | Who runs it | Trigger |
|---|---|---|---|---|
| System-level model | The whole service / bounded context | Half a day to two days | Architect + security + team | New system; major re-architecture; annual refresh |
| Feature/epic model | One new capability that adds flows | 1–2 hours | The feature team | New trust boundary or data class in an epic |
| Story-level check | One user story | 5–15 min, in refinement | The developer | Every story, as a checklist |
| Delta model | An incremental change to an existing model | 15–30 min | Whoever makes the change | Any PR that changes a flow or boundary |
The story-level check is the one that scales, because it is lightweight enough to survive contact with a sprint. Bake it into your “definition of ready” or “definition of done” as a handful of questions the developer answers themselves:
| Story-level question | If “yes”, do this |
|---|---|
| Does this story add a new data flow across a trust boundary? | Add it to the DFD; run STRIDE on the crossing |
| Does it handle a new class of data (PII, secrets, money)? | Re-check confidentiality + integrity threats |
| Does it add authentication or authorization logic? | Model spoofing + elevation explicitly |
| Does it call a new external service? | New third-party boundary; model R + T |
| Does it accept file uploads or untrusted input? | Model tampering + DoS + E (RCE) |
| None of the above? | No model needed; note “no security-relevant change” |
The decisive move is keeping the model alive by making it code, not a document. Wire three gates:
- Design-review gate. Every RFC for a system that crosses a trust boundary must link a threat model before approval — a checklist item in the RFC template, not an honor system.
- PR gate for the model itself. The threat YAML and DFD live in the repo. A CI check fails the build if a threat is
openwith nomitigation_ref, or a control references a threat ID that does not exist:
# .github/workflows/threat-model.yml
name: threat-model-lint
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate threat models
run: |
pip install pyyaml
python scripts/check_threats.py # fails on open threats w/o mitigation_ref
python scripts/check_coverage.py --dfd tm_upload.py --threats threats/
- Right-sized cadence enforced by the trigger list. A full pass for greenfield; a delta model for incremental change. Calibrate effort to the size of the architectural change, or teams route around the process — and a process people route around is worse than none, because it produces false assurance.
This is the same shift-left philosophy that governs testing; if your pipeline already runs Shift-Left Testing and Quality Gates in CI/CD, the threat-model gate slots in beside your existing quality gates rather than as a separate ceremony.
Tooling: from whiteboard to threat-model-as-code
You can threat-model with a whiteboard and a text file, and for a first pass you should — the tool is never the point. But tooling helps at scale, and the field now has genuinely good options across the spectrum from GUI to code:
| Tool | Type | Model format | STRIDE support | Cost | Best for |
|---|---|---|---|---|---|
| OWASP Threat Dragon | Web + desktop (Electron) | JSON (diffable) | STRIDE + LINDDUN | Free, OSS | Teams wanting a GUI whose model still lives in git |
| Microsoft Threat Modeling Tool | Windows desktop | .tm7 (XML) |
Auto-generates STRIDE from DFD | Free | Azure shops; auto-suggested threats |
| pytm | Python library | Python code | STRIDE via threat library | Free, OSS | Threat-model-as-code; CI integration |
| Threagile | Go, YAML-driven | YAML model | Rule-based risk engine | Free, OSS | Agile-as-code; rich auto-generated risk report |
| IriusRisk | Commercial SaaS | Proprietary + API | Extensive, questionnaire-driven | Paid | Enterprises needing governance, scale, compliance mapping |
The two worth knowing hands-on are the free auto-generators. Microsoft’s Threat Modeling Tool ingests a DFD you draw and generates candidate STRIDE threats per interaction, which is a fast way to avoid missing a category — though it over-generates and you must prune. OWASP Threat Dragon does the same in-browser, stores the model as JSON you commit, and supports both STRIDE and privacy-focused LINDDUN.
For engineering-led teams, threat-model-as-code is the endgame. pytm (by Izar Tarandach) lets you define the model in Python and generate a data-flow diagram, a sequence diagram, and a findings report from a built-in threat library:
pip install pytm
python tm_upload.py --dfd | dot -Tpng -o dfd.png # data-flow diagram
python tm_upload.py --seq | java -jar plantuml.jar -pipe > seq.png
python tm_upload.py --report docs/report.md # threats + findings
Threagile takes a YAML model and produces a full risk report (PDF, Excel, JSON) plus a generated DFD, running its risk rules over your described architecture — ideal when you want the whole thing to run in a container in CI:
# Threagile runs from a single YAML model and emits a risk report
docker run --rm -v "$PWD:/app/work" threagile/threagile \
-verbose -model /app/work/threagile-model.yaml -output /app/work/out
# out/ now holds risks.xlsx, report.pdf, data-flow-diagram.png, risks.json
The right progression for most teams: start on a whiteboard to learn the muscle, move the model into Threat Dragon or a text file so it diffs, and adopt pytm/Threagile once threat modeling is a routine part of your pipeline rather than an event. Do not start with the heaviest tool — a beautiful IriusRisk instance that nobody updates is the same failure as a whiteboard photo, just more expensive.
Worked example: a three-tier web app, end to end
Enough theory. Model OrderFlow, a fictional but realistic multi-tenant e-commerce order service, from blank page to ranked, mitigated threats. This is the pattern you will reuse on your own systems.
Question 1 — what are we building?
OrderFlow is a classic three-tier web app on AWS. Customers browse and place orders; an admin console manages catalog and refunds; payments go to Stripe. The Level 1 DFD elements:
| # | Element | Type | Trust zone |
|---|---|---|---|
| E1 | Customer (browser SPA) | External entity | Internet |
| E2 | Admin user | External entity | Internet (privileged) |
| E3 | Stripe payment API | External entity | Third-party |
| P1 | ALB + WAF | Process | Edge (public subnet) |
| P2 | Order API (ECS service) | Process | App subnet |
| P3 | Auth service (OIDC) | Process | App subnet |
| P4 | Fulfilment worker | Process | App subnet |
| D1 | Orders DB (RDS PostgreSQL) | Data store | Data subnet |
| D2 | Session cache (Redis) | Data store | Data subnet |
| D3 | Invoice store (S3) | Data store | Data subnet |
| D4 | Audit log (CloudWatch/S3) | Data store | Data subnet |
The trust boundaries: Internet → Edge (at the ALB/WAF), Edge → App (public to private subnet), App → Data (app to data subnet), and App → Stripe (your control to a vendor). The flows: customer → WAF → Order API (HTTPS + OIDC token, contains order + PII); Order API → Auth service (validate token); Order API → Orders DB (SQL, PII + order); Order API → Redis (session); Order API → Stripe (HTTPS, card token — never raw PAN); Order API → SQS → Fulfilment worker; worker → S3 (invoice PDF); all processes → Audit log.
Question 2 — what can go wrong? (the STRIDE pass)
Walk each element against its applicable STRIDE cells. Here is the abbreviated pass — one representative threat per high-value cell, which in a real model would be the full grid:
| ID | Element | STRIDE | Threat | L | I |
|---|---|---|---|---|---|
| OF-01 | E1 Customer | S | Stolen session/JWT lets attacker act as a customer | M | H |
| OF-02 | E2 Admin | S | Phished admin creds grant refund + catalog control | M | H |
| OF-03 | P2 Order API | S | Missing token audience check accepts tokens from another app | L | H |
| OF-04 | P2 Order API | T | Mass-assignment lets client set price or tenantId in the body |
H | H |
| OF-05 | P2 Order API | I | IDOR: GET /orders/{id} returns another tenant’s order |
H | H |
| OF-06 | P2 Order API | D | Unbounded search query exhausts DB connections | M | M |
| OF-07 | P2 Order API | E | SSRF in an image-URL field reaches the instance metadata service | M | H |
| OF-08 | P3 Auth service | S | JWT signed with a weak/none alg is accepted |
L | H |
| OF-09 | P4 Worker | E | Malicious PDF triggers RCE in the render library | L | H |
| OF-10 | Flow API→DB | I | Unencrypted DB connection sniffed inside the VPC | L | H |
| OF-11 | D1 Orders DB | I | Unencrypted RDS snapshot shared/leaked | L | H |
| OF-12 | D3 Invoice S3 | I | Public/over-broad bucket policy exposes invoices | M | H |
| OF-13 | D4 Audit log | R | App role can delete audit entries to hide activity | L | H |
| OF-14 | Flow API→Stripe | T | Webhook from “Stripe” is spoofed; signature unchecked | M | H |
| OF-15 | E3 Stripe | R | Customer disputes a charge; no signed proof retained | M | M |
Rank
Apply the likelihood x impact matrix. The ones that rise to Critical/High and get worked first:
| ID | L x I | Risk | Why it is top |
|---|---|---|---|
| OF-05 | H x H | Critical | IDOR is trivially automatable; cross-tenant PII = reportable breach |
| OF-04 | H x H | Critical | Mass-assignment on price/tenantId is silent fraud + tenant escape |
| OF-07 | M x H | High | SSRF → metadata → IAM creds is a full-compromise chain |
| OF-02 | M x H | High | Admin takeover controls money and catalog |
| OF-12 | M x H | High | One bucket misconfig exposes every invoice |
| OF-14 | M x H | High | Spoofed webhook can mark unpaid orders paid |
| OF-09 | L x H | Medium | Serious impact but needs a render-lib exploit |
Question 3 — what do we do about it? (threats → controls)
Each top threat gets a response and a control ID. Notice several threats share one control — that is the leverage attack trees promised:
| ID | Response | Control | What it is |
|---|---|---|---|
| OF-05 | Mitigate | CTRL-AUTHZ-ROW | Row-level tenant check on every query; derive tenant from token, never from the request |
| OF-04 | Mitigate | CTRL-INPUT-ALLOWLIST | Explicit DTO allow-list; server sets price/tenantId, ignores client values |
| OF-07 | Mitigate | CTRL-EGRESS-METADATA | Block egress to 169.254.169.254 + IMDSv2 required; allow-list outbound hosts |
| OF-02 | Mitigate | CTRL-ADMIN-MFA | Phishing-resistant MFA + PIM/JIT for admin actions |
| OF-12 | Mitigate | CTRL-S3-PRIVATE | Block Public Access on; bucket policy least-privilege; access via presigned URLs |
| OF-14 | Mitigate | CTRL-WEBHOOK-SIG | Verify Stripe webhook signature + timestamp before trusting it |
| OF-09 | Transfer + Mitigate | CTRL-SANDBOX-RENDER | Render in a no-egress sandbox with a scoped role (blast radius = one job) |
| OF-13 | Mitigate | CTRL-WORM-AUDIT | Append-only audit log shipped off-box; app role cannot delete |
| OF-11 | Mitigate | CTRL-ENCRYPT-REST | KMS encryption on RDS + snapshots; snapshots not shared |
| OF-10 | Mitigate | CTRL-TLS-INTERNAL | TLS on the DB connection even inside the VPC |
Question 4 — did we do well enough?
Coverage check: every trust-boundary crossing produced at least one threat (the Internet→Edge, Edge→App, App→Data, App→Stripe crossings all have entries). Every Critical/High threat has a control with a test. Every AND node in the exfiltration attack tree is broken by at least one control. That is a model you can sign off — and re-run when OrderFlow adds its next feature.
Architecture at a glance
Picture the OrderFlow model as three horizontal bands separated by dashed trust-boundary lines, with data flowing top to bottom and the audit log running down the right margin catching everything.
The top band is the internet — two external entities, the customer’s browser SPA and the privileged admin console, both anonymous and untrusted until proven otherwise. They meet your system at a single dashed line: the Internet → Edge boundary, drawn across the flow into the ALB + WAF. That first line is where spoofing, injection, and volumetric DoS threats concentrate, which is why the WAF, TLS termination, and rate limiting all sit exactly on it.
The middle band is the app tier inside a private subnet, behind a second dashed line — the Edge → App boundary. Here live the three processes: the Order API that does the real work, the Auth service that validates every OIDC token crossing in, and the Fulfilment worker that consumes the queue. This band is where the six-way STRIDE richness applies, because these are processes: they authenticate callers (spoofing), accept input (tampering, SSRF-driven elevation), hold logic that can leak (disclosure), can be exhausted (DoS), and run with IAM roles worth stealing (elevation). To the right, a flow leaves the band across the App → Stripe third-party boundary — the only place your data crosses into a vendor’s control, and therefore where webhook-signature verification and non-repudiation live.
The bottom band is the data tier in its own subnet, behind the third dashed line — the App → Data boundary. Four data stores rest here: the Orders DB (PII, the crown jewels), the Redis session cache, the S3 invoice store, and the append-only audit log. Data stores get the narrow STRIDE set — tampering, disclosure, DoS, and for the audit store, repudiation — which is why the controls clustered here are encryption at rest, least-privilege database and bucket policies, and immutability on the log. Trace any customer request straight down through the three bands and you have walked every trust boundary in order; that top-to-bottom path is the threat model, and each dashed line you cross is a place you owed an analysis and paid it.
Real-world scenario
A fintech platform team I worked with ran a multi-tenant document service almost identical to OrderFlow’s invoice path — it processed KYC documents (passports, proof-of-address) for dozens of business customers. The original design used a Lambda converter running headless LibreOffice to render uploaded .docx/.xlsx files to PDF, with a single execution role that held s3:GetObject on the whole bucket. It passed code review cleanly, because “it is our own bucket, and Lambda is ephemeral.” No one had drawn a data-flow diagram.
The threat model changed the conversation in an afternoon. The STRIDE-per-element pass flagged Elevation of Privilege on the converter process — the E cell that every process owns. That alone was a shrug (“it is sandboxed”) until the attack tree made the chain undeniable. Root goal: exfiltrate another tenant’s KYC documents. Path: untrusted file in → LibreOffice and its font/image parsers have a documented history of memory-corruption RCE → converter achieves code execution → the converter’s role can read every tenant’s objects → one malicious upload equals full cross-tenant exposure of regulated PII. In their jurisdiction that is a reportable breach and a licence-threatening event. The tree turned an abstract “E on a process” into a single sentence the CTO understood in ten seconds.
The constraints were real: they could not drop LibreOffice (nothing else rendered their customers’ document zoo), and a per-tenant Lambda was operationally impossible at their tenant count. But the model had already told them where to cut the chain. It was an AND node — RCE and an over-broad role — so breaking either child stopped the path. They broke both. First, they scoped the converter’s role with an IAM PrincipalTag condition so each invocation could read only the one tenant prefix for its job, resolved from a session tag set per invocation:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "JobScopedRead",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::kyc-docs/tenants/${aws:PrincipalTag/tenantId}/*"
}]
}
Second, they moved the converter to a per-job execution context with no outbound network egress, so even a successful RCE could not exfiltrate or call home. Third — closing the DoS leaf the same pass had surfaced — they added a decompressed-size and CPU-time guard to kill zip-bomb inputs. The blast radius of the worst-case threat went from “all tenants” to “the single document already being processed for that tenant” — a residual the business could actually accept and sign off with a named owner. None of it required new tooling or a bigger security team. It required drawing the boundary, walking STRIDE against one process, and following one attack tree to its root. That is the entire return on the practice, in one story.
Advantages and disadvantages
Threat modeling is not free, and pretending otherwise is how it gets abandoned. The honest trade-off:
| Advantages | Disadvantages |
|---|---|
| Finds design flaws no scanner can (half of real defects) | Takes senior time and cross-team coordination |
| Cheapest place to fix — design stage, before code | Quality depends on the modelers’ skill and honesty |
| Produces security requirements, not just findings | Can rot instantly into a stale document if not gated |
| Prioritizes work by real risk, not scanner noise | Easy to over-model and burn weeks on Level-3 detail |
| Creates shared understanding across dev/sec/ops | Analysis paralysis if you chase every theoretical threat |
| Diffable, auditable, re-runnable when done as code | Hard to prove ROI — the breaches you prevented are invisible |
The disadvantages are all process failures, not method failures, and every one has a countermeasure covered above: right-size the model (kills over-modeling), gate it in CI (kills rot), anchor the ratings (kills skill-dependence), and prune by feasibility (kills paralysis). Where it genuinely does not pay: a throwaway prototype, a system with no trust boundaries and no sensitive data, or a change that touches nothing security-relevant — which is exactly what the story-level checklist is for. The method’s overhead should scale with the risk, and when it does, it is the highest-leverage security activity a team can do.
Hands-on lab
Model a small service with pytm end to end, generate the diagram and report, and add a CI check — all free, on your laptop. Budget 30 minutes.
1. Set up.
mkdir tm-lab && cd tm-lab
python -m venv .venv && source .venv/bin/activate
pip install pytm
# pytm needs Graphviz (dot) for the DFD and Java+PlantUML for sequence diagrams
brew install graphviz # or: apt-get install graphviz
2. Write the model as tm.py — a tiny login service with a user, an API, and a user DB across two boundaries:
from pytm import TM, Server, Datastore, Actor, Boundary, Dataflow, Classification
tm = TM("Login Service")
tm.description = "Lab model for the KloudVin threat-modeling article"
net = Boundary("Internet")
app = Boundary("App VPC")
user = Actor("User"); user.inBoundary = net
api = Server("Login API"); api.inBoundary = app
db = Datastore("User DB"); db.inBoundary = app
db.storesSensitiveData = True
f1 = Dataflow(user, api, "POST /login (email, password)")
f1.protocol = "HTTPS"; f1.dstPort = 443
f1.data = "credentials"; f1.classification = Classification.SECRET
f2 = Dataflow(api, db, "SELECT user WHERE email=?")
f2.protocol = "PostgreSQL"; f2.dstPort = 5432
f2.data = "password hash"; f2.classification = Classification.SECRET
if __name__ == "__main__":
tm.process()
3. Generate the DFD and the threat report.
python tm.py --dfd | dot -Tpng -o dfd.png # data-flow diagram
python tm.py --report /dev/stdin <<'EOF' > report.md
# Threat Report
{tm:hyperlinks}
## Threats
{threats:repeat:* {{item.description}}\n}
EOF
python tm.py --list # list findings pytm generated
Expected output. --list prints generated findings such as “Reading credentials from a flow without encryption” and “Datastore does not authenticate the caller” — pytm’s threat library matched them from your model. dfd.png shows the user in the Internet boundary, the API and DB in the App VPC, with the two labeled flows crossing the boundary.
4. Turn a finding into a tracked threat. Create threats/login.yaml:
- id: TM-LOGIN-01
element: "Login API (process)"
stride: S
title: "No account-lockout enables credential stuffing"
likelihood: high
impact: high
risk: critical
response: mitigate
status: open
mitigation_ref: CTRL-RATELIMIT-LOGIN
5. Add the CI gate — a check that fails on any open threat with no mitigation_ref:
# scripts/check_threats.py
import sys, glob, yaml
bad = []
for path in glob.glob("threats/*.yaml"):
for t in yaml.safe_load(open(path)) or []:
if t.get("status") == "open" and not t.get("mitigation_ref"):
bad.append(t["id"])
if bad:
print("Open threats missing mitigation_ref:", ", ".join(bad)); sys.exit(1)
print("Threat model OK")
python scripts/check_threats.py # exit 0 -> "Threat model OK"
6. Validate. Delete the mitigation_ref line from the YAML and re-run check_threats.py — it should exit non-zero and name TM-LOGIN-01. That failing exit is your pipeline gate working. Restore the line to go green.
7. Teardown. Nothing to tear down — it is all local files. deactivate && cd .. && rm -rf tm-lab. No cloud spend, no resources left running.
Common mistakes & troubleshooting (anti-patterns)
The method fails in predictable ways. Here is the anti-pattern playbook — the symptom you will observe, the root cause, how to confirm it, and the fix:
| # | Symptom | Root cause | Confirm | Fix |
|---|---|---|---|---|
| 1 | Model produced once, never updated | Treated as an event, not an asset | Model file has one commit, months old | Put it in the repo; gate in CI; delta-model every relevant PR |
| 2 | 200 threats, none prioritized | No rating scheme applied | No risk field on any threat |
Apply likelihood x impact with a written rubric; sort |
| 3 | Threats are generic (“SQLi possible”) | Skipped the DFD; modeled from imagination | No DFD in the repo; threats not tied to elements | Draw the DFD first; STRIDE per element |
| 4 | Modeling took two weeks | Over-decomposed to Level 3 | DFD has function-level detail | Stop at the level where threats stop changing |
| 5 | Team defends against nation-states, ignores IDOR | No feasibility pruning | Attack tree leaves all rated “critical” | Annotate cost/skill/access; prune the absurd |
| 6 | “Accept” everywhere, nothing fixed | Acceptance is not auditable | response: accept with no owner/date |
Require named owner + expiry on every accept |
| 7 | Same threat rated 3 and 9 by two people | No calibrated rubric | Compare two raters on one threat | Anchor each axis with written definitions |
| 8 | Controls exist but regress silently | Mitigations not tested | No test maps to the control ID | Every implemented control gets a failing-on-regression test |
| 9 | Trust boundary crossings have no threats | Boundaries not drawn or ignored | Count threats per crossing = 0 | A clean crossing means you missed something, not that it is safe |
| 10 | DREAD scores feel arbitrary | Discoverability + averaging distort | Recompute without Discoverability | Move to likelihood x impact or fix the rubric |
| 11 | Security team models in isolation, devs ignore it | No developer ownership | Devs cannot find or read the model | Model with the team; keep it in their repo, in their PRs |
| 12 | Findings never become work | No link to tickets/backlog | Threats have no issue reference | Auto-file High/Critical threats as tracked issues |
The two that quietly kill the most programs are #1 (the model rots) and #8 (controls regress untested). Both are solved by the same principle: the model and its controls are code, gated in CI, or they are theater. If you take one thing from this article, take that.
Best practices
- Draw the DFD first, always. Threats enumerated without a diagram are threats invented from bias — you find what you already fear and miss what you do not.
- Model boundaries, not boxes. The dashed lines are where the analysis pays; a threat far from any boundary is usually low-value.
- Run STRIDE per element mechanically. Walk the grid; write a concrete threat or an explicit reasoned “N/A” for every applicable cell. No skipping “because it won’t happen here.”
- Use attack trees for your top few goals. They surface the high-leverage AND-node controls that per-element STRIDE cannot see.
- Rate with a calibrated scheme, consistently. Likelihood x impact with anchored axes; retire DREAD; reserve CVSS for real CVEs.
- Prune by feasibility. Spend defensive energy on cheap, low-skill, internet-reachable leaves — that is who actually attacks you.
- Trace every threat to a tested control. No orphan threats, no orphan controls, and every implemented control has a test that fails on regression.
- Make “accept” auditable. A name, a reason, an expiry — or it is just “ignore.”
- Keep the model as code in the repo. Diffable, reviewed in PRs, gated in CI. A wiki page rots; a tracked file lives.
- Right-size to the change. Full pass for greenfield, delta model for increments, a five-minute checklist per story.
- Model with the builders. A threat model handed down by a separate security team gets ignored; one the developers built gets maintained.
- Re-model on trigger, not on schedule. New trust boundary, new data class, or auth/crypto change — those are the events that invalidate assumptions.
Security notes
Threat modeling is a security activity, but it has its own security posture worth naming. The threat model itself is sensitive: it is a map of your weaknesses, ranked by exploitability, with the accepted risks helpfully labeled. Store it in the same access-controlled repo as the code it describes, not a public wiki, and treat a leaked threat model as an incident — it hands an attacker your prioritized to-do list.
Apply least privilege to the modeling process the same way you apply it in the model: the CI job that lints threats needs read access to the repo and nothing else; it should not hold cloud credentials. When your mitigations reach for identity controls — and they will, since Spoofing and Elevation dominate most models — lean on the patterns in Zero Trust Architecture Blueprint: Identity, Network, and Data Pillars and Locking Down Workload Identities: Conditional Access, Risk Detection, and Going Secretless. When a model surfaces a secret-handling flaw (the R and I cells around config and credentials), route the fix through Eliminating Secret Sprawl: Pipeline Scanning, Push Protection, and Leaked-Credential Remediation. And remember that threat modeling’s output is a set of requirements: the encryption, the least-privilege role, the audit log are only real once the code and the tests exist. A model full of status: mitigated entries with no corresponding test is a model lying to you.
Cost & sizing
The costs here are people-time and, optionally, tooling — not cloud spend. Sizing the effort correctly is the whole game, because an over-heavy process gets abandoned and a too-light one misses the flaws:
| Activity | Time cost | Money cost | When it is worth it |
|---|---|---|---|
| Story-level checklist | 5–15 min / story | ₹0 | Every security-relevant story |
| Delta model on a PR | 15–30 min | ₹0 | Any change to a flow or boundary |
| Feature/epic model | 1–2 hours, small group | ₹0 | New capability crossing a boundary |
| System-level model (greenfield) | 0.5–2 days, cross-team | ₹0 (self-run) | New service; major re-architecture |
| Open-source tooling (pytm, Threat Dragon, Threagile) | Setup hours | ₹0 (OSS) | Teams standardizing on model-as-code |
| Commercial platform (IriusRisk et al.) | Onboarding weeks | Licence: lakhs/yr (USD tens of thousands) | Large enterprises needing governance + compliance mapping |
| External threat-model consultancy | Per engagement | ₹1.5–8 lakh (USD ~2–10k) per system | High-stakes system, no in-house skill yet |
Rough right-sizing rule: your threat-modeling effort should be a small fraction of the build effort for the same component, scaled by its risk. A payment or KYC service earns a full-day system model plus per-feature passes; an internal read-only dashboard with no sensitive data earns a five-minute checklist and a note. The trap at both ends: heavyweight modeling on trivial systems (waste, and the process gets resented) and no modeling on the crown jewels (the expensive kind of savings). Start free with OSS and a checklist; buy a platform only once threat modeling is routine and the bottleneck is governance at scale, not the modeling itself.
Interview & exam questions
1. What are the four questions that frame a threat model? What are we building? What can go wrong? What are we going to do about it? Did we do a good enough job? They map to modeling (DFD), enumeration (STRIDE/attack trees), response (rating + mitigation), and validation (coverage + tests). Popularized by Adam Shostack.
2. What does STRIDE stand for, and what property does each letter violate? Spoofing (authentication), Tampering (integrity), Repudiation (non-repudiation), Information disclosure (confidentiality), Denial of service (availability), Elevation of privilege (authorization). Each is the negation of a security property, which is why walking all six is exhaustive by construction.
3. What are the four element types in a data-flow diagram, and what is the fifth thing you draw? External entity, process, data store, data flow. The fifth — and most important — is the trust boundary, a dashed line where trust/privilege level changes and where threats concentrate.
4. In STRIDE-per-element, why does a data flow not get Spoofing or Elevation? A data flow does not authenticate anyone (so it cannot be spoofed in the auth sense) and does not run code with privilege (so it cannot elevate). It can be tampered or read in transit (T, I) or flooded (D). Only processes get the full six because only they authenticate, run logic, and hold privilege.
5. Explain AND vs OR nodes in an attack tree and why the distinction matters for defense. OR: any child suffices, so the defender must block every branch. AND: all children are required, so breaking any one child collapses the path. You hunt for AND nodes because a single control there is high-leverage. Cost propagates as minimum across OR, sum across AND.
6. Why did Microsoft deprecate DREAD? It is subjective and inconsistent between raters, its dimensions overlap (Reproducibility vs Exploitability), averaging hides high-damage/low-other threats, and Discoverability rewards security by obscurity. Prefer a calibrated likelihood x impact matrix for design threats.
7. When would you use CVSS instead of likelihood x impact? CVSS scores known, concrete vulnerabilities (every CVE carries one) and is comparable across organizations. Use it for dependency findings and communicating with teams that speak CVSS. It is the wrong altitude for abstract design-stage threats, where you would have to invent the metric values.
8. What is the difference between a threat, a vulnerability, and a risk? A threat is a potential negative event; a vulnerability is the specific weakness enabling it; a risk is the threat weighted by likelihood and impact. A control reduces the risk. Keeping them distinct prevents muddled prioritization.
9. How do you keep a threat model alive in an Agile team? Right-size the effort (story checklist, delta model, feature model, system model), keep the model as diffable code in the repo, and gate it in CI so an open threat with no mitigation fails the build. Re-model on trigger — new boundary, new data class, auth/crypto change — not on a calendar.
10. Name three threat-modeling tools and what distinguishes them. OWASP Threat Dragon (free, GUI, JSON model that diffs, STRIDE + LINDDUN); Microsoft Threat Modeling Tool (free, auto-generates STRIDE per interaction from a DFD, Azure-oriented); pytm/Threagile (threat-model-as-code, generate DFD + findings in CI). IriusRisk is the commercial governance-scale option.
11. What are the four responses to a rated threat, and what does “accept” require? Mitigate, eliminate, transfer, accept. Acceptance must carry a named owner and an expiry date, recorded next to the threat — otherwise it is just “ignore” and the finding dies silently.
12. Why draw the DFD before enumerating threats? Because threats enumerated without a diagram come from the modelers’ biases — you find the risks you already fear and miss the ones you never pictured. The DFD gives you a complete, unbiased worklist: every element, every boundary crossing, analyzed the same way.
These map to secure-design questions in CISSP (Domain 3), CSSLP, the SANS SEC540/DevSecOps track, and most cloud security certifications’ secure-architecture domains.
Quick check
- Which STRIDE category maps to a violation of integrity, and give one control family that restores it.
- You expand a process into sub-processes and find no new trust boundary and no new threat class. What does the “decompose until threats stop changing” rule tell you to do?
- In an attack tree, you find an AND node with two children. Which do you have to break to stop that path?
- A threat is rated
response: accept. What two fields make that acceptance auditable rather than “ignore”? - Why is a calibrated likelihood x impact matrix usually preferred over DREAD for design-stage threats?
Answers
- Tampering (T) violates integrity. Control families that restore it: TLS in transit, HMAC/digital signatures, input validation, WORM storage, least-privilege writes.
- Stop — you have gone deep enough (or one level too deep). Decomposing further only adds detail without adding threats, which is wasted effort; roll back to the level where the threats last changed.
- Either one. Under an AND node all children are required together, so breaking any single child collapses the whole branch — that is exactly the high-leverage control you hunt for.
- A named owner and an expiry/review date. Without both, the accepted risk has no one accountable and no trigger to revisit it, so it silently becomes permanent.
- Because likelihood x impact speaks the business’s risk language, avoids DREAD’s subjectivity and overlapping dimensions, and drops Discoverability — which rewards security by obscurity. With anchored axes it is defensible in an audit; DREAD’s averaging hides high-damage threats.
Glossary
- Threat model — A structured analysis of a system that answers the four questions: what are we building, what can go wrong, what do we do, did we do well.
- DFD (data-flow diagram) — A model of how data moves, using four element types (external entity, process, data store, data flow) plus trust boundaries.
- Trust boundary — A line across which trust or privilege level changes; where threats concentrate and analysis is owed.
- STRIDE — Six threat categories (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege), each the negation of a security property.
- STRIDE-per-element — Applying STRIDE to each DFD element using the element-to-threat grid, generating one concrete threat per applicable cell.
- STRIDE-per-interaction — The variant that applies STRIDE to each (source, flow, destination) tuple; more threats, fewer false N/As.
- LINDDUN — The privacy-focused counterpart to STRIDE (Linkability, Identifiability, Non-repudiation, Detectability, Disclosure, Unawareness, Non-compliance).
- Attack tree — A top-down model from an adversary goal (root) refined into AND/OR sub-goals down to mitigable leaves.
- AND / OR node — AND: all children required (break one to stop the path). OR: any child suffices (block all to stop it).
- DREAD — A deprecated rating scheme (Damage, Reproducibility, Exploitability, Affected, Discoverability); subjective and obscurity-biased.
- CVSS — Common Vulnerability Scoring System; the industry standard for scoring known, concrete vulnerabilities (0–10).
- Likelihood x impact — The default design-threat rating: a matrix combining how easy an attack is with how bad the outcome is.
- Control / mitigation — A measure that reduces a risk’s likelihood or impact; every open threat must trace to one.
- Risk response — One of mitigate, eliminate, transfer, accept — chosen explicitly per rated threat.
- Threat-model-as-code — Defining the model in a text/code format (pytm, Threagile, Threat Dragon JSON) so it diffs, reviews in PRs, and gates in CI.
- Attack surface — The sum of points where an attacker can interact with a system; shrinking it eliminates threats outright.
Next steps
- Wire your mitigations to real control patterns with Zero Trust Architecture Blueprint: Identity, Network, and Data Pillars — the identity, network, and data pillars are where most STRIDE controls live.
- Make the model a pipeline gate rather than a document using Shift-Left Testing and Quality Gates in CI/CD.
- Close the loop from “threat found” to “threat handled” with Engineering Incident Response: Runbooks, Tabletop Exercises, and Cloud Forensics.
- Address the Spoofing and Elevation threats that dominate most models with Locking Down Workload Identities: Conditional Access, Risk Detection, and Going Secretless.
- Extend the practice to your dependencies and build pipeline with Consuming the Software Supply Chain: SBOM Ingestion, VEX Triage, and Admission Verification.