Threat Modelling for APIs
A structured hour that finds more than a week of ad-hoc review, because it forces you to consider what you would not have thought of.
Threat modelling replaces "can anyone think of a problem?" with a procedure. Four questions, asked in order.
1. What are we building?
Draw the data flow: clients, your API, datastores, third parties, and every trust boundary the data crosses. A trust boundary is any line where the level of trust changes — browser to API, API to database, your service to a vendor. Vulnerabilities cluster on those lines.
2. What can go wrong?
STRIDE gives you six prompts so you do not just re-find the bug you already know about.
| Category | Asks | API example |
|---|---|---|
| Spoofing | Can they pretend to be someone? | forged JWT, stolen session |
| Tampering | Can they change data in flight or at rest? | mass assignment, no HMAC on a webhook |
| Repudiation | Can they deny doing it? | no audit log, shared service credentials |
| Information disclosure | Can they see what they should not? | BOLA, excessive fields, verbose errors |
| Denial of service | Can they exhaust something? | unbounded page size, expensive query |
| Elevation of privilege | Can they gain rights? | BFLA, role in a mutable field |
3. What are we doing about it?
Four honest options per threat: mitigate (build the control), transfer (insurance, a vendor), accept (documented, with a name against it), eliminate (delete the feature). "Accept" is a legitimate answer when written down; it is a problem only when it happens by silence.
4. Did we do a good job?
Every mitigation needs a test. A control with no test is an intention.
Keep it small and repeated
An hour per feature, at design time, beats a week before launch. The output is a table you commit next to the code — a living document that reviewers can check against, not a PDF nobody opens.
Example
# Feature: "customers can export their data as CSV"
# One hour, STRIDE, six prompts.
S Can someone export ANOTHER customer's data?
→ the export job takes a userId. Is it from the token or the request?
T Can the export be tampered with in transit or the request replayed?
→ signed, expiring download URLs; no permanent public links
R If data leaks, can we prove who exported it and when?
→ audit: who, what, how many rows, from where — required
I Does the CSV contain more than the UI shows?
→ it serialises the model: includes internal_notes, risk_score. Shape it.
D What does 10,000 concurrent exports cost?
→ unbounded query + no queue = database down. Rate limit + async job.
E Can a normal user trigger an admin-scoped export?
→ the route is behind auth but not behind a role check. Finding.
# Six prompts, three real findings, one hour. The DoS and repudiation ones
# would not have come up in an unstructured review.When to use it
- A one-hour session on a new export feature surfaces an unbounded query that would have taken the database down at launch.
- A threat model documents an accepted risk with a named owner and a review date, so it is a decision rather than an oversight.
- A team adds an audit log for exports because the repudiation prompt asked a question nobody had considered.
More examples
The data flow diagram, and where to look
Marking each participant HOSTILE / TRUSTED / SEMI-TRUSTED forces the third-party question, which is where a surprising number of real incidents start.
# Draw it once. Every ═══ is a trust boundary, and bugs cluster on them.
┌──────────┐ ═══════ ┌──────────┐ ═══════ ┌──────────┐
│ Browser │ │ API │ │ Postgres │
│ HOSTILE │──────────▶│ TRUSTED │──────────▶│ TRUSTED │
└──────────┘ └────┬─────┘ └──────────┘
═══════ │
┌──────────▼─────────┐
│ Payment provider │
│ SEMI-TRUSTED │
└────────────────────┘
# Boundary 1 — browser → API (everything is attacker-controlled)
# authenticate, authorize per object, validate input, shape output,
# rate limit, and never trust a header the client could have set
# Boundary 2 — API → database (do not build strings)
# parameterised queries, least-privilege DB user, RLS for tenancy,
# encryption at rest for the sensitive columns
# Boundary 3 — API → third party (they can be compromised too)
# pin the hostname, timeouts, validate THEIR response as untrusted input,
# verify webhook signatures, and fail closed on their outage
# The boundary teams forget is 3. "It came from Stripe" is not verification —
# anything can POST to your webhook endpoint until you check the signature.The threat model as a file next to the code
Linking each row to a test file is what stops the model drifting from the code — a deleted test becomes a visible gap in the model.
# docs/threat-model/exports.md — committed, reviewed, updated with the feature.
## Feature: customer data export
Owner: @payments-team Reviewed: 2026-08-04 Next review: 2027-02-04
| # | STRIDE | Threat | Likelihood | Impact | Decision | Control | Test |
|---|--------|--------|-----------|--------|----------|---------|------|
| 1 | I | Export another customer's data | High | Critical | Mitigate | userId from token only; query scoped by tenant_id | `exports.test.ts:"rejects foreign tenant"` |
| 2 | I | CSV contains internal fields | High | High | Mitigate | explicit column allowlist in `ExportSerializer` | `exports.test.ts:"only public columns"` |
| 3 | D | Unbounded export exhausts DB | Medium | High | Mitigate | async queue, 1 concurrent per tenant, row cap 1e6 | `load/export.k6.js` |
| 4 | R | Cannot prove who exported | Medium | High | Mitigate | audit: actor, tenant, rows, ip, request_id | `audit.test.ts` |
| 5 | T | Download URL shared or replayed | Medium | Medium | Mitigate | signed URL, 15 min, single use, bound to user | `exports.test.ts:"url expires"` |
| 6 | E | Non-admin triggers full-tenant export | Low | Critical | Mitigate | `requireRole('admin')` on the tenant-wide route | `authz.test.ts` |
| 7 | S | Stolen token used to export | Low | High | **Accept** | 10-min tokens + audit alerting on volume | — |
### Accepted risks
- **#7**: sender-constrained tokens (DPoP) would close this. Deferred to Q4;
accepted by @security-lead on 2026-08-04. Revisit if export volume grows.
# Two properties make this useful: every mitigation names a TEST, and every
# acceptance names a PERSON and a DATE. Without those it is a wish list.Prompts that find what STRIDE alone misses
Most authorization bugs found in mature codebases are on a second path to the same data — search indexes, exports and webhooks are the usual culprits.
# Run these after STRIDE. They catch a different class of problem.
ABUSE OF INTENDED FUNCTIONALITY
"What if someone uses this exactly as designed, 10,000 times a day?"
→ password reset becomes an email bomber
→ search becomes a scraper
→ invite becomes spam with your domain's reputation attached
THE INSIDER
"What can a support agent do? What can a compromised employee laptop do?"
→ impersonation features with no audit trail
→ an admin panel with no second factor
→ a database read replica accessible from a laptop
THE COMPROMISED DEPENDENCY
"An npm package we use ships a malicious version tonight. What does it reach?"
→ environment variables, so every secret in the process
→ outbound network, so exfiltration is trivial
→ this is why workload identity beats static secrets
THE PARTIAL FAILURE
"Redis is down. Postgres is slow. The IdP times out. What happens?"
→ does the rate limiter fail open? (it must not)
→ does auth fail open? (it must not)
→ does a timeout leave a half-completed transaction?
THE FORGOTTEN PATH
"Which other code path reaches this data?"
→ the GraphQL resolver next to the REST route
→ the CSV export, the webhook payload, the search index
→ the admin panel that bypasses the service layer entirely
# The last one is the most productive question in this whole lesson: the REST
# endpoint is reviewed and correct, and the export path returns everything.
Discussion