Most multi-tenant SaaS products keep customers apart with one line of code repeated a few hundred times:
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, tenantId: req.user.tenantId },
});
That works until someone writes the query without the tenantId. It might be a new endpoint, an export job, or a "quick" admin route. Nothing fails. The tests pass, because the tests only ever log in as one tenant. Then one customer sees another customer's invoice.
AWS's whitepaper on SaaS tenant isolation puts the stakes plainly: crossing the tenant boundary "in any form would represent a significant and potentially un-recoverable event for a SaaS business." The same class of bug sits at the top of the OWASP API Security Top 10 (2023) as API1: Broken Object Level Authorization.
The fix that holds up is boring: write tests whose only job is to try to read another tenant's data, and run them on every release. Below is the setup we use, in TypeScript with Jest and Supertest, plus Postgres row-level security as a second line of defence.
1. Seed two tenants with the same shape of data
Each cross-tenant test needs a victim. Seed two tenants that each own one of every resource type.
// test/fixtures/tenants.ts
export async function seedTwoTenants(db: Db) {
const a = await db.tenant.create({ data: { name: "Tenant A" } });
const b = await db.tenant.create({ data: { name: "Tenant B" } });
const userA = await createUser(db, a.id, "alice@a.test");
const userB = await createUser(db, b.id, "bob@b.test");
// One of every resource for tenant B: the data we will try to steal.
const bData = {
invoiceId: (await db.invoice.create({ data: { tenantId: b.id, total: 100 } })).id,
projectId: (await db.project.create({ data: { tenantId: b.id, name: "B project" } })).id,
fileId: (await db.file.create({ data: { tenantId: b.id, key: `${b.id}/report.pdf` } })).id,
};
return { a, b, userA, userB, bData };
}
2. List every route that takes an ID, in one place
The test is only as good as its coverage. Generate the list from your router if you can. If you can't, keep it as a table in the test file. The test then fails when someone adds a route and forgets to add it here.
// test/cross-tenant.routes.ts
export const idRoutes = (ids: Record<string, string>) => [
{ method: "get", path: `/api/invoices/${ids.invoiceId}` },
{ method: "get", path: `/api/invoices/${ids.invoiceId}/pdf` },
{ method: "get", path: `/api/projects/${ids.projectId}` },
{ method: "get", path: `/api/files/${ids.fileId}/download` },
// Writes last, so a leaking DELETE can't hide a leaking GET.
{ method: "patch", path: `/api/invoices/${ids.invoiceId}`, body: { total: 1 } },
{ method: "delete", path: `/api/invoices/${ids.invoiceId}` },
];
3. Sign in as tenant A and attack tenant B
// test/cross-tenant.test.ts
import request from "supertest";
import { app } from "../src/app";
import { seedTwoTenants } from "./fixtures/tenants";
import { idRoutes } from "./cross-tenant.routes";
describe("cross-tenant access", () => {
let ctx: Awaited<ReturnType<typeof seedTwoTenants>>;
let tokenA: string;
beforeAll(async () => {
ctx = await seedTwoTenants(db);
tokenA = await loginAs(ctx.userA);
});
test("no ID route is reachable from another tenant", async () => {
const leaks: string[] = [];
for (const { method, path, body } of idRoutes(ctx.bData)) {
const res = await (request(app) as any)[method](path)
.set("Authorization", `Bearer ${tokenA}`)
.send(body ?? {});
// 404, not 403: a 403 confirms the record exists.
const bodyLeaks = JSON.stringify(res.body).includes(ctx.b.id);
if (res.status !== 404 || bodyLeaks) {
leaks.push(`${method.toUpperCase()} ${path} -> ${res.status}`);
}
}
// One failure message listing every leaking route, not just the first.
expect(leaks).toEqual([]);
});
test("list endpoints never return another tenant's rows", async () => {
const res = await request(app)
.get("/api/invoices?limit=1000")
.set("Authorization", `Bearer ${tokenA}`);
const tenantIds = new Set(res.body.items.map((i: any) => i.tenantId));
expect([...tenantIds]).toEqual([ctx.a.id]);
});
});
Three details matter here:
-
Expect 404, not 403. A 403 tells an attacker that invoice
8f2cā¦exists, which is still a leak. -
Check the body as well as the status. Some bugs return 200 with an empty-looking object that still includes the other tenant's
tenantIdor a signed file URL. - Put writes last. If a DELETE leaks, it removes the record, and every later read returns 404 and looks like a pass. That's why the route list ends with PATCH and DELETE.
4. Cover the places tests usually miss
Leaks between tenants rarely come from the main CRUD routes. They come from the code paths that run without a request:
-
Background jobs and queues. A job payload that carries
invoiceIdbut nottenantId, then loads the record without a tenant filter. -
Caches. A cache key like
invoice:${id}instead oftenant:${tenantId}:invoice:${id}. - Search indexes. One shared index queried without a tenant filter.
- File storage. Object keys without the tenant prefix, or signed URLs that live for days.
- Exports and reports. CSV exports built from raw SQL that skips the ORM's tenant scoping.
- Admin and support tools. "Impersonate user" features that forget to switch the tenant context back.
Add at least one test per category: enqueue a job as tenant A with tenant B's ID, and assert it fails or does nothing.
5. Add a database backstop with row-level security
Tests catch the bugs you thought of. Postgres row-level security catches the query someone writes next year. With RLS on, a query that forgets the tenant filter returns nothing instead of everything.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Without FORCE, the table owner (often your app's DB user) bypasses RLS.
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
Set the tenant at the start of every transaction, from the authenticated user, never from a request parameter:
await db.$transaction(async (tx) => {
// `true` makes the setting local to this transaction,
// so a pooled connection can't carry it into the next request.
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
return handler(tx);
});
A few things to know before you rely on it:
- Superusers and roles with
BYPASSRLSignore policies, so connect the app as an ordinary role. - If
app.tenant_idis never set,current_settingraises an error, which is what you want. Don't "fix" it with a default. - Migrations and genuine cross-tenant jobs (billing runs, for example) need a separate role, and that role should be rare and logged.
Then add one test that proves the backstop works: run a raw query without the tenant filter inside a tenant-A transaction, and assert it returns only tenant A's rows.
6. Make it a release gate
Put the cross-tenant suite in CI as its own job, and fail the build if it fails. Not a nightly run, not "we'll look at it". If it's slow, run the full route list on the main branch and a smoke subset on pull requests.
It's also the evidence enterprise buyers ask for. Security questionnaires usually include some version of "how do you prevent one customer accessing another's data?". "We have a cross-tenant test suite that runs on every release, and RLS at the database layer" is a far better answer than "we're careful".
Where this fits
Isolation is one of a handful of costs that show up after the MVP, alongside billing, tax, enterprise security reviews and uptime. We wrote up the rest, with sources, in SaaS development cost: the decisions that set the number.
The short version for isolation: pick your model (separate databases, shared tables with a tenant column, or a mix) deliberately, because it's expensive to change later. Then write the test that tries to break it.
Your turn: where have you found a cross-tenant leak that the main routes didn't have? Jobs, caches and exports are the usual suspects, but we'd like to hear the odd ones.
From the team at Redlio Labs, where we build and run SaaS products.
Top comments (0)