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:
- Database layer — queries that don't filter by
tenant_id, missing row-level security policies, or ORM misconfiguration - API layer — endpoints that accept a resource ID without verifying it belongs to the requesting tenant
- 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_idcontext 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