Skip to main content

How to Test Multi-Tenant SaaS Isolation (IDOR, RLS, Cross-Tenant APIs)

Short answer

Multi-tenant E2E fails when tests use one shared org, accept generic 404 UI while APIs return 200 + JSON leaks, or disable RLS in test. Seed two tenants per runId, run negative probes (cross-tenant read/write expect deny), and assert server-side row counts—not toast copy. Pair with Supabase RLS patterns when Postgres policies enforce isolation.

Part of Testing Guides by industry.

Who this is for​

B2B SaaS, workspace apps, and admin consoles with org_id / tenant_id scoping—CRUD on shared tables, invite flows, billing per workspace, and APIs that accept resource IDs in URLs. One IDOR bug can expose every customer.

Why multi-tenant isolation needs different Arrange/Assert​

LayerPitfallE2E fix
IDORGuessed UUID returns dataNegative navigate + probe zero rows
API scopeMissing org_id filterCross-tenant request expects 403/404
RLSPolicy driftUser-scoped probe as tenant A vs B
JWT claimsWrong org_id in tokenSeed login per tenant; never swap cookies manually
Shared fixturesdemo-org in all testsorg-${runId}-a and org-${runId}-b
List endpointsPagination leakProbe IDs ⊆ tenant allowlist

Empty UI is not proof—network tab may still return other tenant's records to a SPA that renders "Not found."

Complexity map​

ScenarioEdge caseWhy tests breakApproach
IDOR by URL/invoices/{id} guess200 with JSON bodyProbe as tenant A; id from tenant B
IDOR by APIREST GET resource200 leakrequest.get with A session → B id → 403
List leakageMissing WHEREExtra rows in arrayProbe response IDs all match org
Create cross-tenantPOST with wrong org headerRow in wrong tenantProbe row org_id; expect 403
Update cross-tenantPATCH other org resourceSilent overwriteProbe unchanged for victim tenant
Delete cross-tenantDELETE foreign idVictim data goneProbe victim row still exists
Invite acceptToken reused across orgsWrong membershipProbe membership table scoped
Subdomain routingb.corp.app vs a.Session on wrong hostSeed DNS mapping; probe host header
Admin impersonationSupport mode sees allFalse positive leakSeparate impersonation scenario
Search indexAlgolia missing ACLCross-tenant hitsFilter probe on org_id
File storageS3 key guessPublic URL leakProbe 403 on foreign key
Webhook fan-outEvent to wrong tenantCross-posted msgProbe delivery scoped by org
GraphQLNested resolver skip authField leakProbe nested id from B as A
RLS bypass routeService role overuseTests never hit RLSUser-scoped probes on critical tables
Parallel CISame org slugData collisionrunId in org slug

Dual-tenant seed pattern​

// app/api/test/seed-tenants/route.ts
export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { runId } = await req.json();
const orgA = `org-a-${runId}`;
const orgB = `org-b-${runId}`;

const userA = await auth.createUser({
email: `a+${runId}@test.local`,
password: `pw-${runId}`,
org_id: orgA,
});
const userB = await auth.createUser({
email: `b+${runId}@test.local`,
password: `pw-${runId}`,
org_id: orgB,
});

const invoiceB = await db.invoices.insert({
org_id: orgB,
amount: 9900,
run_id: runId,
label: 'secret-b-invoice',
});

return NextResponse.json({
runId,
orgA,
orgB,
userA: { email: userA.email, password: `pw-${runId}` },
userB: { email: userB.email, password: `pw-${runId}` },
invoiceBId: invoiceB.id,
});
}
// app/api/test/probe-tenant-rows/route.ts
export async function GET(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const runId = new URL(req.url).searchParams.get('runId');
const orgId = new URL(req.url).searchParams.get('orgId');
const table = new URL(req.url).searchParams.get('table') ?? 'invoices';
const { count } = await adminDb
.from(table)
.select('*', { count: 'exact', head: true })
.eq('org_id', orgId)
.eq('run_id', runId);
return NextResponse.json({ count, orgId, runId, table });
}

Use user-scoped probes (forwarding session JWT) for positive paths; admin aggregate probes only on server routes for negative confirmation.

Playwright negative IDOR spec​

scenario annotations security/invoice-idor-deny
import { test, expect } from '../fixtures/run';

test('tenant A cannot read tenant B invoice by URL or API', async ({ page, request, runId }) => {
const seed = await request.post('/api/test/seed-tenants', { data: { runId } });
const { userA, invoiceBId } = await seed.json();

await page.goto('/login');
await page.getByLabel('Email').fill(userA.email);
await page.getByLabel('Password').fill(userA.password);
await page.getByRole('button', { name: 'Sign in' }).click();

// UI path — may show empty state OR 403 page
const uiRes = await page.goto(`/invoices/${invoiceBId}`);
expect(uiRes?.status()).not.toBe(200); // or expect 404/403 per app contract

// API negative probe — must not return B's payload
const apiRes = await request.get(`/api/invoices/${invoiceBId}`);
expect(apiRes.status()).toBeGreaterThanOrEqual(403);

// Confirm tenant A still sees only their rows
const probeA = await request.get(
`/api/test/probe-tenant-rows?runId=${runId}&orgId=org-a-${runId}&table=invoices`,
);
const probeB = await request.get(
`/api/test/probe-tenant-rows?runId=${runId}&orgId=org-b-${runId}&table=invoices`,
);
expect((await probeA.json()).count).toBe(0);
expect((await probeB.json()).count).toBe(1);
});

Document expected HTTP status contract (403 vs 404) in scenario markdown—both are valid if no body leak.

RLS and application-layer auth​

EnforcementE2E focus
Postgres RLSUser JWT probes; see Supabase RLS guide
App middlewareAPI negative requests with wrong org header
BothNever disable either in test—cover regression on critical tables

Keep exhaustive policy matrices in SQL/migration tests; E2E covers user-visible IDOR on invoices, projects, files, and admin settings.

RBAC within tenant​

Cross-tenant is not enough—pair with RBAC permissions for role denial inside org A.

TestChimp workflow​

Security scenarios belong in markdown plans with explicit negative probes. Use scenario annotations security/invoice-idor-deny so /testchimp test does not "fix" assertions into unsafe passes when UI copy changes. /testchimp plan helps author IDOR matrices from OpenAPI resources. TrueCoverage should not drive IDOR priority—cover all resource types with IDs in URLs.

Anti-patterns​

Anti-patternWhy it failsBetter approach
Single demo orgNever tests isolationDual-tenant seed every security spec
UI "Not found" onlyJSON leak stillAPI status + body length check
Disable RLS in testFalse confidenceReal policies + user probes
Service role in browserKey leak + bypassServer probes only
404-only assertionEnumeration differencesConsistent no-leak contract
Skip list endpointsBulk exfiltrationProbe all IDs match org
Shared user across orgsUnrealistic sessionDistinct users per tenant
Positive path onlyIDOR undetectedMandatory negative scenarios

External references​

Example scenario

Situation: User in org A pastes org B invoice URL from browser history.

Expected outcome: 403/404 with no invoice fields; org B row unchanged; org A list count zero.

Why UI-only automation breaks: Friendly empty state while GET /api/invoices/{id} returns 200 with amount and PDF URL.

  1. Arrange: Seed org A user, org B invoice with runId; no membership linking A to B.
  2. Act: Login as A; navigate to B invoice URL; parallel API GET same id.
  3. Assert: API status ≥403; probe tenant rows: A count 0, B count 1; no sensitive fields in response body.

TestChimp workflow: scenario annotation links IDOR spec to security markdown; /testchimp test must not weaken negative API status asserts.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Frequently asked questions

403 vs 404 for IDOR—which to assert?

Pick a consistent contract per resource and document in scenario markdown. Assert no sensitive body fields regardless of status code.

How many tenants to seed per test?

Minimum two: victim tenant B with data, attacker tenant A without access. Add third for invite/pending membership edge cases.

Application auth vs Postgres RLS?

Test both where used. E2E catches IDOR on APIs; RLS catches direct DB access paths. Do not disable RLS in CI.

GraphQL isolation testing?

Same negative probes—query nested resource IDs from tenant B with tenant A session; assert errors or null nodes without fields.

Search index cross-tenant leak?

Probe search API with org filter; see Algolia/ES guide for index ACL patterns.

Admin impersonation scenarios?

Separate positive spec with audit probe. Do not use impersonation as default login—it masks IDOR regressions.

Stripe-style metadata org_id?

Probe webhook-created rows include correct org_id; cross-tenant checkout session ids must 403 on retrieve.

TestChimp with isolation testing?

/testchimp plan authors IDOR matrices from API map; scenario annotation links negative specs; /testchimp test is configured to preserve 403/API asserts—not swap to UI-only checks.

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo