Every multi-tenancy bug we have ever chased down was a variation on one thing: data crossing a tenant boundary it should not have crossed. The interesting part isn't the concept, every engineer building a multi-tenant SaaS app already knows tenants must not see each other's data. The interesting part is that the boundary breaks in a small number of specific, recurring ways, and most teams only write a test for the one that already bit them.
Here are the four that ship most often in production multi-tenant codebases, and the exact test that catches each. If you've read our multi-tenant SaaS testing playbook, this is the companion catalog: less "here's the framework," more "here's the bug, and here's the test with your name on it."
Why These Four, Specifically
Tenant scoping is a cross-cutting concern. It doesn't live in one file. It has to be re-applied correctly at every query, every new endpoint, every queued job, and every cache read, by every engineer, on every PR, forever. A cross-cutting concern that depends on a human remembering the same line of code in twenty different places is exactly the kind of thing that eventually gets forgotten once, and once is enough.
The four failure modes below aren't arbitrary. They map to the four places tenant context has to survive: the request path (did the handler resolve the tenant from the right place), the query layer (did the query carry the filter), the boundary where request context ends (did the job payload keep the tenant id when the work left the request), and the layer that exists purely for speed (did the cache key remember there's more than one tenant at all). Here's the shared fixture the four tests below reuse, two tenants seeded with their own users and invoices:
And here's the tenant-scoped module the tests exercise: a repository function, an HTTP route handler, a job queue, and a cache, all real, all small enough to read in one pass:
Every multi-tenancy failure is the same shape: data crossing a tenant boundary it was never supposed to cross.
Failure Mode 1: The Cross-Tenant Read
The bug: a request authenticated as Tenant A asks for a resource by ID, and the ID happens to belong to Tenant B. The handler resolves the resource and returns it. Tenant A now has Tenant B's invoice, customer record, or support ticket, in a 200 response that looks completely normal in the access logs.
This is the classic IDOR (insecure direct object reference) pattern, just wearing a tenant boundary instead of a user boundary. It ships because the handler trusts the ID more than it trusts the session. Somewhere in the code, a lookup does findById(invoiceId) instead of findById(invoiceId, sessionTenantId), or the tenant gets read from a path param or request body that the client controls, instead of from the authenticated session that it doesn't.
The test that catches it authenticates as Tenant A, requests a resource ID that belongs to Tenant B, and asserts the response is a 404 (or 403), never a 200 with Tenant B's data mixed in. Run it against every resource-by-ID endpoint your app exposes, not just the one that already had an incident.
This test belongs in the same CI stage as your other authorization tests, and it should run on every PR that touches a resource-by-ID route, not just on a scheduled security sweep. By the time a cross-tenant read reaches a scheduled scan, it has usually already reached a customer.
Failure Mode 2: The Query That Forgot tenant_id
The bug is quieter than the first one. A new report, a new admin endpoint, a migration script, or an aggregate query gets written, and the developer forgets to add the WHERE tenant_id = ? predicate. Nothing throws an error. The query runs, returns rows, and the endpoint ships. It just happens to return every tenant's rows instead of one.
This is the one that scares experienced multi-tenant engineers the most, because it's not a logic error you'd catch by reading the code twice. It's an omission, and omissions don't show up in a diff the way a wrong comparison operator does. Manual tenant scoping means every single query in the codebase is a place this can happen, and the one place a developer forgets is the one place the entire tenant boundary silently disappears for that query.
The test that catches it seeds two tenants with a deliberately different number of rows, runs the query as one tenant, and asserts two things: the result count matches only that tenant's seeded rows, and no row in the result set has an ID that belongs to the other tenant. Both assertions matter. A query that unions every tenant's data fails the count check. A query that filters on the wrong column, or accidentally scopes by user instead of tenant, can still pass a naive count check while returning the wrong rows, which is why the membership check is not optional.
Beyond the test itself, the durable fix is structural: push tenant scoping into a repository layer that every query has to go through, the way the module above does, so "did this developer remember the filter" stops being a per-query question and becomes a property of the abstraction. Some teams go further and add a static check or lint rule that flags any raw query against a tenant-scoped table without a tenant predicate. Either way, this test is the regression guard that tells you the moment someone bypasses the repository and writes a raw query directly.
These Tests Only Tell the Truth on an Isolated Environment
Here's the part that trips up teams who write all four tests correctly and still get burned: a leak test's result is only as trustworthy as the data it runs against. On a shared staging database, a cross-tenant leakage test can pass for the wrong reason, the other tenant's data simply isn't present in that run, so there's nothing to leak. Or it can fail for the wrong reason, a parallel test run seeded Tenant B's data into rows that collide with what your test expected for Tenant A, and now you're debugging a false positive that has nothing to do with your code.
Both failure directions come from the same root cause: shared state between test runs. The fix isn't a smarter assertion, it's giving each run its own isolated tenant data so the test is checking your code's behavior, not the leftover state of whoever ran last. That's a big enough topic that we cover how to isolate test data per tenant and the broader question of multi-tenant test data isolation as their own pieces. The short version: these four tests are necessary but not sufficient. They need a clean, per-run, two-tenant dataset underneath them, or they'll lie to you in both directions.
A tenant-leak test only tells the truth when it runs against isolated, per-run tenant data, not a shared database where results pass or fail by accident.
How Autonoma Closes the Gap Hand-Written Leak Tests Leave Open
Everything above this section is correct and worth writing by hand. It's also two separate maintenance burdens stacked on top of each other. First, every new endpoint, query, job, and cache key needs its own tenant-boundary assertion, and that assertion rots quietly as the schema changes, a renamed column or a refactored repository can silently stop a test from testing what it used to test. Second, as the previous section covers, these tests are only honest when they run against isolated tenant data, which most teams solve with ad hoc seed scripts that drift from what production actually looks like.
Autonoma's testing agents were built around exactly this shape of problem: the Planner reads the codebase (routes, queries, jobs, cache usage) and plans test cases from what it finds, including the cross-tenant boundary cases a repository or endpoint implies. The Executor runs those tests against a live preview environment. The Reviewer separates a real cross-tenant leak from an agent error or a stale test expectation, so a schema change doesn't just produce a wall of red. The Diffs Agent updates the suite on every PR by reading the code diff, so a new endpoint or a new query gets its tenant-boundary test added automatically instead of depending on someone remembering to write one.
The isolated-data half of the problem is what PreviewKit, Autonoma's managed full-stack preview-environment product, is for. It provisions a complete preview per pull request, backend, database, and services, with tenant data already isolated for that run, which is the precondition the section above just spent several paragraphs establishing. PreviewKit is not a database vendor and it's not a lint rule; it's the environment these tests need in order to tell the truth, with the tests themselves generated and maintained against it. The contrast isn't "hand-written tests are wrong." It's that hand-written tests plus shared staging data is a combination that looks fine until the day it silently isn't.
Failure Mode 3: The Background Job That Forgets Its Tenant
The bug shows up in async work: a queued job, a cron task, a webhook handler, or a scheduled digest email. In the request path, middleware resolves the tenant from the session and holds it for the duration of the request. Once work moves to a queue, that context is gone. The job runs on a worker process that never saw the original request, and whatever tenant context it has depends entirely on what got written into the job payload.
When the payload omits tenant_id, or a shared ORM connection carries a stale tenant from whatever job ran before it, the failure isn't subtle. A digest job can fan out and process every tenant instead of one. A report job can compute Tenant A's numbers using Tenant B's data because a global query defaulted to "no filter" instead of failing. The failure is invisible in the request/response cycle because there is no request, just a worker quietly doing the wrong thing on a schedule.
The test that catches it enqueues a job for one tenant specifically, drains the queue, and asserts every side effect (rows written, emails sent, notifications fired) is scoped to that tenant and none touch the other. Just as important: it asserts that enqueuing a job without a tenant_id fails loudly rather than defaulting to "process everything," because a queue that silently accepts an untenanted payload is the single point where one missing field turns into a cross-tenant fan-out.
Put this test wherever your other job-processing tests live in CI, and treat a thrown error on a missing tenant_id as a feature, not a bug to silence. The alternative, a job that quietly defaults and keeps running, is how one tenant's weekly digest becomes everyone's weekly digest.
Failure Mode 4: The Cache Key That Doesn't Know About Tenants
The bug is almost embarrassing in retrospect: a cache key gets built from the resource ID alone, user:123 or dashboard:summary, with no tenant segment anywhere in it. If two tenants ever compute a value that resolves to the same logical key, whichever one warms the cache first serves their value to the other. Tenant A's cached dashboard summary, session data, or computed report becomes Tenant B's response, and it looks exactly like a caching bug because that's exactly what it is, one with a tenant-shaped blast radius.
This one ships easily because caching is usually added after the feature already works, by someone thinking about response time, not about tenant boundaries. A shared Redis instance across tenants makes it worse: the key collision isn't hypothetical, it's the default behavior unless every key builder explicitly includes the tenant segment.
The test that catches it warms the cache as Tenant A for a given logical resource, then reads the same logical resource as Tenant B, and asserts a miss, never Tenant A's value. A second assertion checks the key builder directly: the same resource string produces a different physical key per tenant, so the collision can't happen even before anything gets cached.
This test is cheap to run and belongs in the same suite as your cache module's unit tests. It's also worth re-running any time someone introduces a new cache layer (a CDN edge cache, a client-side cache, a second Redis instance for a different service) because each new cache is a new place the same key-collision bug can reappear independently.
Catching the Pattern, Not Just the Instance
The AWS Well-Architected SaaS Lens gets the concepts right: tenant isolation, noisy-neighbor protection, per-tenant testing all show up in it. What it doesn't do is show you the query, the test file, or the assertion. It tells you tenant isolation matters and leaves the "how" as an exercise for the reader. Most of the content that ranks for multi-tenancy testing has the same shape, a listicle explaining what multi-tenancy testing is in the abstract, with no named bug and no runnable test, ending in a product pitch instead of a test file.
The four failure modes above are not exhaustive. They're the four that recur often enough across real multi-tenant codebases to be worth a permanent, named test each, one you can point a new engineer at and say "this is the shape of bug that got past code review before, write the equivalent test for your new endpoint." That's a more durable habit than any single test passing once.
For teams that need those checks to run without competing with another pull request's state, PreviewKit, Autonoma's managed preview-environment product, provides the relevant environment layer: a full-stack preview per pull request, including the backend, database, and services. Autonoma's web-first E2E testing can exercise the tenant-boundary scenarios against that isolated preview. The tenant rules and assertions remain specific to your application; PreviewKit makes sure the environment underneath them is not shared staging data that can make a leak test pass or fail for the wrong reason.
Frequently Asked Questions
Seed two tenants with distinct data, authenticate as one, and try to access the other tenant's resources by ID, by query, through a background job, and through the cache. For reads, assert a 404 or 403 rather than a 200 with the other tenant's data. For queries, assert the result set contains only rows belonging to the requesting tenant, checked by both count and ID membership. For jobs and caches, seed one tenant's work or cache entry and assert it never surfaces for the other tenant. Run all of it against isolated, per-run tenant data, not a shared staging database, or the results can pass or fail for reasons that have nothing to do with your code.
Four recur across most multi-tenant codebases: a cross-tenant read where a valid resource ID resolves to another tenant's data (an IDOR variant), a query that omits the tenant_id predicate entirely and returns rows across every tenant, a background job or async worker that loses tenant context once work leaves the request path, and a cache key built without a tenant segment so one tenant's cached value gets served to another. Each has a distinct root cause and needs its own test rather than a single generic 'tenant isolation' smoke test.
Seed two tenants with a different number of rows each, run the query as one tenant, and assert two things: the returned count matches only that tenant's seeded rows, and none of the returned row IDs belong to the other tenant. The count check alone isn't enough, a query that filters on the wrong column can still return the right number of rows while returning the wrong ones. Pushing tenant scoping into a shared repository layer that every query goes through turns 'did the developer remember the filter' into a property of the abstraction instead of a per-query risk.
Tenant scoping is a cross-cutting concern spread across every query, endpoint, job, and cache key in the codebase, and a missing tenant_id predicate or an omitted tenant segment in a cache key doesn't produce a syntax error or an obviously wrong-looking line. It looks like ordinary code that happens to be missing one filter. Code review catches logic that looks wrong; it's much weaker at catching logic that's simply incomplete. That's why these failure modes need standing tests, not just careful reviewers.




