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
| Layer | Pitfall | E2E fix |
|---|---|---|
| IDOR | Guessed UUID returns data | Negative navigate + probe zero rows |
| API scope | Missing org_id filter | Cross-tenant request expects 403/404 |
| RLS | Policy drift | User-scoped probe as tenant A vs B |
| JWT claims | Wrong org_id in token | Seed login per tenant; never swap cookies manually |
| Shared fixtures | demo-org in all tests | org-${runId}-a and org-${runId}-b |
| List endpoints | Pagination leak | Probe 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
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| IDOR by URL | /invoices/{id} guess | 200 with JSON body | Probe as tenant A; id from tenant B |
| IDOR by API | REST GET resource | 200 leak | request.get with A session → B id → 403 |
| List leakage | Missing WHERE | Extra rows in array | Probe response IDs all match org |
| Create cross-tenant | POST with wrong org header | Row in wrong tenant | Probe row org_id; expect 403 |
| Update cross-tenant | PATCH other org resource | Silent overwrite | Probe unchanged for victim tenant |
| Delete cross-tenant | DELETE foreign id | Victim data gone | Probe victim row still exists |
| Invite accept | Token reused across orgs | Wrong membership | Probe membership table scoped |
| Subdomain routing | b.corp.app vs a. | Session on wrong host | Seed DNS mapping; probe host header |
| Admin impersonation | Support mode sees all | False positive leak | Separate impersonation scenario |
| Search index | Algolia missing ACL | Cross-tenant hits | Filter probe on org_id |
| File storage | S3 key guess | Public URL leak | Probe 403 on foreign key |
| Webhook fan-out | Event to wrong tenant | Cross-posted msg | Probe delivery scoped by org |
| GraphQL | Nested resolver skip auth | Field leak | Probe nested id from B as A |
| RLS bypass route | Service role overuse | Tests never hit RLS | User-scoped probes on critical tables |
| Parallel CI | Same org slug | Data collision | runId 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
| Enforcement | E2E focus |
|---|---|
| Postgres RLS | User JWT probes; see Supabase RLS guide |
| App middleware | API negative requests with wrong org header |
| Both | Never 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-pattern | Why it fails | Better approach |
|---|---|---|
| Single demo org | Never tests isolation | Dual-tenant seed every security spec |
| UI "Not found" only | JSON leak still | API status + body length check |
| Disable RLS in test | False confidence | Real policies + user probes |
| Service role in browser | Key leak + bypass | Server probes only |
| 404-only assertion | Enumeration differences | Consistent no-leak contract |
| Skip list endpoints | Bulk exfiltration | Probe all IDs match org |
| Shared user across orgs | Unrealistic session | Distinct users per tenant |
| Positive path only | IDOR undetected | Mandatory 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.
- Arrange: Seed org A user, org B invoice with runId; no membership linking A to B.
- Act: Login as A; navigate to B invoice URL; parallel API GET same id.
- 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.
Related
- Supabase and RLS testing
- RBAC permissions
- Admin RBAC flows
- Seed routes and probe Assert
- UI-only assertions miss backend bugs
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.