E2E Testing for Multi-Tenant SaaS: Prevent Tenant Data Leaks Before They Happen
Multi-tenancy is deceptively simple to describe and genuinely hard to test. The core promise to each tenant is this: your data stays yours, your configuration stays yours, and your experience stays yours — even when you are sharing infrastructure with thousands of other tenants.
Breaking that promise — even once — can be catastrophic. Tenant A being able to see Tenant B's billing records is not a bug; it is a legal liability. A misconfigured feature flag that enables a premium feature for a free-tier tenant is not a bonus; it is a revenue leak. A subdomain routing error that serves one tenant's branded UI to another tenant's users is not a UX glitch; it is a trust-destroying incident.
This guide covers the complete E2E testing strategy for multi-tenant SaaS applications: data isolation verification, tenant-specific routing, cross-tenant auth testing, and the CI/CD patterns that keep it all reliable.
The Four Pillars of Multi-Tenant QA
flowchart LR
A[Multi-Tenant QA] --> B[Data Isolation]
A --> C[Routing & Identity]
A --> D[Feature Gating]
A --> E[Performance Isolation]
B --> B1[Tenant A can't\nread Tenant B's data]
C --> C1[Subdomain routing\nworks per tenant]
D --> D1[Plan limits enforced\nper tenant account]
E --> E1[One tenant's load\ndoesn't affect others]
Pillar 1: Data Isolation
This is the non-negotiable. In a multi-tenant database (whether using row-level security, separate schemas, or separate databases), your tests must verify that tenants cannot access each other's resources.
The test pattern is explicit cross-tenant access attempts:
test('tenant A cannot access tenant B resources', async ({ browser }) => {
const tenantAContext = await browser.newContext({
storageState: 'playwright/.auth/tenant-a-user.json',
});
const tenantAPage = await tenantAContext.newPage();
// Get a resource ID that belongs to Tenant B
const tenantBProjectId = 'proj-tenant-b-001';
// Attempt to access it as Tenant A
const response = await tenantAPage.request.get(`/api/projects/${tenantBProjectId}`);
// Must return 403 or 404, never 200
expect([403, 404]).toContain(response.status());
// Also verify via UI: attempting to navigate to tenant B's resource
await tenantAPage.goto(`/projects/${tenantBProjectId}`);
// Should redirect to 404 or unauthorized page
await expect(tenantAPage).toHaveURL(/\/not-found|\/unauthorized/);
await tenantAContext.close();
});
Pillar 2: Routing and Tenant Identity
If your SaaS uses subdomain-based tenant routing (acme.app.yoursaas.com), your E2E tests must verify that:
- The correct tenant's data is returned for each subdomain
- Accessing one subdomain does not leak identifier context from another
- Cross-subdomain navigation (if supported) maintains proper session context
// playwright.config.ts - Configure for subdomain testing
export default defineConfig({
projects: [
{
name: 'tenant-a',
use: {
baseURL: 'https://acme.staging.scanlyapp.com',
storageState: 'playwright/.auth/tenant-a.json',
},
},
{
name: 'tenant-b',
use: {
baseURL: 'https://enterprise.staging.scanlyapp.com',
storageState: 'playwright/.auth/tenant-b.json',
},
},
],
});
// tests/tenant-routing.spec.ts
test('tenant subdomain shows correct tenant branding', async ({ page }) => {
await page.goto('/'); // Uses project's baseURL (tenant-specific subdomain)
// Verify the correct tenant's name/logo appears
await expect(page.getByRole('img', { name: 'Acme Corp logo' })).toBeVisible();
// Verify no data leakage from other tenants
await expect(page.getByText('Enterprise Corp')).not.toBeVisible();
});
Pillar 3: Feature Gating Per Tenant
SaaS products typically have plan-based feature tiers. Tests must verify that feature gates are enforced per tenant based on their subscription, not globally:
const planScenarios = [
{
tenant: 'free-tier-tenant',
storageState: 'playwright/.auth/free-user.json',
expectedScanLimit: 5,
canScheduleScans: false,
canExportReports: false,
},
{
tenant: 'pro-tier-tenant',
storageState: 'playwright/.auth/pro-user.json',
expectedScanLimit: 100,
canScheduleScans: true,
canExportReports: true,
},
];
for (const scenario of planScenarios) {
test(`feature gates enforced for ${scenario.tenant}`, async ({ browser }) => {
const ctx = await browser.newContext({ storageState: scenario.storageState });
const page = await ctx.newPage();
await page.goto('/dashboard');
if (!scenario.canScheduleScans) {
await page.getByRole('button', { name: 'Schedule Scan' }).click();
// Should show upgrade prompt, not the schedule dialog
await expect(page.getByRole('dialog')).toContainText('Upgrade to schedule scans');
} else {
await page.getByRole('button', { name: 'Schedule Scan' }).click();
await expect(page.getByRole('dialog')).toContainText('Schedule your scan');
}
await ctx.close();
});
}
Pillar 4: Performance Isolation
One noisy tenant should not degrade the experience for others. This is harder to test via E2E but you can validate response time SLAs per-tenant:
test('API response time stays within SLA under tenant isolation', async ({ page }) => {
const startTime = Date.now();
const response = await page.request.get('/api/projects', {
headers: { 'X-Tenant-ID': process.env.TEST_TENANT_ID! },
});
const responseTime = Date.now() - startTime;
expect(response.status()).toBe(200);
expect(responseTime).toBeLessThan(500); // 500ms SLA
});
Setting Up Multi-Tenant Test Environments
Multi-tenant testing requires multiple sets of test credentials and careful environment management:
playwright/.auth/
├── tenant-a-admin.json # Tenant A: Admin user
├── tenant-a-member.json # Tenant A: Regular member
├── tenant-b-admin.json # Tenant B: Admin user
├── tenant-b-free.json # Tenant B: Free tier
└── super-admin.json # Platform super-admin
Your global setup must provision all of these:
// tests/setup/global-setup.ts
const tenantConfigs = [
{ name: 'tenant-a-admin', email: process.env.TENANT_A_ADMIN_EMAIL!, password: process.env.TENANT_A_ADMIN_PASS! },
{ name: 'tenant-a-member', email: process.env.TENANT_A_MEMBER_EMAIL!, password: process.env.TENANT_A_MEMBER_PASS! },
{ name: 'tenant-b-admin', email: process.env.TENANT_B_ADMIN_EMAIL!, password: process.env.TENANT_B_ADMIN_PASS! },
];
async function globalSetup() {
const browser = await chromium.launch();
for (const config of tenantConfigs) {
const page = await browser.newPage();
await page.goto(`${process.env.BASE_URL}/login`);
await page.getByLabel('Email').fill(config.email);
await page.getByLabel('Password').fill(config.password);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({
path: `playwright/.auth/${config.name}.json`,
});
await page.close();
}
await browser.close();
}
Row-Level Security (RLS) Verification
If your database uses Row-Level Security (Supabase, PostgreSQL), your tests should explicitly verify that RLS policies work correctly. The most reliable way is via API endpoint testing — make requests that should return filtered results and verify the filter worked.
test('API returns only requesting tenant data via RLS', async ({ request }) => {
// Login as Tenant A user and make an API request
const response = await request.get('/api/scans', {
headers: {
Authorization: `Bearer ${TENANT_A_JWT_TOKEN}`,
},
});
const scans = await response.json();
// Every returned scan must belong to Tenant A
scans.forEach((scan: { org_id: string }) => {
expect(scan.org_id).toBe(TENANT_A_ORG_ID);
});
// Verify none belong to Tenant B
const tenantBScanIds = scans.filter((s: { org_id: string }) => s.org_id === TENANT_B_ORG_ID);
expect(tenantBScanIds.length).toBe(0);
});
This test pattern should run on every deployment. See our guide on database testing best practices for RLS-specific coverage patterns.
Testing Admin Impersonation Features
Many multi-tenant SaaS products have a super-admin feature allowing the platform team to log in as any tenant for support purposes. This is powerful and needs careful testing:
test('super admin can impersonate tenant without permanent data mutation', async ({ browser }) => {
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/super-admin.json',
});
const adminPage = await adminContext.newPage();
// Navigate to tenant management
await adminPage.goto('/admin/tenants');
await adminPage
.getByRole('row', { name: /Acme Corp/ })
.getByRole('button', { name: 'Impersonate' })
.click();
// Should now be viewing as tenant
await expect(adminPage).toHaveURL('/dashboard');
await expect(adminPage.getByTestId('impersonation-banner')).toBeVisible();
await expect(adminPage.getByTestId('impersonation-banner')).toContainText('Viewing as Acme Corp');
// Stop impersonation
await adminPage.getByRole('button', { name: 'Stop Impersonating' }).click();
// Should return to admin view
await expect(adminPage.getByTestId('impersonation-banner')).not.toBeVisible();
await expect(adminPage).toHaveURL('/admin/tenants');
await adminContext.close();
});
CI/CD Matrix: Testing Multiple Tenants in Parallel
Testing multiple tenant configurations does not have to be slow. Playwright's project configuration lets you run them in parallel:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'isolation-tests',
testMatch: '**/isolation/*.spec.ts',
timeout: 30_000,
},
{
name: 'tenant-a-features',
testMatch: '**/tenant-specific/*.spec.ts',
use: { storageState: 'playwright/.auth/tenant-a-admin.json' },
},
{
name: 'tenant-b-features',
testMatch: '**/tenant-specific/*.spec.ts',
use: { storageState: 'playwright/.auth/tenant-b-admin.json' },
},
],
workers: process.env.CI ? 4 : 2,
});
Common Multi-Tenant Test Failures and What They Signal
| Test Failure | Root Cause | Fix |
|---|---|---|
| Tenant A returns Tenant B data | Missing tenant filter in query | Add WHERE org_id = $tenantId to all queries |
| Feature gate bypassed | Front-end only check missing backend validation | Add server-side plan check in API handler |
| Subdomain routes to wrong tenant | Nginx/middleware routing bug | Fix tenant-resolution middleware |
| Cross-tenant permission | Missing RLS policy | Add Supabase RLS policy; verify with tests |
| Super-admin session bleeds | Missing tenant context reset | Clear tenant context on impersonation end |
Connecting Multi-Tenant Monitoring to ScanlyApp
For SaaS products, monitoring each tenant context separately in production is a layer of coverage that pre-deploy testing cannot replace. A deploy might work perfectly for new tenants but break path resolution for tenants with legacy configurations.
ScanlyApp lets you configure separate scan projects per subdomain or per tenant context, so each tenant's critical workflows are monitored independently. A regression in Tenant A's settings flow will not be masked by Tenant B's scans passing.
Monitor each tenant independently: Try ScanlyApp free and set up per-subdomain scans for your multi-tenant application.
Related articles: Also see securing the authentication flows multi-tenant apps rely on, simulating multiple concurrent tenants in a single Playwright run, and the access control testing that is critical in multi-tenant systems.
Summary: Multi-Tenant Test Coverage Checklist
- Data isolation: Tenant A cannot read/write Tenant B data via API
- Subdomain routing: Each subdomain resolves to the correct tenant context
- Feature gating: Plan limits enforced server-side, not just in the UI
- RLS verification: Database queries return only tenant-scoped records
- Admin impersonation: Session does not permanently assign admin to tenant context
- Cross-tenant auth: Token from Tenant A's auth provider rejected by Tenant B's endpoints
- Tenant creation/deletion: Orphan records cleaned up correctly on tenant removal
Multi-tenancy done right requires multi-tenant testing done right. The cost of a cross-tenant data leak — in legal liability, in user trust, in brand damage — vastly exceeds the investment in a robust isolation test suite.
Further Reading
- OWASP Testing Guide — Broken Access Control (Tenant Isolation): OWASP methodology for testing authorization bypass and cross-tenant access vulnerabilities
- Supabase Row Level Security: How to implement and verify RLS policies in Postgres to enforce tenant data isolation at the database layer
- Playwright Storage State — Multi-User Testing: Reusing authenticated states for multiple tenant accounts in Playwright test suites
- JWT Claims Best Practices — RFC 7519: The JWT specification including
sub,aud, and custom claim patterns used for tenant scoping in tokens
Monitor your multi-tenant application's isolation continuously: Try ScanlyApp free and run scheduled cross-tenant boundary tests against your deployed environments.
