Skip to main content
All articles
5 min read

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:

  • findUnique instead of findFirst: its where only accepts unique fields, so organizationId can't be added to it. An automatic read-side filter (a Prisma $extends pattern, for instance) doesn't cover it.
  • update or delete without organizationId in the where: 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.

  1. cross-tenant-isolation: two mocked organizations, verifying that every read (invoices, quotes, conversations, calendar events) filters by organizationId before it ever touches a real database.
  2. idor-comptabilite, idor-conversations, idor-messages, idor-notifications: one test per sensitive business domain, because a correct filter on invoices says nothing about messages.
  3. idor-iopole-events: inbound events from a third-party integration (e-invoicing platform webhooks) are an IDOR surface too, not just internal routes.
  4. webhook-security: signature verification (Stripe, invoicing platforms) compares the HMAC with a constant-time comparison, never a === that leaks information through response timing.
  5. open-redirect: an unvalidated ?next= parameter can redirect to an external domain after login. Classic, easy to forget.
  6. rate-limiting: protection against abuse (brute force, expensive-request spam), not just against IDOR.
  7. 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.
  8. env-security: no server secret should leak into the client bundle. One mis-prefixed environment variable is enough to expose it.
  9. comprehensive-audit: a generic test that walks every organization-scoped model and verifies each findMany either goes through the automatic filter or explicitly declares organizationId. 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-audit above) 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

Share

Ready to launch your SaaS?

HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $389.

30-day money-back guarantee