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.
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.
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 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.
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.
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
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:
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.
STRIDE is a prompt list. Each letter is a type of threat and the opposite of a security property.
| Threat | Property violated | Prompt question |
|---|---|---|
| Spoofing | Authentication | Can someone claim to be another user or system? |
| Tampering | Integrity | Can data or code be changed without authorization? |
| Repudiation | Non-repudiation | Can someone deny an action, with no evidence to show otherwise? |
| Information disclosure | Confidentiality | Can data reach someone who should not see it? |
| Denial of service | Availability | Can the service be degraded for others? |
| Elevation of privilege | Authorization | Can 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.
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.
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.Repeating that exercise over the diagram gives a list like this one, reduced here to the most instructive rows.
| ID | Element | Type | Threat | Primary control |
|---|---|---|---|---|
| T-001 | F2 | S | Forged payment webhook marks an invoice paid | Signature, timestamp, event ID dedupe |
| T-003 | P2 | T | Client sets status or total through an update endpoint | Explicit writable fields, server-side state transitions |
| T-004 | D2 | T | Queue access lets a neighbor inject or alter jobs | Authenticated queue, JSON payloads with IDs only, worker rechecks |
| T-005 | P4 | R, E | Impersonation with no record of who acted or why | Mandatory reason, append-only audit, tenant-visible log |
| T-006 | P2 | I | Object lookup returns another tenant's invoice | Tenant-scoped queries, row-level security |
| T-007 | F11 | I | Signed PDF URL stays valid after being forwarded or logged | Short expiry, reissue through an authorized page |
| T-008 | P3 | D | One tenant's bulk export starves the job queue | Per-tenant concurrency limits, separate queues |
| T-009 | P3 | D | Trial accounts use invoice email as a bulk mail relay | Send caps, sender verification |
| T-010 | P2 | E | Member invites themselves with a higher role | Server-side role policy on invite and role change |
| T-011 | P3 | E | Worker fetches a tenant-supplied logo URL and reaches internal services | Allow-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.
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.
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.
| Actor | Feature | Abuse | Harm | Response |
|---|---|---|---|---|
| Throwaway trial tenant | Send invoice by email | Sends fake invoices with payment instructions from a trusted domain | Fraud against third parties, domain reputation loss | Daily recipient cap, verified sender, restricted free-text on trial plans |
| Departing member | Customer export | Downloads the full customer list before leaving | Data loss for the tenant | Export audit event, admin notification, optional role restriction |
| Support agent | Impersonation | Browses an acquaintance's business records | Privacy breach, loss of customer trust | Required 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.
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 / Impact | Low: one user, minor | Medium: one tenant, or regulated data at small scale | High: many tenants, money, or legal notification |
|---|---|---|---|
| High: reachable by any member or anonymously, little skill | Medium | High | High |
| Medium: needs an account plus specific conditions | Low | Medium | High |
| Low: needs privileged access or a chain of failures | Low | Low | Medium |
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.
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.
"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.
| ID | From | Requirement | Verified by |
|---|---|---|---|
| SR-001 | T-006 | A request for an object owned by another tenant returns 404 and changes nothing, on every tenant-scoped route | Integration test per route |
| SR-002 | T-006 | The application's database role cannot read rows outside the current tenant, even if a query has no tenant filter | Test with row-level security enabled |
| SR-003 | T-001 | A webhook is rejected unless its signature verifies and its timestamp is within 5 minutes | Unit test |
| SR-004 | T-001 | Processing the same event ID twice changes state once | Integration 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.
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))
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.
Most threat models die because they sit outside the workflow. Keep them where the code is, and let the build notice when they rot.
# 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.
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.
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:
Found a mistake in this tutorial? Let us know and we will fix it.