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: 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: 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: 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 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