threat-modeling
Use when a design introduces a new trust boundary, a new class of sensitive data, a new external integration, or a change to authentication or authorization, when a design review needs a security section, when asked what could go wrong with a system or feature, or when reasoning about attackers, attack surface, blast radius, and which risks to fix first. Also use when a limit, quota, entitlement, or uniqueness rule is enforced at the one write path whoever wrote it had in mind, while import, sync, restore, bulk seeding, or admin tooling can reach the same state, or when such a rule is checked only at creation and nothing re-checks it afterwards. Also use when a component is sandboxed, jailed, or otherwise confined, and the audit of what it can reach came back clean, or when a local listener is called safe because it binds only to loopback. Covers STRIDE, trust boundaries, data flow, attacker capability tiers, risk ranking, and enumerating every path that can produce a guarded state.
Overview
A threat model is a 30–60 minute conversation that produces decisions, not a document. Its output is a list of design changes, mitigations with tests, and explicitly accepted risks with owners. If the design did not change and nothing got a test, the session was theater.
When to use
- A new trust boundary appears: a new listener, IPC surface, plugin or extension point, tenant, file format you now parse, or a queue you now consume.
- A new class of data enters: credentials, personal data, payment, health, keys, or telemetry that can carry user content.
- A new external integration or an authorization change: third-party API, webhook receiver, identity provider, a new role, a new token type, a session mechanism, or any delegation.
- Before writing the security section of a design doc, so the section reports decisions instead of inventing them.
- Not for: root-causing a specific reported bug; evaluating a package's supply chain (
auditing-dependencies); writing the actual permission scopes (least-privilege-design); the input-handling rules themselves (handling-untrusted-input).
The four questions
- What are we building. One diagram, drawn from the running system, not the architecture doc — the doc is aspirational and the real system has an extra debug endpoint, a legacy path, and one listener nobody remembers. Data flows, not boxes: label what crosses each arrow.
- What can go wrong. Enumerated per boundary, never per component. Component lists are unbounded; boundary lists come out at 5–15 and terminate.
- What are we going to do about it. Every threat gets one of four verbs: eliminate, mitigate, transfer, accept — with a named owner.
- Did we do a good enough job. Every "what can go wrong" has a decision; every mitigation names the test or assertion that will fail if someone removes it.
The boundary list is where the value is
A trust boundary is any place where the trustworthiness of the caller changes. Enumerate all of these before thinking about threats:
| Boundary kind | Examples |
|---|---|
| Privilege | kernel/user, root/service user, sandbox edge, container edge |
| Network | internet-to-service, and service-to-service whenever the two run as different identities |
| Tenant | per-customer rows, per-customer keys, shared caches and shared indexes |
| Runtime and format | FFI, native extensions, unsafe blocks, eval or template compilation, and every parser — uploads, images, archives, config, serialized objects |
| Human | admin console, support tooling, break-glass, the ops runbook |
| Time | a token issued last quarter, an artifact built by a retired pipeline |
| Supply | a dependency's install hook, which runs at your privilege |
For each boundary write three lines: what crosses it, what authority the far side holds, and what happens if the far side lies. If you cannot state what happens when the far side lies, that boundary is not modeled — and shared caches, log pipelines, and message queues are the ones consistently missed.
Loopback is a network boundary, not a trust boundary. A listener bound to a loopback address (127.0.0.1, ::1) is off the network, and the threat model tends to record that as reaching it requires running code as this user. Two callers break that without coming from the network. The first is the browser, which runs code from every page it has open, as this user, and will send a request or open a WebSocket to a local port a page names. Browsers have begun restricting that, but partially and differently by browser and version, so do not count on it. Cross-origin rules stop a page reading the response: a simple request, such as a form post, still arrives and the handler still runs, and a WebSocket handshake is not subject to those rules at all. With DNS rebinding a page can also point its own hostname at the loopback address, after which the browser treats the listener as part of that page's origin. This is least-privilege-design's confused deputy seen from the target's side: the browser is the deputy, and whoever owns the listener can defend only the target. The second caller is every other account on the same machine, because they all share the loopback interface.
So a local control surface is two boundaries: the web, through the browser, and the other accounts on the machine. A secret only the owning account can read closes both, because a web page cannot read local files and another account cannot read that one: a token in an owner-only file, which the client presents on every connection, never as a cookie the browser would attach on a page's behalf. Check two headers as well, which turns a page away before it reaches authentication. A browser sends Origin on every WebSocket handshake and on cross-origin writes, so refuse any Origin not on your list; some native client libraries send one too, so list theirs rather than refusing every value. And require Host to be the loopback name you serve, which is what defeats rebinding. Where the platform offers a channel the browser cannot address at all, such as a Unix domain socket or a named pipe, it removes the first boundary outright and hands the second to the file's own permissions.
STRIDE as a prompt list, not a ritual
Apply per boundary, not per box. Budget 60–90 seconds per cell; when a cell needs more than five minutes, that's the finding — record it and move on.
| Prompt | Question at this boundary | Property it defends |
|---|---|---|
| Spoofing | Can the far side claim an identity it does not have? | Authentication |
| Tampering | Can data or code be altered in transit or at rest? | Integrity |
| Repudiation | Can an actor deny an action we cannot prove? | Attribution |
| Information disclosure | Who else can read this, including logs and errors? | Confidentiality |
| Denial of service | What unbounded work can a caller cause? | Availability |
| Elevation of privilege | Can a caller act with authority it was not granted? | Authorization |
Skip categories that genuinely do not apply — a read-only static asset path has no meaningful repudiation story — and say so out loud rather than filling the cell.
Attackers, in capability tiers
Naming the tier turns "someone could" into "who, and would they bother". A generic "hacker" produces a generic mitigation. A clairvoyance sweep's actor pass — the empty chairs, the field a request sits in — hands off here the moment an adversarial chair is occupied; the tiers below are the enumeration it deliberately does not attempt.
| Tier | Has | Wants | Effort |
|---|---|---|---|
| 0 Curious user | a valid account, dev tools, your API docs | another tenant's data, a role upgrade, free usage | minutes |
| 1 Opportunist | mass scanners, credential lists, public exploits within days | any foothold, compute, a proxy | near zero, untargeted |
| 2 Targeted | reads your source, registers lookalike names, phishes staff, buys your expired domain | your specific data or money | days to weeks |
| 3 Insider / supply chain | a valid credential, a maintainer account, a CI token | anything, with your own authority | mitigated structurally, not detectively |
Label any mitigation that only stops tier 0 — unguessable URLs, undocumented endpoints, client-side checks — as exactly that. Tier 4 (state-level) is out of scope unless you can name why you are a target; write the exclusion down instead of leaving it ambiguous.
Ranking: impact × reachability
Severity labels are computed for someone else's deployment. Reachability is where your system differs.
- Reachability: pre-auth ×4 · any authenticated user ×2 · operator or admin only ×1 · requires an existing compromise ×0.5 (that is a blast-radius item — route it to a privilege review).
- Impact: high if irreversible, cross-tenant, or credential-yielding; low if self-only, single-record, or self-recovering degradation.
- Fix order: pre-auth + irreversible, then pre-auth + cross-tenant, then authenticated + cross-tenant, then the rest. A critical-rated flaw in a path no request reaches outranks nothing.
The output
Three columns: Threat | Decision | Where it lives now. Decisions rank in this order — design change so the threat cannot exist, mitigation with a named test, detection with a named alert and a named recipient, accepted with an owner and a review date. "We'll monitor it" without an alert name is acceptance spelled dishonestly.
A limit is a property of state, not of one transition
A limit, a quota, an entitlement, a uniqueness rule — each of these is a property of a state, and each gets implemented as a check on a single transition, almost always the interactive one that was in mind when it was written. The gate is real, it works, and it guards one door of a building with several.
Every other route that can reach the same state walks past it: first-run seeding that creates in bulk, import, migration, restore from backup, sync from another replica, admin tooling, an undo that re-adds what was removed. These are the paths that typically run with more authority and less scrutiny than the one that got the check, which is the wrong way round.
So the question is never "is the limit enforced?" It is: enumerate every path that can produce this state, and does each one pass the same gate? Write the list; a limit with one enforcement site and four producers is a finding, and it is findable in minutes by grepping for writes to the thing being limited rather than for the check. tracing-data-flow covers doing that enumeration properly when the producers are not obvious.
Then ask the second half, which is what turns a temporary breach into a permanent one: does anything re-check the state after it exists? A gate that runs only at creation cannot repair what arrived before it, around it, or from a version of the code that predates it. Enforcement only at creation encodes an assumption that state can only ever arrive by creation, and every item in the list above violates that assumption. Without a read-time or load-time check as well, the rule degrades into an honour system the moment a second writer appears, and the violation persists for the lifetime of the install rather than until the next request.
This generalizes past security limits to any invariant worth stating: if it is only ever established, and never verified, it is a hope with a constructor.
Confining what something may run does not confine what it may write
A containment boundary is drawn around execution: this process may call these things, reach these hosts, touch these paths. The audit that follows matches the boundary — enumerate what it can invoke, attempt to step outside, confirm the attempts fail. That audit comes back clean on a system with an exit in it, because the exit is not an invocation.
The boundary says nothing about authorship. Anything the confined thing may write, that something outside the boundary will later read, run, or treat as configuration, is a way out at the outer thing's privilege — deferred until the next time that outer thing reads. The confined process never steps over the line; it leaves something on its own side of the line for a trusted reader to pick up. Familiar carriers: a task, hook, or launch definition a surrounding tool discovers on its own; an interpreter, plugin, or shim on a path something else resolves through; a lockfile or dependency manifest a later build obeys; a socket or state file a privileged local service consumes; a rule file that widens the very allowlist doing the confining.
Two things make this hard to find with the usual pass. It is time-shifted, so nothing is happening at the moment of the write and there is no request to inspect — the trace at the interesting instant shows a file being created inside a permitted directory. And the escalation is performed by a component that is behaving correctly: it loaded its configuration, as designed, from where it has always loaded it.
So run the boundary list twice. Once for reachability, which is the pass everyone runs. Then once for authorship: for every location the confined thing can write, name who else reads it and with what privilege. Wherever that answer names something outside, the boundary is drawn in the wrong place — either that reader belongs inside it, or what you have is not a boundary. least-privilege-design carries the same-moment version of this, where the gap is between an approved name and what that name resolves to at the instant it runs; this is the delayed version, where the gap is a file sitting between the two. The strong version of the question is worth asking out loud, because it makes the failure obvious in one sentence — does this thing get to write the future inputs of anything that is not contained? Where it does, containment was never the property you had.
Assumptions are the tripwires
List them at the top of the model. Each one, if false, invalidates everything below it, and each has a cheap check that almost nobody runs.
| Assumption | Cheap verification |
|---|---|
| "This port is not reachable from the internet" | scan it from outside |
| "Only code running as this user can reach this loopback port" | open it from a page on another origin in a browser, and connect to it from a second account on the same machine |
| "Only our own code calls this" — service, queue, or storage path | list who can actually call, publish, or write, from access logs rather than memory |
| "The input was sanitized upstream" | find the line that does it |
| "Only admins hold this role" | list current role assignments |
Re-run the model when any assumption changes. That change list is the trigger for re-modeling — without it, models rot silently.
Common mistakes
| Symptom | Real cause |
|---|---|
| 40 threats, zero decisions | Modeled components instead of boundaries; component enumeration never terminates |
| Every threat begins "an attacker gains access and then…" | No capability tiers; "gains access" is a conclusion being used as a premise |
| Ranked by CVSS or a severity label | A context-free score used in place of reachability in your system |
| Long debate on crypto choice, nothing on authorization | Novel-looking risks crowd out the common ones; broken access control outnumbers crypto flaws by a wide margin |
| Mitigation exists, no test | A mitigation without a test is a comment; the next refactor deletes it silently |
| "The gateway will block that" | Control placed at a layer any direct caller bypasses |
| The paid tier stops existing for users who arrived by one particular route | A limit enforced at one write path; the bulk or seeding path never consulted it |
| A violated invariant that never repairs itself | Checked at creation only, so nothing re-examines the state once it exists |
| A sandboxed component escaped without ever calling anything it was not allowed to call | The boundary was drawn around execution; it wrote a file something outside the boundary later loaded |
Red flags
- A containment claim supported by a list of what the thing can call, with no list of what it can write.
- "That's an edge case, no real user would do that" — the attacker is not a user.
- "It's internal-only," asserted without a check that it is.
- "It only listens on localhost," offered as the access control rather than as the network placement.
- "We authenticate the caller, so we're covered" — authentication answers who, not what on whose behalf.
- "We'll do security after the MVP" — trust boundaries are architecture; they are not retrofitted, and the session that ends with zero design changes has already conceded this.
- Nobody in the room can say which single credential could delete all customer data.
- Exactly one mitigation per threat — defense in depth was never considered.
- A rule described as "enforced" with exactly one enforcement site named, and no list of what else writes to the same state.
- An invariant that is established at creation and never checked again, on a system that also imports, migrates, restores, or syncs.