Threat modeling is a structured way to ask how a system could be misused — before someone else asks the same question with your data. It is not a penetration test and not a compliance checklist: it is a short, repeatable exercise that produces a list of plausible attacks, the boundary where each one would have to succeed, and the person responsible for the control. Done well it takes an afternoon; skipped, the same discussion happens during an incident instead.
Assets, actors, data flows, boundaries
Four nouns are enough to start:
- Assets — what an attacker wants: credentials, personal data, payment state, signing keys, admin capability, availability itself.
- Actors — who wants it: an anonymous internet caller, an authenticated but curious user, a compromised dependency, a malicious tenant, an insider with production access.
- Data flows — the paths data and commands actually travel, drawn from the real deployment diagram, not the architecture slide.
- Trust boundaries — where assumptions change: browser to server, service to database, tenant to tenant, production to third party, your account to your cloud provider.
A boundary is wherever you must stop assuming and start verifying. That definition is broader than the network edge: a process boundary, a queue consumer, or an internal service that receives data from a less-trusted one all qualify. Most real gaps sit one hop inside the perimeter — an internal API that trusts any caller because "we're behind the load balancer."
Draw the path, then interrogate each crossing
Take a concrete feature — file upload — and draw it honestly:
[ browser ] --HTTPS--> [ API ] --put--> [ object store ]
|
+--enqueue--> [ worker ] --fetch--> [ image processor ]
^ |
trust boundary trust boundary
(user input) (worker privileges)Every crossing raises a specific, answerable set of questions:
- Who authenticates this hop, and what exactly do they get to do after it?
- What does the caller control here — filename, MIME type, size, URL, timing, contents?
- What is validated before the value is used by the next component?
- What privileges does the next component run with?
- What happens when this hop is slow, malicious, or absent?
For this upload flow the abuse cases write themselves: a .jpg that is actually an SVG with
an embedded script (stored XSS once it is served back); ../../etc/passwd as a filename
(path traversal against a naive path join); a 4 GB "image" (denial of service by disk
exhaustion — so enforce the size limit while streaming, not after buffering); a
decompression bomb inside a zip or a 60,000 × 60,000 pixel PNG (CPU and memory exhaustion in
the processor); and a worker that runs with credentials for the whole bucket because "it
needs to read the file it wrote a moment ago."
Turn each threat into a question with an owner
A lightweight taxonomy keeps the questions from collapsing into generic worries. For each boundary, ask the six classes:
| Class | The question | Typical control |
|---|---|---|
| Spoofing | Can the caller pretend to be someone else? | mutual auth, session validation |
| Tampering | Can data change in transit or at rest? | signatures, TLS, integrity checks |
| Repudiation | Can someone deny an action they took? | audit logs with actor and object |
| Information disclosure | Can data be read by the wrong party? | scoping, encryption, redaction |
| Denial of service | Can it be made unavailable? | rate limits, quotas, size caps |
| Elevation of privilege | Can it do more than intended? | least privilege, explicit authz |
The output is not a matrix — it is five to fifteen sentences of the form "an attacker without a session can reach this endpoint, and the control is X, owned by Y." Anything more detailed than that stops being read.
Make it survive contact with the codebase
A threat list without mitigations, owners, and tests becomes paperwork. Three habits keep it alive:
- Timebox it and attach it to a change. A 60–90 minute session on a new feature, led by someone who did not write it, produces more than a multi-day review of an entire system.
- Convert findings into tests. "Filename is attacker-controlled" becomes an integration
test that uploads
../../etc/passwdand asserts rejection; "authorization at the resource" becomes the second-user test described in security follows data and authority. Tests are what stops the same threat from coming back after a refactor. - Revisit on trigger, not only on a calendar. Re-run the model when the architecture changes, a new external exposure appears, sensitive data is added, a third party joins the path, or an incident reveals an assumption was wrong. A quarterly glance plus those triggers is a realistic cadence for most teams; an annual "security review" is not.
Where this bites in production
- The perimeter you drew is not the perimeter that exists. Internal services, CI jobs, and admin tools are boundaries too, and they are where over-privileged credentials turn a single compromise into an account-wide one.
- Generic checklists miss the local risk. The dangerous path is usually specific to your deployment — a webhook callback with no signature check, a tenant ID taken from the request instead of the session, an object key built from user input.
- Undiagrammed features are unmodeled features. The flow nobody drew (password reset, CSV export, webhook retry) is the one with an unanswered boundary.
- Ownership is the deliverable. A threat with no owner and no test will be rediscovered by the next engineer — and by anyone reading your error messages.
Companion reading: security follows data and authority across boundaries and treat browser input as data at every boundary. Further reading: OWASP: Threat Modeling.