In short

Pick one sensitive operation, such as an invoice export, and trace tenant identity through every hop: request, API check, queued job, worker query, storage, cache and download. At each hop ask who chooses the tenant and what verifies it. Derive the tenant from verified membership, persist it in the job and recheck it when work runs. Write each threat as a causal path to a prohibited outcome, with an owner, a fix and a failing test.

  • A requested tenant is a claim; the authorized tenant comes from verified membership.
  • Persist the authorized tenant in the job record and recheck membership when the job runs and when its output is downloaded.
  • Put the tenant in every cache key, search filter and storage path, not only in the main query.
  • Model support access and tenant switching as separate, audited flows rather than hidden bypasses.
  • Walk STRIDE across each hop to find what the trace missed, and keep the model next to the code it describes.

Verify it

  1. Request another tenant's export as an authenticated member and confirm no job is created.
  2. Remove a member after a job is queued and confirm both the worker and the download deny.
  3. Seed the same local id in two tenants and confirm caches and search results never cross.
  4. Switch a multi-tenant user between tenants and confirm queued jobs keep their original tenant.
  5. Check that every threat record names a regression test that fails on the old design.
Applies to

Multi-tenant APIs, asynchronous work, caches and support tooling. The method needs the actual system diagram.

Part of
Learning path: AppSec in practice (step 1 of 18). Workflow: Review a design

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 26 minutes: 8 to read and 18 for the exercise

What you will learn

  • Trace tenant authority across asynchronous components.
  • Write threats as causal paths to prohibited outcomes.
  • Turn assumptions and revocation rules into tests.

Before you start

Architects, tech leads and developers designing multi-tenant features, and security reviewers running design reviews.

  • List tenants, actors, sensitive stores, and the services that move data between them.

Refresh first: Enforce authorization for every object action

Go to the guided practice

Tenant context across a request and background job

InternetApplicationShared storesCallerAPIExport workerData and cachecreateExport(requestedTenant)1Resolve actor;check storedownership2Job: requester A,authorized tenant red3Recheck permissionwhen the job runs3readInvoices(authorizedTenant=red)4Store the export,tagged tenant red,owner A4download(jobId)1Check membership, job owner and tenant2Trust boundaryUntrusted input1Step, described below
  1. Untrusted requestObject IDs and tenant hints are caller-controlled.
  2. Authenticated APIResolve the actor and authorize the action against stored ownership.
  3. Job boundaryCarry the approved scope; recheck current permissions when work executes.
  4. Data and cacheScope database, object-store and cache operations to the authorized tenant.
Each boundary needs an explicit decision. A tenant filter on one API does not cover exports, background jobs, support tools or shared caches.

Model one complete tenant-sensitive operation

A threat model becomes useful when it predicts a specific failure and leads to a testable design change. Start with an operation such as exporting invoices, not a diagram labeled user, cloud and database. Alice is a member of tenant red. She asks the API to create an export, a worker reads invoices, object storage holds the file, and a later download returns it. The asset is the invoice data; the prohibited outcome is any red request receiving blue records or a blue export file.

Mark who can choose each input. Alice controls request fields and timing. The identity service authenticates Alice. The application determines her active tenant memberships. The queue and worker transport that context, but neither should convert an arbitrary tenant string into permission. Storage keys and download ids select objects; they are not credentials. Include delayed execution and revocation in the scope because an export may run after its requester's membership changes.

Trace identity, tenant and purpose across every hop

In the vulnerable flow the API checks Alice is signed in and accepts body.tenant=blue. The worker trusts the queued tenant and runs SELECT * FROM invoices WHERE tenant='blue'. The download endpoint checks only that Alice created the job. Every component performs its local check, yet the combined system delivers blue data. The design lost the distinction between a requested tenant and a tenant Alice is authorized to use.

Repair the API by checking membership and deriving the accepted tenant from verified context. Persist requester, tenant, operation and authorization decision with the job. Give the worker a narrowly scoped data-access function that requires tenant, and ensure the export query, object metadata and download check agree on it. Decide whether long-running jobs recheck membership at execution, at delivery, or both. For this exercise, recheck at both points: removal from red stops a pending export and prevents later download of an existing one. This explicit rule makes revocation behavior testable. /domains/appsec/authorization/object-level-authorization/ shows the membership check in code.

Plain text
DATA-FLOW RECORD
Alice -> API: createExport(requestedTenant)
API -> jobs: {jobId, requester=A, authorizedTenant=red, purpose=invoices}
worker -> DB: readInvoices(authorizedTenant=red)
worker -> storage: {objectId, jobId, tenant=red, owner=A}
Alice -> download: jobId
download -> membership + job: allow only current member of job.tenant AND owner
Cache identity: (tenant, export filters, relevant policy scope)

Use STRIDE to find what the trace missed

A data-flow trace finds the threats you already suspect. A checklist method helps find the rest. STRIDE, from Microsoft, names six threat categories: spoofing, tampering, repudiation, information disclosure, denial of service and elevation of privilege. Ask each question at every hop in the record above, and write a threat only when you can state the causal path.

STRIDE organizes questions; it does not rank them. Prioritize by impact and by how plausible the path is, starting with cross-tenant data exposure. /domains/appsec/threat-modeling/threat-modeling-methods/ compares these methods. When tenant data includes personal data, LINDDUN adds privacy threats such as linking and identifying people. For larger systems, OWASP Threat Dragon draws the data-flow diagram, and OWASP pytm keeps the model as code next to the service so that a pull request can update both. PostgreSQL row-level security on tenant-scoped tables is useful defense in depth for the information-disclosure row, but it does not replace the membership check that chooses the tenant.

  • Spoofing: can a caller or an internal service claim another tenant, for example with a forged X-Tenant-Id header or a message on an unauthenticated queue?
  • Tampering: can the job payload, cache entry or object metadata change between the API decision and the worker?
  • Repudiation: can support staff read or export tenant data without a recorded grant, reason and audit event?
  • Information disclosure: can a cache key, search index, error message or download link return another tenant's data?
  • Denial of service: can one tenant's large export or retry storm starve the others, and are quotas set per tenant?
  • Elevation of privilege: can a tenant administrator or a support tool act outside its tenant?

Convert threats into falsifiable statements

An actionable threat is a causal sentence: a red member changes the tenant field to blue; the API passes it into the job without membership validation; the worker reads blue rows; the member obtains them through their own job id. Each clause points to evidence to inspect. 'Multitenancy is risky' does not tell an engineer what to change. Give each threat an owner, affected boundary, invariant, proposed control and a regression that would fail under the old design.

Do not stop at the database. A cache keyed only by invoice id can mix tenants with locally unique ids. A search index can return foreign titles even if a later document fetch denies access; the RAG lab at /labs/rag-isolation/ applies the same filter-before-retrieval rule to an AI retriever. A support tool may intentionally cross tenants but needs a distinct authorized support action, scoped selection and an audit trail. Model that exception explicitly so it cannot accidentally become the default rule for ordinary API traffic.

Keep the model small enough to maintain

The useful artifact is a short data-flow record plus a decision table linked to implementation and tests. Revisit it when a feature adds a new consumer, storage layer, tenant-switching capability or background job. A queue migration may preserve payload bytes while changing who can enqueue jobs. A cache optimization may remove the tenant from a key. Those are security-relevant changes even if no login code changes.

A threat model is not proof that implementation matches intent. Review the query and storage policy, inspect actual job payloads, and test the negative cases. Capture residual assumptions, such as the queue accepting messages only from the API workload or object storage remaining private. Where an assumption cannot be verified, record it as unresolved rather than marking the threat mitigated. Prioritize realistic cross-tenant data exposure before speculative low-impact abuse, while retaining the latter for later review.

Keeping the data-flow record as code makes that review part of the pull request. The OWASP pytm model below describes the export flow's actors, trust boundaries, stores and seven data flows; python3 export_model.py --dfd renders the diagram with Graphviz. Run against pytm 1.4.0, its generic rule library matched 118 candidate threats for these flows, most of them standard web and storage checks. That list is a prompt, not the model: the tenant-specific causal threats above still have to be written by hand, and a changed flow in the code should change this file in the same pull request.

export_model.pyPython
"""export_model.py: the invoice-export data flow as an OWASP pytm model (pytm 1.4).

python3 export_model.py --dfd | dot -Tpng -o export.png   # diagram
python3 export_model.py --list                             # the rule library pytm applies
"""
from pytm import TM, Actor, Boundary, Data, Dataflow, Datastore, Process

tm = TM("Invoice export")
tm.description = "A tenant member exports invoices; a worker builds the file later."
tm.isOrdered = True

internet = Boundary("Internet")
service = Boundary("Export service")

alice = Actor("Tenant member", inBoundary=internet)
api = Process("Export API", inBoundary=service)
queue = Datastore("Job queue", inBoundary=service)
worker = Process("Export worker", inBoundary=service)
invoices = Datastore("Invoice database", inBoundary=service)
files = Datastore("Export storage", inBoundary=service)

job = Data("Export job: requester, authorized tenant, purpose", isPII=False)
rows = Data("Tenant invoices", isPII=True)

Dataflow(alice, api, "createExport(requested tenant)")
Dataflow(api, queue, "job with the tenant from verified membership", data=job)
Dataflow(queue, worker, "job", data=job)
Dataflow(worker, invoices, "readInvoices(authorized tenant)")
Dataflow(invoices, worker, "rows for that tenant only", data=rows)
Dataflow(worker, files, "export file tagged with tenant and owner", data=rows)
Dataflow(files, alice, "download after a fresh membership check", data=rows)

if __name__ == "__main__":
    tm.process()

A real-world failure

ChaosDB (2021) broke tenant isolation in Azure Cosmos DB: Wiz researchers used the Jupyter Notebook feature to escalate into other customers' notebook containers and retrieve their database primary keys. The missing control was isolating tenants so one customer's compute could never reach another's secrets.

Review support and tenant-switching exceptions as separate flows

A threat model that omits legitimate cross-tenant support work often encourages developers to add a hidden bypass later. Model the support actor explicitly. A support session may select tenant blue only after a scoped grant, with a reason, expiry and audited action. Its service identity should not silently turn all ordinary sessions into cross-tenant readers. Test that a request lacking the support grant follows the normal membership rule even when it arrives from the same corporate network or application screen.

Tenant switching is another boundary. If a user belongs to red and blue, changing the active tenant should not leave red responses in a cache rendered under a blue page. The server still checks resource ownership against the selected authorized tenant, while the client clears or partitions tenant-specific data. A job created under red should retain red in its immutable job record; changing the user's active tenant later must not reinterpret the queued job as blue.

Record these cases as explicit model variants. For user M with memberships red and blue, create a red export J, switch M to blue, and execute J. Under this guide's policy, J still queries red and rechecks M's red membership; it must never query blue because a mutable session preference changed. If red membership is revoked before execution, J is denied. This small schedule exposes whether tenant context is durable authority or a mutable UI setting.

Guided practice

Review an invoice-export design using the supplied architecture and evidence.

Supplied scenario material
Plain text
Memberships: A in red only; B in blue only. DB: red/R1=40; blue/B1=90.
Original API: signedIn(A); enqueue({owner:A,tenant:request.body.tenant}).
Original worker: query invoices WHERE tenant=job.tenant; save file; mark ready.
Original download: allow if session.user == job.owner.
Request X: A asks tenant=red. Request Y: A asks tenant=blue.
Job J: authorized red export requested by A at 10:00; A removed from red at 10:01; worker starts 10:02.
Cache keys under proposal: invoice:{localId}; both tenants may have localId=7.
New policy: active membership at request, execution and download; owner-only download.
  1. 1. Trace normal and adversarial requests

    Follow X and Y through the original API, worker and download. List the records Alice receives.

    Expected result and reasoning

    X returns R1=40. Y returns B1=90 because the worker accepts the user-selected tenant and the download only proves job ownership. This is a cross-tenant disclosure despite successful authentication.

  2. 2. Place the earliest repair

    Choose the first boundary that must reject Y and identify later defense checks.

    Expected result and reasoning

    The API denies blue because A has no blue membership and creates no job. The worker uses recorded authorized tenant, and download checks that tenant plus owner and current membership. Each layer has a distinct purpose.

  3. 3. Apply revocation semantics

    Evaluate J at 10:02 and a red file created before 10:01 but downloaded after removal.

    Expected result and reasoning

    The worker cancels or denies J before querying red. The earlier file also cannot be downloaded by A after removal. These outcomes follow the stated policy; a different product rule would need explicit review.

  4. 4. Write two threat records

    Create one record for body.tenant and one for invoice:7. Include trigger, violated rule, fix and expected test result.

    Expected result and reasoning

    The first record requires Y to create zero jobs. The second requires a tenant-qualified cache key or equivalent isolation: red/7 can never satisfy blue/7. Both records name a measurable boundary rather than a generic security concern.

Your deliverable: A data-flow record and two threats with concrete failing inputs, owners and post-fix outcomes.

Paper exercise: memberships, data, job timing and cache keys are all supplied. Apply the same trace to your own system diagram afterwards.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Threat Modeling
  2. Microsoft: identity in multitenant solutions
  3. Microsoft Threat Modeling Tool: STRIDE threat categories
  4. LINDDUN privacy threat types
  5. OWASP Threat Dragon
  6. OWASP pytm: threat modeling as code
  7. Threat Modeling Manifesto
  8. Wiz: ChaosDB, cross-tenant access in Azure Cosmos DB (2021). 26 August 2021.

Reference information

SDLC phase
Design
ASVS 5.0.0 requirements
v5.0.0-8.4.1

Download this guide as Markdown