Multi-Tenant SaaS Testing: Tenant Isolation and Data Segregation Tests

Multi-Tenant SaaS Testing: Tenant Isolation and Data Segregation Tests

Tenant isolation is the most critical security property of a multi-tenant SaaS application. A bug that allows Tenant A to read Tenant B's data is a catastrophic breach — and it's the kind of bug that standard happy-path tests never catch. This guide covers how to systematically test tenant isolation at every layer.

The Threat Model

Tenant isolation failures happen at three layers:

  1. Database layer — queries that don't filter by tenant_id, missing row-level security policies, or ORM misconfiguration
  2. API layer — endpoints that accept a resource ID without verifying it belongs to the requesting tenant
  3. UI layer — navigation or state bugs that show one tenant's data while another is logged in

You need tests at all three layers.

Database Layer: Row-Level Security Tests

PostgreSQL RLS

-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
// tests/db/rls.test.ts
import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL })

async function queryAsAnotherTenant<T>(
  tenantId: string,
  fn: (client: any) => Promise<T>
): Promise<T> {
  const client = await pool.connect()
  try {
    await client.query(`SET app.tenant_id = '${tenantId}'`)
    return await fn(client)
  } finally {
    await client.query("RESET app.tenant_id")
    client.release()
  }
}

describe('Row-level security: projects table', () => {
  const tenantA = 'tenant-a-uuid'
  const tenantB = 'tenant-b-uuid'

  beforeAll(async () => {
    // Seed tenant A's project
    await pool.query(
      `INSERT INTO projects (id, tenant_id, name) VALUES ('proj-a', $1, 'Tenant A Project')`,
      [tenantA]
    )
    // Seed tenant B's project
    await pool.query(
      `INSERT INTO projects (id, tenant_id, name) VALUES ('proj-b', $1, 'Tenant B Project')`,
      [tenantB]
    )
  })

  afterAll(async () => {
    await pool.query("DELETE FROM projects WHERE id IN ('proj-a', 'proj-b')")
    await pool.end()
  })

  it('tenant A can only see their own projects', async () => {
    const rows = await queryAsAnotherTenant(tenantA, (client) =>
      client.query('SELECT id FROM projects').then((r: any) => r.rows)
    )

    const ids = rows.map((r: any) => r.id)
    expect(ids).toContain('proj-a')
    expect(ids).not.toContain('proj-b')
  })

  it('tenant B cannot access tenant A projects by ID', async () => {
    const rows = await queryAsAnotherTenant(tenantB, (client) =>
      client.query("SELECT id FROM projects WHERE id = 'proj-a'").then((r: any) => r.rows)
    )

    expect(rows).toHaveLength(0) // RLS hides the row
  })
})

API Layer: Cross-Tenant Access Tests

Test that your API rejects cross-tenant resource access with 403/404, not 200:

// tests/api/cross-tenant.test.ts
import supertest from 'supertest'
import app from '../../src/app'
import { createTestTenant, createTestUser, createTestProject } from '../factories'

describe('Cross-tenant API isolation', () => {
  let tenantA: any
  let tenantB: any
  let tenantAProject: any
  let tokenA: string
  let tokenB: string

  beforeAll(async () => {
    tenantA = await createTestTenant({ name: 'Tenant A' })
    tenantB = await createTestTenant({ name: 'Tenant B' })
    
    const userA = await createTestUser({ tenantId: tenantA.id })
    const userB = await createTestUser({ tenantId: tenantB.id })
    
    tenantAProject = await createTestProject({ tenantId: tenantA.id, name: 'Secret Project' })
    
    tokenA = await getAuthToken(userA)
    tokenB = await getAuthToken(userB)
  })

  it('returns 404 when tenant B requests tenant A project by ID', async () => {
    const response = await supertest(app)
      .get(`/api/projects/${tenantAProject.id}`)
      .set('Authorization', `Bearer ${tokenB}`)

    // Must be 404 (not 403 which reveals existence) or 403
    expect([403, 404]).toContain(response.status)
  })

  it('tenant A project does not appear in tenant B project list', async () => {
    const response = await supertest(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${tokenB}`)

    expect(response.status).toBe(200)
    const ids = response.body.projects.map((p: any) => p.id)
    expect(ids).not.toContain(tenantAProject.id)
  })

  it('tenant B cannot update tenant A project', async () => {
    const response = await supertest(app)
      .patch(`/api/projects/${tenantAProject.id}`)
      .set('Authorization', `Bearer ${tokenB}`)
      .send({ name: 'Hijacked Project Name' })

    expect([403, 404]).toContain(response.status)

    // Verify the name was not changed
    const original = await supertest(app)
      .get(`/api/projects/${tenantAProject.id}`)
      .set('Authorization', `Bearer ${tokenA}`)

    expect(original.body.name).toBe('Secret Project')
  })

  it('tenant B cannot delete tenant A project', async () => {
    const response = await supertest(app)
      .delete(`/api/projects/${tenantAProject.id}`)
      .set('Authorization', `Bearer ${tokenB}`)

    expect([403, 404]).toContain(response.status)

    // Verify it still exists
    const check = await supertest(app)
      .get(`/api/projects/${tenantAProject.id}`)
      .set('Authorization', `Bearer ${tokenA}`)

    expect(check.status).toBe(200)
  })
})

UI Layer: Cross-Tenant Leakage Tests with Playwright

// tests/e2e/tenant-isolation.spec.ts
import { test, expect, chromium } from '@playwright/test'

test.describe('UI tenant isolation', () => {
  test('tenant B cannot see tenant A data after auth state switch', async () => {
    // Log in as tenant A and create a project
    const browserA = await chromium.launch()
    const ctxA = await browserA.newContext()
    const pageA = await ctxA.newPage()
    
    await pageA.goto('https://app.example.com/login')
    await pageA.fill('[name=email]', 'admin@tenanta.com')
    await pageA.fill('[name=password]', process.env.TENANT_A_PASSWORD!)
    await pageA.click('button[type=submit]')
    await pageA.waitForURL('**/dashboard')
    
    await pageA.click('text=New Project')
    await pageA.fill('[name=project-name]', 'Tenant A Confidential')
    await pageA.click('button:has-text("Create")')
    await expect(pageA.locator('text=Tenant A Confidential')).toBeVisible()

    // Log in as tenant B in a separate browser context
    const browserB = await chromium.launch()
    const ctxB = await browserB.newContext()
    const pageB = await ctxB.newPage()
    
    await pageB.goto('https://app.example.com/login')
    await pageB.fill('[name=email]', 'admin@tenantb.com')
    await pageB.fill('[name=password]', process.env.TENANT_B_PASSWORD!)
    await pageB.click('button[type=submit]')
    await pageB.waitForURL('**/dashboard')

    // Tenant B's dashboard should NOT show tenant A's project
    await expect(pageB.locator('text=Tenant A Confidential')).not.toBeVisible()

    await browserA.close()
    await browserB.close()
  })
})

Testing Shared Resources

Some resources (like global tag libraries or plan features) are intentionally shared. Test that they're accessible to all tenants but not writable by non-owners:

describe('Shared resources', () => {
  it('all tenants can read global templates', async () => {
    const responseA = await supertest(app)
      .get('/api/templates?scope=global')
      .set('Authorization', `Bearer ${tokenA}`)
    
    const responseB = await supertest(app)
      .get('/api/templates?scope=global')
      .set('Authorization', `Bearer ${tokenB}`)

    expect(responseA.status).toBe(200)
    expect(responseB.status).toBe(200)
    expect(responseA.body.templates).toEqual(responseB.body.templates)
  })

  it('tenants cannot modify global templates', async () => {
    const globalTemplateId = 'global-template-1'
    
    const response = await supertest(app)
      .patch(`/api/templates/${globalTemplateId}`)
      .set('Authorization', `Bearer ${tokenA}`)
      .send({ name: 'Modified Name' })

    expect(response.status).toBe(403)
  })
})

Automated Leakage Detection

For bulk coverage, write a parameterized test that checks every resource type:

const resourceTypes = [
  { name: 'projects', endpoint: '/api/projects', seedFn: createTestProject },
  { name: 'documents', endpoint: '/api/documents', seedFn: createTestDocument },
  { name: 'members', endpoint: '/api/members', seedFn: createTestMember },
  { name: 'api-keys', endpoint: '/api/api-keys', seedFn: createTestApiKey },
]

describe.each(resourceTypes)('Cross-tenant isolation: $name', ({ endpoint, seedFn }) => {
  let resourceId: string

  beforeAll(async () => {
    const resource = await seedFn({ tenantId: tenantA.id })
    resourceId = resource.id
  })

  it(`${endpoint}/:id returns 403 or 404 for tenant B`, async () => {
    const response = await supertest(app)
      .get(`${endpoint}/${resourceId}`)
      .set('Authorization', `Bearer ${tokenB}`)

    expect([403, 404]).toContain(response.status)
  })

  it(`${endpoint} list excludes tenant A resources for tenant B`, async () => {
    const response = await supertest(app)
      .get(endpoint)
      .set('Authorization', `Bearer ${tokenB}`)

    const ids = response.body[Object.keys(response.body)[0]].map((r: any) => r.id)
    expect(ids).not.toContain(resourceId)
  })
})

What Automated Tests Miss

Unit and API tests verify isolation logic but won't catch:

  • Cache poisoning — shared Redis caches that return tenant A's data for tenant B's key
  • Background job leakage — report generation jobs that accidentally process all tenants' data
  • Search index leakage — full-text search that crosses tenant boundaries
  • WebSocket isolation — real-time events broadcasting to wrong tenant's channel

HelpMeTest runs scheduled end-to-end tests that log in as multiple tenant personas and verify isolation in a real browser session. Catch cross-tenant leakage before a security researcher does.

Summary

Testing tenant isolation at every layer:

  • Database — set app.tenant_id context and verify RLS blocks cross-tenant reads/writes
  • API — test every endpoint with a token from a different tenant; expect 403 or 404
  • UI — use separate browser contexts to simulate different tenant sessions
  • Parameterize — run isolation assertions across all resource types automatically
  • Verify writes — test that failed cross-tenant mutations didn't silently succeed

Read more