Multitenancy Testing: Tenant Isolation Verification Strategies
Multitenancy bugs are among the most dangerous in SaaS applications — a data leak between tenants can expose customer data to competitors, trigger regulatory penalties, and destroy trust. Testing tenant isolation systematically prevents these catastrophes.
This guide covers multitenancy testing: verifying data isolation, testing API-level tenant boundaries, detecting cross-tenant data leakage, and validating tenant-specific configurations.
Types of Multitenancy Architecture
Database per tenant: Separate database per customer. Isolation is strongest but most expensive.
Schema per tenant: Shared database, separate PostgreSQL schema per tenant. Good isolation, moderate cost.
Row-level tenancy: Shared tables with a tenant_id column. Least expensive, highest risk if not implemented correctly.
This guide focuses on row-level tenancy (most common in SaaS) — it requires the most rigorous testing.
The Core Isolation Test Pattern
Every tenant isolation test follows the same pattern:
- Create resource as Tenant A
- Authenticate as Tenant B
- Attempt to access/modify Tenant A's resource
- Verify access is denied and data is not leaked
// tests/multitenancy/isolation-pattern.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import app from '~/app';
describe('Tenant isolation pattern', () => {
let tenantAToken: string;
let tenantBToken: string;
let tenantAResourceId: string;
beforeAll(async () => {
// Create two tenants and authenticate as each
tenantAToken = await createTenantAndGetToken('tenant-a@example.com');
tenantBToken = await createTenantAndGetToken('tenant-b@example.com');
// Tenant A creates a resource
const res = await request(app)
.post('/api/projects')
.set('Authorization', `Bearer ${tenantAToken}`)
.send({ name: 'Tenant A Secret Project', data: 'confidential' });
tenantAResourceId = res.body.id;
});
it('Tenant B cannot read Tenant A resources', async () => {
const res = await request(app)
.get(`/api/projects/${tenantAResourceId}`)
.set('Authorization', `Bearer ${tenantBToken}`);
expect(res.status).toBe(404); // Not 200, not 403 — should appear non-existent
});
it('Tenant B cannot update Tenant A resources', async () => {
const res = await request(app)
.put(`/api/projects/${tenantAResourceId}`)
.set('Authorization', `Bearer ${tenantBToken}`)
.send({ name: 'Hijacked' });
expect(res.status).toBe(404);
});
it('Tenant B cannot delete Tenant A resources', async () => {
const res = await request(app)
.delete(`/api/projects/${tenantAResourceId}`)
.set('Authorization', `Bearer ${tenantBToken}`);
expect(res.status).toBe(404);
});
it("Tenant A's resource is unchanged after Tenant B's attempts", async () => {
const res = await request(app)
.get(`/api/projects/${tenantAResourceId}`)
.set('Authorization', `Bearer ${tenantAToken}`);
expect(res.status).toBe(200);
expect(res.body.name).toBe('Tenant A Secret Project');
expect(res.body.data).toBe('confidential');
});
});Testing List Endpoints for Data Leakage
List endpoints are the most common source of cross-tenant leakage — they often filter by tenant_id but developers forget edge cases:
describe('List endpoint tenant isolation', () => {
beforeAll(async () => {
// Tenant A creates 5 projects
for (let i = 1; i <= 5; i++) {
await request(app)
.post('/api/projects')
.set('Authorization', `Bearer ${tenantAToken}`)
.send({ name: `Tenant A Project ${i}` });
}
// Tenant B creates 3 projects
for (let i = 1; i <= 3; i++) {
await request(app)
.post('/api/projects')
.set('Authorization', `Bearer ${tenantBToken}`)
.send({ name: `Tenant B Project ${i}` });
}
});
it('Tenant A sees only their own projects', async () => {
const res = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${tenantAToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(5);
// No Tenant B projects should appear
for (const project of res.body) {
expect(project.name).not.toContain('Tenant B');
}
});
it('Tenant B sees only their own projects', async () => {
const res = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${tenantBToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(3);
for (const project of res.body) {
expect(project.name).not.toContain('Tenant A');
}
});
it('search results are tenant-scoped', async () => {
const res = await request(app)
.get('/api/projects/search?q=Project')
.set('Authorization', `Bearer ${tenantBToken}`);
expect(res.body).toHaveLength(3); // Tenant B has 3 "Project" items
for (const project of res.body) {
expect(project.name).not.toContain('Tenant A');
}
});
it('pagination does not expose other tenants', async () => {
// Get all pages for Tenant B
const allProjects = [];
let page = 1;
while (true) {
const res = await request(app)
.get(`/api/projects?page=${page}&limit=2`)
.set('Authorization', `Bearer ${tenantBToken}`);
allProjects.push(...res.body.items);
if (!res.body.hasMore) break;
page++;
}
expect(allProjects).toHaveLength(3);
for (const project of allProjects) {
expect(project.tenantId).toBe(tenantBId);
}
});
});Testing Database-Level Isolation
For row-level tenant isolation, verify the query includes tenant scoping:
// tests/multitenancy/database-queries.test.ts
import { describe, it, expect, vi } from 'vitest';
import { ProjectRepository } from '~/repositories/project-repository';
import { db } from '~/database';
vi.mock('~/database');
describe('ProjectRepository tenant scoping', () => {
const tenantId = 'tenant-123';
it('findAll includes tenant_id in WHERE clause', async () => {
await ProjectRepository.findAll(tenantId);
expect(db.query).toHaveBeenCalledWith(
expect.stringMatching(/WHERE.*tenant_id\s*=\s*\?/i),
expect.arrayContaining([tenantId])
);
});
it('findById includes tenant_id check', async () => {
await ProjectRepository.findById('proj-456', tenantId);
expect(db.query).toHaveBeenCalledWith(
expect.stringMatching(/WHERE.*id\s*=.*AND.*tenant_id\s*=|WHERE.*tenant_id.*AND.*id/i),
expect.arrayContaining(['proj-456', tenantId])
);
});
it('update includes tenant_id in WHERE clause', async () => {
await ProjectRepository.update('proj-456', tenantId, { name: 'Updated' });
expect(db.query).toHaveBeenCalledWith(
expect.stringMatching(/WHERE.*id\s*=.*AND.*tenant_id\s*=|WHERE.*tenant_id.*AND.*id/i),
expect.arrayContaining(['proj-456', tenantId])
);
});
});Testing IDOR (Insecure Direct Object Reference)
IDOR vulnerabilities allow tenant ID guessing. Test sequential and predictable IDs:
describe('IDOR prevention', () => {
it('cannot access resources by guessing sequential IDs', async () => {
// Create Tenant A resource
const createRes = await request(app)
.post('/api/documents')
.set('Authorization', `Bearer ${tenantAToken}`)
.send({ title: 'Secret Document' });
const tenantADocId = createRes.body.id;
// Assume IDOR: try IDs before and after
const adjacentIds = [
parseInt(tenantADocId) - 1,
parseInt(tenantADocId),
parseInt(tenantADocId) + 1,
].map(String);
for (const id of adjacentIds) {
const res = await request(app)
.get(`/api/documents/${id}`)
.set('Authorization', `Bearer ${tenantBToken}`);
// Should get 404, not 200 with another tenant's data
expect(res.status).not.toBe(200);
}
});
it('uses non-guessable UUIDs for resource IDs', async () => {
const res = await request(app)
.post('/api/documents')
.set('Authorization', `Bearer ${tenantAToken}`)
.send({ title: 'Test' });
// ID should be a UUID, not sequential integer
expect(res.body.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
});
});Testing Tenant-Specific Configuration
describe('Tenant configuration isolation', () => {
it('tenant-specific settings are not visible to other tenants', async () => {
// Tenant A sets a custom domain
await request(app)
.put('/api/settings')
.set('Authorization', `Bearer ${tenantAToken}`)
.send({ customDomain: 'tenant-a.example.com', slackWebhookUrl: 'https://hooks.slack.com/secret' });
// Tenant B should not see Tenant A's settings
const res = await request(app)
.get('/api/settings')
.set('Authorization', `Bearer ${tenantBToken}`);
expect(res.body.customDomain).not.toBe('tenant-a.example.com');
expect(res.body.slackWebhookUrl).toBeUndefined();
});
it('webhooks fire for the correct tenant only', async () => {
const webhookCalls: Array<{ tenantId: string; event: string }> = [];
// Mock the webhook delivery
vi.spyOn(webhookService, 'deliver').mockImplementation(async (tenantId, event) => {
webhookCalls.push({ tenantId, event });
});
// Tenant A creates a resource (triggers webhook)
await request(app)
.post('/api/projects')
.set('Authorization', `Bearer ${tenantAToken}`)
.send({ name: 'New Project' });
// Only Tenant A's webhook should fire
expect(webhookCalls).toHaveLength(1);
expect(webhookCalls[0].tenantId).toBe(tenantAId);
});
});Automated Tenant Isolation Matrix
Test all resource types systematically:
const RESOURCES = [
{ path: '/api/projects', createBody: { name: 'Test Project' } },
{ path: '/api/documents', createBody: { title: 'Test Doc' } },
{ path: '/api/team-members', createBody: { email: 'member@example.com', role: 'viewer' } },
{ path: '/api/api-keys', createBody: { name: 'Test Key' } },
];
describe('Cross-tenant isolation matrix', () => {
for (const resource of RESOURCES) {
describe(resource.path, () => {
let resourceId: string;
beforeAll(async () => {
const res = await request(app)
.post(resource.path)
.set('Authorization', `Bearer ${tenantAToken}`)
.send(resource.createBody);
resourceId = res.body.id;
});
it('Tenant B GET returns 404', async () => {
const res = await request(app)
.get(`${resource.path}/${resourceId}`)
.set('Authorization', `Bearer ${tenantBToken}`);
expect(res.status).toBe(404);
});
it('Tenant B PUT returns 404', async () => {
const res = await request(app)
.put(`${resource.path}/${resourceId}`)
.set('Authorization', `Bearer ${tenantBToken}`)
.send(resource.createBody);
expect(res.status).toBe(404);
});
it('Tenant B DELETE returns 404', async () => {
const res = await request(app)
.delete(`${resource.path}/${resourceId}`)
.set('Authorization', `Bearer ${tenantBToken}`);
expect(res.status).toBe(404);
});
});
}
});Summary
Multitenancy isolation testing requires:
- Test every CRUD operation — GET, POST, PUT, DELETE must all be tenant-scoped
- Test list endpoints — these are the most common source of cross-tenant leakage
- Verify database queries — unit test repositories to confirm tenant_id is always in WHERE
- Test IDOR vectors — sequential IDs and guessable IDs are common vulnerabilities
- Use the 404-not-403 pattern — cross-tenant resources should appear non-existent, not forbidden
- Test all resource types — generate matrix tests to ensure no resource type is missed
Tenant isolation bugs are silent in development — they only appear when Tenant B has malicious intent or accidentally guesses another tenant's resource ID. Systematic automated testing is the only reliable way to verify the boundary holds.