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.

CategoryAsksAPI example
SpoofingCan they pretend to be someone?forged JWT, stolen session
TamperingCan they change data in flight or at rest?mass assignment, no HMAC on a webhook
RepudiationCan they deny doing it?no audit log, shared service credentials
Information disclosureCan they see what they should not?BOLA, excessive fields, verbose errors
Denial of serviceCan they exhaust something?unbounded page size, expensive query
Elevation of privilegeCan 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

Example · bash
# 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.

Example · bash
# 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.

Example · bash
# 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.

Example · bash
# 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

  • Be the first to comment on this lesson.