# Threat Modeling a Real System

> How to systematically find the security gaps in a whole system - not one vulnerability at a time, but by drawing the data flows, marking trust boundaries, and working through STRIDE against a real example: a file-sharing app with uploads, accounts, and payments.


---

# Threat Modeling a Real System

You know what SQL injection is. You know the difference between authentication and authorization. You've read the OWASP Top 10 enough times to recite it. And none of that tells you where to actually look on the system sitting in front of you.

That's the gap threat modeling fills. It's not a new vulnerability to learn - it's a method for pointing everything you already know about individual attacks at your own architecture, systematically, before an attacker does. Instead of asking "is this endpoint vulnerable to XSS," you ask "where does untrusted data enter this system, and what happens to it at every step from there."

This guide uses one running example throughout: a small file-sharing service. Users sign up, upload files, share links, and pay for a premium tier through a third-party processor. Simple enough to hold in your head, real enough that every finding maps to something you've built before.

## How to read this

- **Need the method fast?** Phase 1 is the diagram and trust boundaries, Phase 2 is STRIDE applied to them - read both and you can run this on your own system today.
- **Want the full arc?** Read in order. Phase 3 closes the loop: what to do with a stack of findings so threat modeling produces fixes instead of a document nobody reads.

## The phases

1. **[Drawing the System](01-drawing-the-system.md)** - a data-flow diagram of the file-sharing app (users, web server, database, payment API, file storage) and marking the trust boundaries where control changes hands.
2. **[STRIDE, Applied](02-stride-applied.md)** - each STRIDE category walked through against the example app's actual boundaries: concrete findings, not definitions.
3. **[From Threats to Action](03-from-threats-to-action.md)** - prioritizing by likelihood times impact, turning findings into real fixes, and where threat modeling's job ends.

> This is the systems-thinking layer above specific vulnerabilities. For the vulnerabilities themselves, see [the OWASP Top 10](/guides/owasp-top-10), [authentication vs. authorization](/guides/auth-vs-authz), and [secrets management](/guides/secrets-management). For the broader "what does secure even mean" question, see [what security means](/guides/what-security-means).


---

# Drawing the System

Threat modeling starts with a picture, not a checklist. Before you can ask "what could go wrong here," you need to know what "here" is - every component, every path data takes between them, and every point where you stop controlling that data. Skip the picture and you'll threat-model the parts of the system you happen to remember, which is a different exercise from threat-modeling the system.

## The example: a file-sharing app

Say you're building a service where users sign up, upload files, generate shareable links, and can pay for a premium tier with a larger storage quota. Small enough to sketch on one page, with all the pieces that make threat modeling worth doing:

- **Users** - anonymous visitors and logged-in accounts, via a browser.
- **Web server** - your application code: auth, upload handling, link generation, API routes.
- **Database** - user accounts, file metadata, share-link tokens, subscription status.
- **File storage** - the actual uploaded bytes (think S3 or equivalent).
- **Payment API** - a third-party processor (Stripe-shaped) that handles card data and tells you when someone paid.

## The diagram

A data-flow diagram (DFD) needs four symbol types: external entities (people or systems outside your control), processes (code that does something), data stores (where state lives), and arrows (data in motion). Draw it left to right, roughly in the order a request travels:

```mermaid
flowchart LR
  U[User - browser] -->|HTTPS requests| WS[Web server]
  WS -->|reads/writes| DB[(Database)]
  WS -->|upload/download| FS[(File storage)]
  WS -->|charge, subscribe| PAY[Payment API]
  PAY -->|webhook: payment succeeded| WS
  U -->|shared link, no login| WS
```

Nothing here needs special tooling - a whiteboard photo is a legitimate threat-modeling artifact. The point isn't polish, it's forcing every component and every data path into the open, including the ones you'd normally skip past because they're "just" a webhook or "just" a shared link.

Two paths are worth noticing before STRIDE starts. First, the shared-link flow lets a user reach the web server with *no login at all* - that's a distinct path from the authenticated one, with different assumptions. Second, the payment API talks back to you via a webhook, which means an external system gets to trigger state changes (marking an account as paid) inside your system.

## Marking trust boundaries

A trust boundary is any line an arrow crosses where the *level of control* changes - where data stops being something you validated and becomes something you're trusting on faith, or where a different party gains the ability to act. This is the single most useful line you'll draw on the whole diagram, because every STRIDE finding in Phase 2 sits on top of one.

Boundaries in the file-sharing example:

- **Browser to web server.** Everything a user sends - form fields, file contents, filenames, headers - is untrusted until validated. This is the obvious one; everyone marks it.
- **Web server to database.** Lower-trust than it looks. If any upstream input reaches a query unsanitized, the boundary you thought was "internal" was never real.
- **Web server to file storage.** A boundary in two directions: what gets *written* (can a filename or path escape the intended bucket/prefix?) and what gets *served back* (does a browser trust content coming from your storage domain more than it should?).
- **Web server to payment API, and back.** Two separate boundaries, not one. Outbound (you send a charge request) is you trusting a third party with money and data. Inbound (the webhook) is the third party sending *you* a command - "mark this account as paid" - trusting a network request to change account state.
- **The shared-link path.** A quieter boundary: the moment a link leaves an authenticated user's session and becomes a bearer token anyone with the URL can use. Control passes from "we know who this is" to "whoever holds this string."

Notice what marking boundaries does that just listing components doesn't: it turns "the database" from a box into a question - *which* arrows into that box crossed a boundary, and were they validated before they got there? STRIDE in Phase 2 answers that question boundary by boundary, instead of guessing at the whole system in one pass.

One habit worth keeping: redraw the boundaries when the architecture changes. A new admin dashboard, a new internal microservice, a new "trusted" partner integration - each one either sits inside an existing boundary or draws a new one. Assuming it's the former without checking is how internal tools become the least-scrutinized part of a system.

```quiz
[
  {
    "q": "What makes a line on a data-flow diagram a trust boundary?",
    "choices": ["It crosses between two different programming languages", "The level of control over the data changes at that point", "It's any connection to the internet"],
    "answer": 1,
    "explain": "A trust boundary marks where data stops being validated/controlled by you and starts being trusted from another party or unvalidated input - languages and protocols are irrelevant to that."
  },
  {
    "q": "In the file-sharing example, why are the outbound charge request and the inbound payment webhook treated as two separate trust boundaries?",
    "choices": ["Because they use different HTTP methods", "Because outbound is you trusting a third party, while inbound is a third party sending a command that changes your account state", "They aren't separate - it's one boundary counted twice"],
    "answer": 1,
    "explain": "Trust runs in one direction per arrow. Sending data out and accepting a command in are different exposures even between the same two systems."
  },
  {
    "q": "Why redraw trust boundaries when the architecture changes, instead of assuming new components fit inside existing ones?",
    "choices": ["It's required by compliance frameworks", "Diagrams need to stay visually up to date for onboarding", "A new component might introduce a boundary you haven't scrutinized yet, like an internal tool nobody threat-modeled"],
    "answer": 2,
    "explain": "New integrations and internal tools often get treated as automatically trusted just because they're 'internal' - that assumption is exactly what a fresh boundary check catches."
  }
]
```


---

# STRIDE, Applied

STRIDE is six questions, one per letter, that you ask at every trust boundary from Phase 1. Most explanations stop at definitions - "Spoofing is pretending to be something you're not" - and leave you no closer to finding anything in your own system. The definitions matter less than the habit of asking each question at each boundary and writing down what you find, even when the answer is "already handled."

Working through the file-sharing app's boundaries: browser-to-server, server-to-database, server-to-storage, server-to-payment-API (both directions), and the shared-link path.

## Spoofing - is anyone able to pretend to be someone else?

**Finding:** The upload endpoint accepts a `filename` and a `uploaded_by` display field in the form data. If `uploaded_by` is trusted as-is and shown next to the file in another user's shared view, an attacker can upload a file that displays someone else's username - spoofing identity in the UI without touching the auth system at all. The fix isn't at the login boundary; it's realizing that identity can be spoofed *downstream* of a correct login, anywhere the server trusts a client-supplied label instead of deriving it from the session.

**Finding:** The payment webhook boundary is a spoofing target too - if the webhook handler trusts the request just because it hit the right URL, anyone who finds that URL can POST a fake "payment succeeded" event and upgrade their own account for free. This is the payment API pretending to be itself, from an attacker who never touched the real payment API.

## Tampering - can someone modify data they shouldn't touch?

**Finding:** Share links are often just a token in a URL (`/share/a1b2c3`). If that token is short or sequential, an attacker can tamper with it - guess or increment their way into someone else's shared files - without ever hitting an authentication check, because the whole point of a share link is that it bypasses one.

**Finding:** At the server-to-storage boundary, if the upload path is built from user input (`/uploads/{username}/{filename}`) without sanitizing `filename`, a value like `../../other-user/secret.pdf` tampers with the intended storage location. This is the classic path-traversal shape, but framed as a STRIDE finding it's specifically about *tampering with where data lands*, which is a narrower and more actionable statement than "check for path traversal."

## Repudiation - can someone deny having done something, with no way to prove otherwise?

**Finding:** If file deletions and share-link creation aren't logged with a user ID, timestamp, and source IP, a user who deletes another account's file (via a tampering bug, or an insider with database access) can plausibly deny it - there's no record. Repudiation findings are almost always "we have no log for X," which is why this category gets skipped: it's not a bug in code, it's an absence.

**Finding:** The payment webhook again - if a "payment succeeded" event isn't logged with the raw payload before your handler acts on it, a disputed charge or a fraud investigation has nothing to point to. You can't reconstruct what the payment API actually sent versus what your handler did with it.

## Information disclosure - is data reaching someone who shouldn't see it?

**Finding:** A share-link page that returns a 404 for "link doesn't exist" but a 403 for "link exists, but it's expired" discloses information through the *shape* of the response - an attacker enumerating tokens learns which guesses are close. Small, but exactly the kind of leak that only shows up when you're deliberately looking at what a response reveals, not just what it's supposed to return.

**Finding:** File storage URLs - if files are served from predictable, unauthenticated storage URLs (common with misconfigured S3-style buckets) rather than through the web server's auth check, the storage boundary itself leaks every file to anyone with the URL, regardless of what the application layer enforces.

## Denial of service - can someone degrade or take down the system?

**Finding:** Uploads are the obvious target: no per-user quota or file-size cap on the upload endpoint means one account can fill shared storage or exhaust server memory processing a multi-gigabyte file. The trust boundary insight here is that "logged-in user" is not the same as "trusted not to abuse capacity" - authentication doesn't bound resource use.

**Finding:** The payment webhook endpoint, if it does expensive work synchronously (re-verifying with the payment API, updating multiple tables) before responding, is a target for anyone who can guess the URL and flood it - even without valid signatures, if the handler does its expensive work *before* checking the signature.

## Elevation of privilege - can someone get more access than they were granted?

**Finding:** If the premium-tier check happens in application code (`if user.plan == "premium"`) rather than being enforced wherever storage quota is actually allocated, a race condition or a missed check on one code path (say, a bulk-import feature added later) lets a free user get premium storage - privilege elevation through an inconsistently enforced boundary, not through breaking auth.

**Finding:** The webhook boundary, once more: if a successful spoofed "payment succeeded" event (from the Spoofing finding above) also grants an *admin* role rather than just a paid plan - because some setup script uses the same webhook path for both - one weak boundary elevates privilege far past what "faked a payment" should buy an attacker.

Notice the payment webhook shows up in four of six categories. That repetition is the method working as intended - STRIDE doesn't spread your attention evenly, it concentrates it on the boundaries doing the most trust-shifting - exactly what Phase 1's boundary-marking surfaces.

```quiz
[
  {
    "q": "Why does the payment webhook boundary generate findings across four different STRIDE categories in this walkthrough?",
    "choices": ["Because webhooks are always the least secure part of any system", "Because it's a boundary where an external system sends commands that change internal state, which concentrates risk", "Because STRIDE requires every boundary to have at least four findings"],
    "answer": 1,
    "explain": "A boundary where an outside party can trigger state changes (not just send data) tends to concentrate risk across spoofing, repudiation, DoS, and elevation - that's the signal STRIDE is designed to surface."
  },
  {
    "q": "A share link uses a short, sequential token. Which STRIDE category does that finding fall under?",
    "choices": ["Tampering - an attacker can guess/increment into another user's data", "Denial of service - short tokens run out faster", "Repudiation - the user can deny sharing the link"],
    "answer": 0,
    "explain": "Guessing or incrementing into a resource you shouldn't be able to reach is a tampering finding - you're modifying (here, gaining) access to data through a boundary that should have blocked it."
  },
  {
    "q": "Why is 'we have no log for X' a legitimate finding under Repudiation, even though there's no faulty code to point to?",
    "choices": ["It isn't - repudiation only applies to authentication bugs", "Repudiation findings are about missing evidence, not broken logic - an absence of logging is itself the gap", "Logging is a performance concern, not a security one"],
    "answer": 1,
    "explain": "Repudiation is about accountability: if an action can't be tied to who did it, that's a real gap even when every other control worked correctly."
  }
]
```


---

# From Threats to Action

Phase 2 produced roughly a dozen findings from one small app. A real system - more endpoints, more integrations, more years of accumulated code - produces dozens to hundreds. Treating every finding as equally urgent is how a threat model turns into a document nobody acts on. The output of threat modeling isn't the list; it's what you fix, in what order, and why.

## Likelihood times impact, not a flat list

For each finding, ask two questions separately: how likely is someone to attempt this, and how bad is it if they succeed? Skipping the first question is the most common mistake - it's easy to fixate on the scariest-sounding outcome and ignore how hard it is to reach.

Rating the file-sharing findings from Phase 2 this way:

| Finding | Likelihood | Impact | Priority |
|---|---|---|---|
| Sequential/guessable share-link tokens | High - no skill required, just enumeration | High - direct access to other users' files | Fix now |
| Unsigned/unverified payment webhook | Medium - requires finding the URL, but public webhook paths are guessable | High - free upgrades, possible role elevation | Fix now |
| No upload size/quota limit | High - any logged-in user can trigger it | Medium - service degradation, cost, not data loss | Fix soon |
| Client-supplied `uploaded_by` field | Low - narrow UI-spoofing impact, not account takeover | Low-Medium - confusing, not damaging | Backlog |
| 404-vs-403 timing/status leak on expired links | Low - needs targeted enumeration | Low - confirms a guess, doesn't grant access | Backlog |

The sequential token and the unverified webhook both land in "fix now" - not because they sound the most technical, but because both are cheap for an attacker to attempt and both hand over something valuable. The `uploaded_by` spoofing finding is real and worth a ticket, but putting it ahead of the webhook issue because it was "creepier" to write up would be optimizing for narrative instead of risk.

This is also where you decide what's not worth fixing at all. A finding with low likelihood and low impact - say, a theoretical timing difference in a rarely-used endpoint - can sit in a backlog indefinitely without that being negligence. Threat modeling that produces an unprioritized wall of findings is often worse than not doing it, because it buries the two things that matter under twenty things that don't.

## Turning a finding into a fix

A threat-model finding names a gap; it doesn't specify the fix, and that's a feature - the fix usually already exists as a known pattern.

- **Sequential share-link tokens** → generate tokens as long, random, unguessable strings (a UUIDv4 or a securely-random 128-bit value, not an auto-incrementing ID). This is a straight application of the access-control thinking in [authentication vs. authorization](/guides/auth-vs-authz): a share link is a bearer credential, and it needs the same unguessability you'd demand of a session token.
- **Unverified payment webhook** → verify the payment provider's signature on every webhook request before trusting the payload, and reject anything that doesn't match. This maps directly onto the broken-access-control and injection-adjacent thinking in [the OWASP Top 10](/guides/owasp-top-10) - an unverified webhook is an unauthenticated input being treated as a trusted command.
- **No upload quota** → enforce size and rate limits server-side, tied to the account, not just the request. Straightforward capacity control, not a novel defense.
- **Client-supplied identity field** → derive `uploaded_by` from the authenticated session server-side, never from form data. Never trust a client to tell you who it is when you already know.

Each fix is small. What made them findable wasn't cleverness at the fix stage - it was systematically asking the STRIDE questions at every boundary in Phase 2, instead of relying on a scan or a hunch to happen to point at the same spot.

## What threat modeling doesn't do

Threat modeling finds *design-level* gaps: missing checks, wrong trust assumptions, boundaries nobody drew before. It does not verify that the code implementing the fix is correct. You can threat-model perfectly, decide "verify the webhook signature," and still ship a broken implementation - comparing signatures with `==` instead of a constant-time comparison, or checking the signature but not the payload it was computed over. That bug is invisible to a threat model and exactly what code review and testing exist to catch.

The reverse gap matters too: code review reads the code you wrote and rarely questions why a component exists at all or whether a boundary was drawn correctly, since by the time there's a diff to review, the architecture is already a given. A threat model happens earlier and asks a different question - not "is this code correct" but "is this design missing a check somewhere." Run both. Neither substitutes for the other, or for testing the running system.

Treat a threat model as a living document, not a one-time exercise. Re-run STRIDE against new boundaries whenever the diagram changes - a new integration, a new endpoint that crosses an existing boundary in a new way, a new "internal" service that turns out not to be as internal as assumed.

```quiz
[
  {
    "q": "Why rate findings on likelihood AND impact separately, instead of just impact?",
    "choices": ["Likelihood doesn't matter for security decisions", "A high-impact finding that's very hard to actually exploit may be lower priority than a medium-impact one that's trivial to trigger", "Impact is always higher priority regardless of likelihood"],
    "answer": 1,
    "explain": "Fixing purely by worst-case impact ignores how attackers actually spend effort - cheap-to-exploit, high-value findings deserve to jump the queue over rare, hard-to-reach ones."
  },
  {
    "q": "A threat model correctly flags 'verify the payment webhook signature' as a needed fix. The team implements it, but uses a non-constant-time string comparison that leaks timing information. Who should catch that?",
    "choices": ["The threat model should have caught it, since it found the webhook issue", "Code review and testing - implementation correctness is outside what a threat model checks", "No one; timing leaks in signature checks aren't a real risk"],
    "answer": 1,
    "explain": "Threat modeling operates at the design level - it says a check is missing, not whether a given implementation of that check is correct. That's code review's job."
  },
  {
    "q": "Why should a threat model be re-run rather than treated as done after the first pass?",
    "choices": ["Because STRIDE categories change over time", "Because new components and boundaries (integrations, endpoints, services) introduce risk the original diagram never covered", "Because compliance audits require a new document every quarter"],
    "answer": 1,
    "explain": "A threat model is only as complete as the diagram it's built on - once the architecture changes, the old diagram no longer reflects reality and needs revisiting."
  }
]
```

## Your turn: ship day, twelve findings, one ranked table

Reading Phase 2's findings is the easy part. Deciding what actually gets fixed before the deploy is the job -
and every hour you spend on one fix is an hour you don't have for anything else on the list. There's no single
right answer below and nothing is scored right or wrong, but the clock is real. Ship with your top risks
closed, then read the debrief.

```scenario
{
  "title": "Ship day: twelve findings, one ranked table",
  "brief": "Phase 2 left you with twelve STRIDE findings on the file-sharing app, already ranked by likelihood and impact. The release ships today. Every hour you spend on one fix is an hour you don't have for the deploy, for testing, or for anything else on the list.",
  "prompt": "Which fixes do you commit to?",
  "clock": { "unit": "hr", "running": "before ship", "resolved": "shipped" },
  "resolvedHeading": "Shipped. Here's what went out the door.",
  "actions": [
    {
      "id": "fix-tokens",
      "label": "Rewrite share-link tokens as unguessable strings",
      "cost": 2,
      "reveals": "before: token = str(next_share_id)\nafter:  token = secrets.token_urlsafe(32)",
      "note": "Sequential tokens were the single highest combined risk on Phase 2's table: no skill required, direct access to someone else's files."
    },
    {
      "id": "fix-webhook",
      "label": "Verify the payment webhook signature",
      "cost": 2,
      "reveals": "before: def handle_webhook(payload):\n            process(payload)\nafter:  def handle_webhook(payload, signature):\n            verify_signature(payload, signature, WEBHOOK_SECRET)\n            process(payload)",
      "note": "This is the boundary that showed up in four different STRIDE categories in Phase 2. Verifying it closes the other 'fix now' item."
    },
    {
      "id": "fix-quota",
      "label": "Add a per-account upload size and rate limit",
      "cost": 2,
      "reveals": "before: no limit on the upload endpoint\nafter:  @rate_limit(max_bytes=500_000_000, per=\"account\", window=\"1d\")",
      "note": "Real, and rated 'fix soon' - high likelihood, but medium impact. It degrades service, it doesn't hand over anyone's files."
    },
    {
      "id": "fix-spoofing",
      "label": "Fix the uploaded_by field to derive identity from the session",
      "cost": 1,
      "reveals": "before: uploaded_by = form.get(\"uploaded_by\")\nafter:  uploaded_by = session.user.id",
      "note": "Rated 'backlog' on Phase 2's table - low likelihood, low-to-medium impact. Worth a ticket. Not worth your ship-day hours."
    },
    {
      "id": "audit-elevation",
      "label": "Trace every code path that can grant premium or admin access",
      "cost": 4,
      "reveals": "$ grep -rn 'user.plan ==' app/\napp/storage.py:44:      if user.plan == \"premium\":\napp/bulk_import.py:118: if user.plan == \"premium\":   # added last quarter, no test coverage\napp/admin_tools.py:9:    # TODO: this endpoint predates the plan check entirely",
      "note": "Genuinely useful, and it turns up more than Phase 2 found. It also doesn't close a single 'fix now' item, and it's the most expensive thing on this list."
    },
    {
      "id": "write-priority-doc",
      "label": "Write a document ranking all twelve findings by risk",
      "cost": 1,
      "reveals": "THREAT-MODEL-PRIORITY.md (draft)\n| Finding | Likelihood | Impact | Priority |\n|---|---|---|---|\n| Sequential tokens | High | High | Fix now |\n| Unverified webhook | Medium | High | Fix now |\n| ... | | | (same ranking as Phase 2's table) |",
      "note": "The ranking you'd be writing already exists - it's Phase 2's own table. This produces a document, not a fix."
    },
    {
      "id": "ship",
      "label": "Ship the release with whatever's fixed",
      "cost": 0,
      "resolves": true,
      "reveals": "$ git push origin release/2026-07-17\n$ ./deploy.sh\ndeploy: release/2026-07-17 -> production ... done",
      "note": "Whatever you closed is closed. Whatever's still open ships with you."
    }
  ],
  "debrief": {
    "ideal": 4,
    "text": "The output of a threat model isn't the list, it's what you fix. The two moves worth your hours were already sitting in the 'fix now' row of Phase 2's table: the sequential tokens and the unverified webhook, both cheap for an attacker to try and both worth real money or real data. The uploaded_by field is a genuine finding too, but reaching for it first because it felt creepier to write up is optimizing for narrative instead of risk - exactly the trap Phase 3 names. Ship with the top two closed; the rest sits in the backlog on purpose, not from neglect.",
    "notes": [
      { "when": "if-taken", "action": "audit-elevation", "text": "The audit was real work and it found real paths. It also spent four of your hours finding more problems instead of fixing the two you already knew were fix-now." },
      { "when": "if-taken", "action": "fix-spoofing", "text": "Fixing uploaded_by wasn't wasted work, but Phase 2's own table rated it low-likelihood, low-impact. Shipping it ahead of the webhook signature check is choosing the finding that read worse over the one that costs the most." },
      { "when": "if-taken", "action": "write-priority-doc", "text": "Re-ranking the findings feels like progress. It fixes nothing - the ranking was the output of Phase 2 and Phase 3, not a new step you needed to take today." },
      { "when": "if-not-taken", "action": "fix-tokens", "text": "You shipped without touching the share-link tokens. They're sequential, guessable, and rated high-likelihood/high-impact for a reason: anyone who noticed could enumerate their way into someone else's files with no login at all." },
      { "when": "if-not-taken", "action": "fix-webhook", "text": "The webhook still trusts any request that hits the right URL. That's the boundary Phase 2 flagged in four separate STRIDE categories, and it shipped unverified." }
    ]
  }
}
```
