Security Advanced

Threat Modeling for Web Applications: STRIDE, Attack Trees, Abuse Cases, and Security Requirements

Model a multi-tenant SaaS app from data-flow diagram and trust boundaries through STRIDE, attack trees, abuse cases, and risk rating, then turn the results into testable requirements and CI checks.

KC Ramo DjangoZen Team Nov 05, 2026 20 min read 3 views

A threat model is a structured argument about what can go wrong with a system and what you will do about it. It is useful only if it changes a design, a backlog, or a test suite. This guide walks one realistic multi-tenant SaaS application from data-flow diagram through STRIDE, attack trees, abuse cases, and risk rating, and ends with requirements that run in CI and a process that keeps the model current.

What a threat model is for

Most threat modeling reduces to four questions: what are you working on, what can go wrong, what are you going to do about it, and did you do a good enough job? Diagrams answer the first. STRIDE, attack trees, and abuse cases answer the second. Risk rating and requirements answer the third, and tests and reviews answer the fourth.

Do it when decisions are still cheap, during design rather than after release. A model written the week before an audit rarely changes anything.

Keep the scope small. A two-hour session on one feature with the engineers who built it produces more than a week-long review of everything.

The sample application

The running example is a multi-tenant invoicing SaaS at app.example.com. Businesses sign up as tenants, invite members, create invoices, and send them to their own customers by email. A customer pays through a hosted payment provider, which notifies the application by webhook. Support staff can impersonate a tenant member to troubleshoot.

  • Assets: invoice data and customer contact details, bank details on invoices, payment status, session tokens, the webhook signing secret, and the reputation of the sending domain.
  • Tenancy: all tenants share one database and each row carries a tenant_id.

Write assets down before threats, so the session stays focused on harm to something specific. Sending reputation is easily forgotten: nobody lists it as data, yet losing it stops the core product from working.

Data-flow diagrams and trust boundaries

A data-flow diagram (DFD) shows how data moves, not how code is structured. It has five element types: external entities, processes, data stores, data flows, and trust boundaries. Keep it as text in the repository, so changes appear in code review. Mermaid renders in most code hosting tools:

flowchart LR
  user([Tenant member])
  recipient([Invoice recipient])
  staff([Support staff])
  payments([Payment provider])
  mail([Email provider])

  subgraph tb1 [TB1 Edge]
    proxy[P1 Reverse proxy]
  end

  subgraph tb2 [TB2 Application network]
    api[P2 API]
    worker[P3 Worker]
    console[P4 Staff console]
  end

  subgraph tb3 [TB3 Data tier]
    db[(D1 PostgreSQL)]
    queue[(D2 Redis)]
    files[(D3 Object storage)]
  end

  user -- F1 HTTPS session --> proxy
  payments -- F2 Signed webhook --> proxy
  proxy -- F3 Forwarded request --> api
  api -- F4 SQL --> db
  api -- F5 Job payload --> queue
  queue -- F6 Job payload --> worker
  worker -- F7 SQL --> db
  worker -- F8 Generated PDF --> files
  worker -- F9 Invoice email --> mail
  mail -- F10 Link to invoice --> recipient
  recipient -- F11 Signed URL download --> files
  staff -- F12 SSO session --> console
  console -- F13 Impersonation request --> api

Finding trust boundaries

A trust boundary is any place where data or control passes between parties with different privileges or different owners. Every flow that crosses one needs a stated answer to two questions: how is the sender authenticated, and how is the input validated?

Network zones are the obvious boundaries, and they are not the most important ones here. Three logical boundaries do not appear as boxes on the diagram, so record them next to it:

  • Tenant boundary. Tenant A's data sits beside tenant B's in the same tables. The API process enforces this separation on every query. It is the application's primary security property, and the diagram cannot show it.
  • Role boundary. Members, tenant admins, and support staff reach the same process with different rights.
  • Job boundary. A queued job carries a request's intent into a process that has no request context. Authorization decisions made in the API do not automatically hold in the worker.

A common mistake is trusting the application network because it is private. A compromised dependency in the worker sits inside TB2, so ask what each component could do if a neighbor were hostile.

A STRIDE walkthrough

STRIDE is a prompt list. Each letter is a type of threat and the opposite of a security property.

ThreatProperty violatedPrompt question
SpoofingAuthenticationCan someone claim to be another user or system?
TamperingIntegrityCan data or code be changed without authorization?
RepudiationNon-repudiationCan someone deny an action, with no evidence to show otherwise?
Information disclosureConfidentialityCan data reach someone who should not see it?
Denial of serviceAvailabilityCan the service be degraded for others?
Elevation of privilegeAuthorizationCan someone gain rights they were not given?

Apply it per element. External entities mainly attract spoofing and repudiation. Processes attract all six. Data stores and data flows attract tampering, disclosure, and denial of service, and logs add repudiation. Do not argue about which letter a threat belongs to. The categories exist to generate ideas.

One flow in depth: the payment webhook

Start with the flow where a wrong assumption costs the most. F2 changes an invoice to paid, comes from the internet, and has no user session.

  • Spoofing. Anyone who finds the URL can POST to it. A forged "paid" event releases goods or closes a debt without payment. Verify an HMAC signature over the raw body with a per-environment secret.
  • Tampering. TLS protects the connection, but a replayed genuine event still changes state. Include a timestamp in the signed material, and record processed event IDs under a unique constraint so a replay within the window has no effect.
  • Repudiation. When a tenant disputes a payment status, you need the stored event, its ID, and the result of processing. Keep them.
  • Information disclosure. Detailed error responses tell a caller which invoice IDs exist. Return a uniform error, and log the detail internally.
  • Denial of service. Limit body size at the proxy, verify the signature before parsing, and acknowledge quickly by moving the work to a job.
  • Elevation of privilege. The handler runs with system rights across tenants. If it trusts a tenant_id from the payload, a valid event can be aimed at the wrong tenant. Resolve the tenant from the provider's own payment reference stored at checkout.

Threat list for the application

Repeating that exercise over the diagram gives a list like this one, reduced here to the most instructive rows.

IDElementTypeThreatPrimary control
T-001F2SForged payment webhook marks an invoice paidSignature, timestamp, event ID dedupe
T-003P2TClient sets status or total through an update endpointExplicit writable fields, server-side state transitions
T-004D2TQueue access lets a neighbor inject or alter jobsAuthenticated queue, JSON payloads with IDs only, worker rechecks
T-005P4R, EImpersonation with no record of who acted or whyMandatory reason, append-only audit, tenant-visible log
T-006P2IObject lookup returns another tenant's invoiceTenant-scoped queries, row-level security
T-007F11ISigned PDF URL stays valid after being forwarded or loggedShort expiry, reissue through an authorized page
T-008P3DOne tenant's bulk export starves the job queuePer-tenant concurrency limits, separate queues
T-009P3DTrial accounts use invoice email as a bulk mail relaySend caps, sender verification
T-010P2EMember invites themselves with a higher roleServer-side role policy on invite and role change
T-011P3EWorker fetches a tenant-supplied logo URL and reaches internal servicesAllow-list, block private ranges, egress proxy

Record what you considered and dismissed as well. "Tampering on F4: TLS with verified certificates, database in a private network" tells the next reader the question was asked. An empty cell cannot show that.

Attack trees

STRIDE gives breadth. An attack tree gives depth on one goal by decomposing it into alternatives. The root is what the attacker wants. Children of an OR node are alternatives, and children of an AND node must all succeed. Use trees sparingly, on the two or three goals that would seriously harm the business.

A tree shows what a flat list hides. An AND branch needs only one strong control, while an OR node needs every alternative covered. The script below holds the tree for the goal "read another tenant's invoices" and shows how the weakest path moves as controls land. Effort scores are team judgment on a 1 to 5 scale, used only to compare branches of the same tree.

from dataclasses import dataclass, field

BLOCKED = 10**6


@dataclass
class Node:
    label: str
    kind: str = "leaf"  # "leaf", "or", "and"
    effort: int = 0     # leaves only
    mitigated: bool = False
    children: list["Node"] = field(default_factory=list)


def cheapest(node):
    """Return the lowest total effort for this node and the leaves used."""
    if node.kind == "leaf":
        return (BLOCKED, []) if node.mitigated else (node.effort, [node.label])
    results = [cheapest(child) for child in node.children]
    if node.kind == "or":
        return min(results, key=lambda result: result[0])
    total = sum(cost for cost, _ in results)
    if total >= BLOCKED:
        return BLOCKED, []
    return total, [leaf for _, leaves in results for leaf in leaves]


unscoped = Node("Reach a tenant-unscoped code path", "or", children=[
    Node("API object lookup lacks tenant filter", effort=2),
    Node("Export job accepts a foreign invoice ID", effort=2),
])
link = Node("Obtain a valid download link", "or", children=[
    Node("Long-lived signed URL leaked in logs", effort=3),
    Node("Signed URL forwarded by a recipient", effort=2),
])
member = Node("Act as a member of the victim tenant", "and", children=[
    Node("Phish credentials of a member", effort=3),
    Node("Pass or bypass MFA", effort=4),
])
staff = Node("Abuse staff access", "and", children=[
    Node("Compromise a support account", effort=4),
    Node("Open impersonation without tenant consent", effort=2),
])
tree = Node("Read another tenant's invoices", "or",
            children=[unscoped, link, member, staff])

print(cheapest(tree))
for leaf in unscoped.children:
    leaf.mitigated = True
print(cheapest(tree))

The first call returns the missing tenant filter at effort 2. With both unscoped paths mitigated, the second call returns the forwarded signed URL, also at effort 2. Closing the code path alone leaves the same exposure through another branch, so T-007 belongs in the same work as T-006.

Trees are only as complete as the team's imagination, and they say nothing about detection. Use the output to direct discussion, and do not report the number as a measurement.

Abuse cases

STRIDE tends to find bugs. Abuse cases find features working exactly as built and harming someone. The actor is often a paying, authenticated user, so firewalls and input validation do not apply. Take a user story and ask who would want to misuse it.

ActorFeatureAbuseHarmResponse
Throwaway trial tenantSend invoice by emailSends fake invoices with payment instructions from a trusted domainFraud against third parties, domain reputation lossDaily recipient cap, verified sender, restricted free-text on trial plans
Departing memberCustomer exportDownloads the full customer list before leavingData loss for the tenantExport audit event, admin notification, optional role restriction
Support agentImpersonationBrowses an acquaintance's business recordsPrivacy breach, loss of customer trustRequired ticket reference, tenant-visible log, periodic review

Scenario form reads directly as an acceptance test. Describe the system's expected response, not the steps of an attack.

Feature: Invoice email cannot act as a bulk mail relay

  Scenario: Trial tenant reaches its daily recipient cap
    Given a tenant on the trial plan
    And the tenant has reached its configured daily recipient cap
    When a member sends an invoice to a new recipient
    Then the send is rejected with status 429
    And a "send_cap_reached" event is recorded for the tenant

  Scenario: Paid tenant with a verified sender is unaffected
    Given a tenant on a paid plan with a verified sender
    When a member sends an invoice to a new recipient
    Then the invoice email is queued

The second scenario matters. Abuse controls fail when they damage legitimate use, so each one needs a false-positive case and a way for a real customer to get unblocked.

Risk rating and its limits

Rating exists to order work and force explicit decisions. A simple three-level scale, with each level tied to a concrete definition, is enough for most teams.

Likelihood / ImpactLow: one user, minorMedium: one tenant, or regulated data at small scaleHigh: many tenants, money, or legal notification
High: reachable by any member or anonymously, little skillMediumHighHigh
Medium: needs an account plus specific conditionsLowMediumHigh
Low: needs privileged access or a chain of failuresLowLowMedium

Under this scale, T-006 is High, because any member can attempt it and the impact crosses tenants. T-008 is Medium. Then choose one of four treatments: eliminate the feature or data, mitigate, transfer, or accept. Acceptance needs a named person with the authority to carry the risk, a rationale, and an expiry date. "Accepted by the team" with no date means forgotten.

Where ratings mislead

  • False precision. Likelihood and impact are ordinal. Multiplying a 3 by a 4 produces a number that looks like a measurement and is not one.
  • Likelihood is mostly a guess. Ask whether any member could do this today, not how probable an attack is.
  • Chains are invisible. Two Low findings can combine into a serious path. Trees exist for this reason.
  • Anchoring. The first person to say "medium" shapes the room. Have participants vote independently.

Other methods have narrower uses. CVSS scores the technical severity of a specific known vulnerability, not the risk to your business. FAIR produces quantified loss estimates, and it is too heavy to apply per threat. DREAD is inconsistent between raters and has been widely dropped.

Turning threats into testable requirements

"The application must prevent IDOR" cannot fail a build. A good security requirement names the condition and the observable result, so a person can write a test for it without asking what it means.

IDFromRequirementVerified by
SR-001T-006A request for an object owned by another tenant returns 404 and changes nothing, on every tenant-scoped routeIntegration test per route
SR-002T-006The application's database role cannot read rows outside the current tenant, even if a query has no tenant filterTest with row-level security enabled
SR-003T-001A webhook is rejected unless its signature verifies and its timestamp is within 5 minutesUnit test
SR-004T-001Processing the same event ID twice changes state onceIntegration test, unique constraint

Those values are design choices for the sample app. For a coverage checklist, map requirements to the OWASP Application Security Verification Standard, and record the version, since section numbers change between versions.

Testing the webhook requirement

The verifier takes now as an argument, which makes the time window testable without patching the clock.

# billing/webhooks.py
import hashlib
import hmac
import time

MAX_SKEW_SECONDS = 300


def verify_signature(secret, body, timestamp, signature, now=None):
    now = time.time() if now is None else now
    try:
        sent_at = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(now - sent_at) > MAX_SKEW_SECONDS:
        return False
    signed = f"{sent_at}.".encode() + body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), signature.encode())

Encoding both sides matters: compare_digest raises TypeError on non-ASCII strings, which would turn a malformed header into a 500 error. Test the negative cases, because a verifier that returns True for everything passes any happy-path test.

# tests/test_webhooks.py
import hashlib
import hmac
import unittest

from billing.webhooks import MAX_SKEW_SECONDS, verify_signature

SECRET = b"test-only-secret"
BODY = b'{"event": "invoice.paid", "invoice": "inv_123"}'
NOW = 1_800_000_000


def sign(timestamp, body=BODY):
    signed = f"{timestamp}.".encode() + body
    return hmac.new(SECRET, signed, hashlib.sha256).hexdigest()


class WebhookSignatureTests(unittest.TestCase):
    def test_valid_signature_is_accepted(self):
        self.assertTrue(verify_signature(SECRET, BODY, str(NOW), sign(NOW), now=NOW))

    def test_modified_body_is_rejected(self):
        tampered = BODY.replace(b"inv_123", b"inv_999")
        self.assertFalse(
            verify_signature(SECRET, tampered, str(NOW), sign(NOW), now=NOW)
        )

    def test_timestamp_outside_window_is_rejected(self):
        old = NOW - MAX_SKEW_SECONDS - 1
        self.assertFalse(verify_signature(SECRET, BODY, str(old), sign(old), now=NOW))

    def test_non_ascii_signature_is_rejected_without_raising(self):
        self.assertFalse(verify_signature(SECRET, BODY, str(NOW), "caf\u00e9", now=NOW))

Testing tenant isolation

SR-001 says every route, so the test iterates over routes. A stronger version walks the URL configuration and fails on any tenant-scoped route missing from the list. The make_* helpers stand for your project's test factories.

# tests/test_tenant_isolation.py
from django.test import TestCase
from django.urls import reverse

from tests.factories import make_invoice, make_member, make_tenant

TENANT_ROUTES = ["invoice-detail", "invoice-pdf", "invoice-history"]


class InvoiceIsolationTests(TestCase):
    def setUp(self):
        self.invoice = make_invoice(tenant=make_tenant("alpha"))
        self.intruder = make_member(tenant=make_tenant("beta"))

    def test_foreign_invoice_is_not_found_on_every_route(self):
        self.client.force_login(self.intruder)
        for route in TENANT_ROUTES:
            with self.subTest(route=route):
                url = reverse(route, args=[self.invoice.public_id])
                self.assertEqual(self.client.get(url).status_code, 404)

Returning 404 instead of 403 avoids confirming that the object exists. For defense in depth (SR-002), add PostgreSQL row-level security, so a forgotten filter returns nothing:

ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoice
    USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
    WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);

Set the value per transaction with SELECT set_config('app.tenant_id', %s, true), the parameterized form of SET LOCAL, which also behaves correctly under transaction-mode connection pooling. A missing setting yields NULL, or on a reused connection an empty string whose cast raises an error, so the policy fails closed. Superusers and roles with BYPASSRLS skip policies entirely, so the application must not connect as one. Workers and migrations often run under a different role and need a deliberate plan.

Keeping the model alive

Most threat models die because they sit outside the workflow. Keep them where the code is, and let the build notice when they rot.

A register in the repository

# docs/threat-model/threats.yaml
- id: T-006
  title: Object lookup returns another tenant's invoice
  element: P2 API
  stride: [I]
  rating: high
  status: mitigated
  owner: team-billing
  requirement: SR-001
  test: tests/test_tenant_isolation.py::InvoiceIsolationTests

- id: T-009
  title: Trial accounts use invoice email as a bulk mail relay
  element: P3 Worker
  stride: [D]
  rating: medium
  status: accepted
  owner: head-of-engineering
  rationale: Daily cap limits exposure until sender verification ships.
  review_by: 2027-01-15

A short script in CI rejects common drift: mitigated threats with no test, expired acceptances, and high risks with no date. It needs PyYAML.

#!/usr/bin/env python3
# tools/check_threats.py
import datetime
import pathlib
import sys

import yaml

REQUIRED = {"id", "title", "element", "stride", "rating", "status", "owner"}
STATUSES = {"open", "mitigated", "accepted"}


def problems(entries, root, today):
    for entry in entries:
        tid = entry.get("id", "<no id>")
        if missing := REQUIRED - entry.keys():
            yield f"{tid}: missing {sorted(missing)}"
            continue
        status, review_by = entry["status"], entry.get("review_by")
        dated = isinstance(review_by, datetime.date) and review_by >= today
        if status not in STATUSES:
            yield f"{tid}: unknown status {status!r}"
        if status == "mitigated":
            test = entry.get("test", "")
            if not test or not (root / test.split("::")[0]).is_file():
                yield f"{tid}: mitigated but test file not found: {test!r}"
        if status == "accepted" and not dated:
            yield f"{tid}: risk acceptance is missing or expired"
        if status == "open" and entry["rating"] == "high" and not dated:
            yield f"{tid}: open high risk has no current target date"


def main():
    register, root = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
    entries = yaml.safe_load(register.read_text(encoding="utf-8"))
    found = list(problems(entries, root, datetime.date.today()))
    for problem in found:
        print(problem, file=sys.stderr)
    return 1 if found else 0


if __name__ == "__main__":
    sys.exit(main())

This proves a test file exists, not that it tests the right thing. Deleting a test now also requires changing the register, which puts the decision in front of a reviewer.

Fitting it into the team's workflow

  • Define triggers. Revisit the model when a change adds a trust boundary, an external integration, a class of sensitive data, or a privileged role, or changes authentication, authorization, or tenancy.
  • Add one question to the pull request template. "Does this change a data flow or trust boundary in the threat model? If yes, update the DFD and register."
  • Run small delta sessions. Thirty minutes on the changed part of the diagram suits most features.
  • Make the service team own it. Security staff facilitate. When they own the document, engineers stop reading it.
  • Feed incidents back. After every incident or penetration test finding, ask whether the model contained the threat. If not, add the row and find out why the method missed it.
  • Measure usefully. Track the share of high threats with an automated test and the age of accepted risks, not the number of threats found.

Watch for familiar failure modes: modeling the whole company at once, producing a list with no owners, treating a rating as the outcome, and writing the model without the people who build the system.

Verifying it works

Run these checks from the project root, or in CI on each pull request:

# Register is complete and no acceptance has expired
python tools/check_threats.py docs/threat-model/threats.yaml .

# Requirement tests for webhooks and tenant isolation
python manage.py test tests.test_webhooks tests.test_tenant_isolation

# Django deployment settings review
python manage.py check --deploy

# An unsigned webhook on staging must be rejected
curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
  https://staging.example.com/webhooks/payments -d '{}'

Confirm the database controls directly, from psql:

-- Row-level security is on and forced
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class WHERE relname = 'invoice';

-- The application role cannot bypass it
SELECT rolname, rolsuper, rolbypassrls
FROM pg_roles WHERE rolname = 'myapp';

Then check the model itself:

  • The DFD matches production, confirmed by someone who operates the system.
  • Every flow crossing a trust boundary states its authentication and validation.
  • Each process has been walked through all six STRIDE categories, and dismissed threats are recorded.
  • The most serious goals have attack trees, rechecked after mitigations.
  • Each privileged or high-volume feature has at least one abuse case, with a false-positive scenario.
  • Every high-rated threat has an owner and is mitigated with a named test, or has a dated plan or acceptance.
  • Each requirement states a condition and an observable result, and negative tests were seen to fail with the control temporarily removed.
  • Detection requirements have been exercised in a drill: the audit event appears and the alert reaches a person.

Found a mistake in this tutorial? Let us know and we will fix it.