Back to Blog

E2E Testing for Multi-Tenant SaaS: Prevent Tenant Data Leaks Before They Happen

Multi-tenancy is one of the hardest architectural patterns to test correctly. Shared infrastructure, data isolation guarantees, tenant-specific features, and subdomain routing all require specialized E2E testing strategies. Here's the complete playbook.

Published

9 min read

Reading time

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

Monitor your multi-tenant application's isolation continuously: Try ScanlyApp free and run scheduled cross-tenant boundary tests against your deployed environments.

Related Posts

Hydration Error Hell: Detecting and Fixing React Rendering Bugs
Next.Js & Modern Framework Testing
9 min read

Hydration Error Hell: Detecting and Fixing React Rendering Bugs

Hydration errors are React's most confusing class of bug. They appear silently, break interactivity unpredictably, and are notoriously hard to reproduce. This guide gives you a systematic process to detect, diagnose, and fix them for good.