Testing Multi-Tenant Isolation in a SaaS
Twelve test families pulled from real code: IDOR, webhooks, rate limiting, sessions. What they prove, and what they don't.
Why a cross-tenant leak happens
A forgotten organizationId filter shows up in neither a quick code review nor manual QA: your two test organizations each have their own data, and nobody deliberately tries another organization's ID. The leak sits dormant until the day a curious (or malicious) customer changes an identifier in the URL.
The concrete causes, in the order they actually happen:
findUniqueinstead offindFirst: itswhereonly accepts unique fields, soorganizationIdcan't be added to it. An automatic read-side filter (a Prisma$extendspattern, for instance) doesn't cover it.updateordeletewithoutorganizationIdin thewhere: the automatic filter only applies to reads. A write that doesn't repeat it explicitly can modify any row whose ID you know.- A nested entity with no direct
organizationId: an invoice line, a message inside a conversation. Without a nested filter through the parent, the line's ID alone is enough to reach it. - An uncovered secondary path: a webhook, a scheduled job, an export, full-text search. The main router is clean, but nobody checked the path that bypasses the usual procedure.
A checklist doesn't stop these mistakes from happening. An automated test that fails on the smallest regression does.
Twelve test families, pulled from real code
These are the actual files present in the product (src/__tests__/security/), not a generic list. Each one covers a specific attack path.
cross-tenant-isolation: two mocked organizations, verifying that every read (invoices, quotes, conversations, calendar events) filters byorganizationIdbefore it ever touches a real database.idor-comptabilite,idor-conversations,idor-messages,idor-notifications: one test per sensitive business domain, because a correct filter on invoices says nothing about messages.idor-iopole-events: inbound events from a third-party integration (e-invoicing platform webhooks) are an IDOR surface too, not just internal routes.webhook-security: signature verification (Stripe, invoicing platforms) compares the HMAC with a constant-time comparison, never a===that leaks information through response timing.open-redirect: an unvalidated?next=parameter can redirect to an external domain after login. Classic, easy to forget.rate-limiting: protection against abuse (brute force, expensive-request spam), not just against IDOR.session-security: session cookie configuration (NextAuth v5 renames its cookie prefix between versions), the kind of detail that silently breaks authentication in production without ever crashing in development.env-security: no server secret should leak into the client bundle. One mis-prefixed environment variable is enough to expose it.comprehensive-audit: a generic test that walks every organization-scoped model and verifies eachfindManyeither goes through the automatic filter or explicitly declaresorganizationId. It catches a new model that someone forgot to add to the scoped-models list.
That last one counts double: a test written by hand for each model only protects the models already known about. A test that walks the schema also protects models that don't exist yet.
NOT_FOUND, never FORBIDDEN
On a resource that doesn't belong to the current tenant, the response should never be "access forbidden." That confirms the resource exists, with a valid ID, in another organization.
// Bad: confirms the resource exists
if (invoice.organizationId !== ctx.session.user.organizationId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// Good: indistinguishable from an ID that doesn't exist
const invoice = await ctx.orgDb.invoice.findFirst({ where: { id } });
if (!invoice) {
throw new TRPCError({ code: "NOT_FOUND" });
}An IDOR test that only checks expect(result).rejects.toThrow() passes just as well with a FORBIDDEN as with a NOT_FOUND. You have to assert the exact code, or the test ends up protecting against the wrong bug entirely, one you shouldn't confuse with the information leak it was meant to catch.
Automate it, don't rely on memory
These tests only have value if they run on every change, not just before an annual audit. Three conditions make that stick over time:
- In CI, on every pull request, not locally at each developer's discretion.
- Fast, with a mocked tRPC context rather than a real database: a security suite that takes ten minutes eventually gets disabled under deadline pressure.
- One generic test per category (like
comprehensive-auditabove) in addition to the specific tests, to cover whatever gets added tomorrow without anyone thinking about it today.
What these tests don't prove
A green suite isn't proof of the absence of vulnerabilities, only the absence of the vulnerabilities someone thought to test for. What it doesn't cover:
- Third-party dependency vulnerabilities (track those separately, with a dedicated tool).
- Infrastructure misconfiguration (bucket permissions, network rules).
- Social engineering and phishing, outside the scope of the code.
- Any attack path nobody has thought of yet: that's why an occasional external audit stays useful as a complement to, not a replacement for, automated tests.
Go further
Related articles
Testing a Multi-Tenant SaaS: 7 Essential Vitest Patterns
From tenant isolation to RBAC permission checks, the test patterns that make a B2B SaaS genuinely reliable. With Vitest, tRPC, and Prisma.
ReadMulti-Tenant Isolation with Prisma and tRPC: What the Automatic Filter Covers, and What Doesn't
Isolate each organization's data in Next.js with Prisma $extends and tRPC: what the automatic filter covers, and what stays explicit.
ReadMulti-Tenant Authentication with NextAuth v5
Implement robust authentication with roles, granular permissions, and multi-tenant isolation for your SaaS, using NextAuth v5.
ReadReady to launch your SaaS?
HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $389.