The phrase "multi-tenant by design" gets thrown around so freely that it stopped meaning anything. Most B2B SaaS apps are multi-tenant in the sense that they have a tenant_id column and remember to filter by it most of the time. The breaches happen the one time someone forgets.
We treat tenant isolation as the platform's #1 invariant — stricter than auth, stricter than rate limiting, stricter than payment integrity. Here's the model.
The threat we're defending against
The dominant cause of breaches in multi-tenant SaaS is not fancy. It's a missing WHERE workspace_id = ?. Either:
- A list endpoint that defaults to "return all rows" when the caller's workspace can't be resolved (auth-bypass on a normally-good endpoint).
- An update / delete endpoint that takes a row ID and patches it without verifying the row belongs to the caller's tenant (BOLA / IDOR).
- A backfill or migration script that scans across tenants and patches one row at a time, leaving any missed tenant check as a gap.
Each one is a one-line bug. Each one leaks an entire customer database when it ships.
Layer 1: workspace-indexed reads
Every workspace-scoped table has a by_workspace index. Every list query goes through it explicitly:
const calls = await ctx.db
.query("crmCalls")
.withIndex("by_workspace", (q) =>
q.eq("workspaceId", callerWs))
.collect();What it doesn't do — and what's the bug-prone alternative — is this:
// Don't do this. Ever.
let calls = await ctx.db.query("crmCalls").collect();
calls = calls.filter((c) => c.workspaceId === callerWs);The second pattern looks correct. It even produces correct results. But under any failure mode where callerWs resolves to undefined (token expired mid-request, race condition during sign-out, etc.), the JS filter passes everything through. The server returns every tenant's data with a 200. We've eliminated this pattern across the codebase — every list query is index-driven.
Layer 2: workspace re-check on every write
Reads are filtered. Writes are gated. Every update or delete mutation verifies the row's workspace matches the caller's:
const lead = await ctx.db.get(leadId);
if (!lead) throw new Error("Lead not found");
assertSameWorkspace(lead.workspaceId, callerWs);
await ctx.db.patch(leadId, patch);assertSameWorkspace throws if the row's workspace doesn't match. This catches the BOLA / IDOR class of attack: a user from tenant A guesses a row ID belonging to tenant B and tries to patch it. The mutation refuses before the patch runs.
Layer 3: workspace context comes from the session, never the client
The workspace ID a query operates on is derived from the session token, never accepted as a request parameter. We have helpers — getWorkspaceIdFromToken(ctx, token) — that resolve it from the session row's user, then the user's workspace. There is no code path where workspaceId comes from the request body.
Layer 4: backfills are workspace-scoped too
Migration / backfill mutations are the part everyone forgets. They run as the platform owner; they sweep the whole DB; they patch one row at a time. If they don't carry the workspace context with them, you've created a privileged mutation that touches every tenant's data with one bug.
Our backfills are either workspace-scoped (run for one tenant) or use internalMutation so they're not callable from the client at all. Public backfills with a per-call workspace check are the third option — used only when the operator legitimately needs them in production (e.g. customer-id renumbering inside a single workspace).
Layer 5: cross-tenant tests in CI
The structural protections above are necessary but not sufficient. Code drifts. The thing that catches drift is tests:
- Spin up two test tenants A and B.
- Sign in as A's admin.
- For every list endpoint, assert it returns only A's data.
- For every mutation that takes an ID, attempt to pass B's row IDs and assert it throws.
We're shipping these as part of our SOC 2 evidence package — the kind of test that catches the bug before a customer notices.
What this gets you
A platform where tenant isolation is structural, not procedural. There is no "remember to filter by tenant ID" line in the contributor docs because there is no way to write a query that doesn't.
That's what "multi-tenant by design" means when we say it. Read the full security model →