Testing Multi-Tenant SaaS Applications: Isolation, Data, and Performance
Multi-tenant SaaS applications are among the most complex systems to test correctly. A single deployment serves dozens, hundreds, or thousands of customers simultaneously — each expecting their data to remain private, their configuration to behave independently, and their performance to be unaffected by noisy neighbors. When isolation breaks down, the consequences are severe: data leaks between tenants can result in legal liability, lost customers, and reputational damage that takes years to recover from.
This guide covers the core testing strategies for multi-tenant SaaS applications: how to verify tenant isolation at every layer, how to test tenant-specific configurations, how to simulate realistic multi-tenant load, and how to validate that your database isolation patterns hold under adversarial conditions.
Understanding Multi-Tenancy Models
Before writing a single test, you need to understand which multi-tenancy model your application uses. The isolation strategies — and their failure modes — differ significantly:
- Siloed (separate databases per tenant): Highest isolation, easiest to test, highest operational cost.
- Pooled (shared database, shared schema, row-level isolation): Most common, cheapest to operate, hardest to test correctly.
- Bridged (shared database, separate schema per tenant): Middle ground, increasingly popular with PostgreSQL schemas.
Most modern SaaS applications use the pooled model, where every table has a tenant_id column and the application is responsible for filtering correctly. This is where the vast majority of data leakage bugs occur.
Testing Tenant Data Isolation
The fundamental test for any multi-tenant application is: can Tenant A ever see Tenant B's data? This sounds simple but has dozens of failure modes.
Basic Isolation Test
// Jest + Supertest example
describe('Tenant Data Isolation', () => {
let tenantAToken, tenantBToken;
let tenantAResourceId;
beforeAll(async () => {
// Create two separate tenant accounts
tenantAToken = await createTenantAndLogin('tenant-a@example.com');
tenantBToken = await createTenantAndLogin('tenant-b@example.com');
// Tenant A creates a resource
const res = await request(app)
.post('/api/projects')
.set('Authorization', `Bearer ${tenantAToken}`)
.send({ name: 'Secret Project', data: 'confidential' });
tenantAResourceId = res.body.id;
});
it('should not return Tenant A resources to Tenant B', async () => {
const res = await request(app)
.get('/api/projects')
.set('Authorization', `Bearer ${tenantBToken}`);
const ids = res.body.map(p => p.id);
expect(ids).not.toContain(tenantAResourceId);
});
it('should return 403 or 404 when Tenant B accesses Tenant A resource directly', async () => {
const res = await request(app)
.get(`/api/projects/${tenantAResourceId}`)
.set('Authorization', `Bearer ${tenantBToken}`);
expect([403, 404]).toContain(res.status);
});
it('should prevent Tenant B from updating Tenant A resources', async () => {
const res = await request(app)
.patch(`/api/projects/${tenantAResourceId}`)
.set('Authorization', `Bearer ${tenantBToken}`)
.send({ name: 'Hijacked' });
expect([403, 404]).toContain(res.status);
// Verify the resource is unchanged
const check = await request(app)
.get(`/api/projects/${tenantAResourceId}`)
.set('Authorization', `Bearer ${tenantAToken}`);
expect(check.body.name).toBe('Secret Project');
});
});IDOR (Insecure Direct Object Reference) Testing
One of the most common multi-tenancy failures is IDOR — where incrementing or guessing an ID allows a tenant to access another tenant's resource. Always test sequential IDs:
it('should block IDOR attacks via sequential ID enumeration', async () => {
// Assume tenantAResourceId is numeric (e.g. 1001)
const adjacentIds = [
tenantAResourceId - 1,
tenantAResourceId + 1,
tenantAResourceId + 100,
];
for (const id of adjacentIds) {
const res = await request(app)
.get(`/api/projects/${id}`)
.set('Authorization', `Bearer ${tenantBToken}`);
// Must be 403 or 404, never 200 for another tenant's data
expect([403, 404]).toContain(res.status);
}
});Testing Tenant-Specific Configurations
Each tenant in a SaaS platform often has unique configuration: custom domains, feature flags, branding, integration credentials, and plan-based limits. These configurations must be isolated and applied correctly.
Feature Flag Isolation Test
describe('Tenant Feature Flags', () => {
it('should apply feature flags per tenant without cross-contamination', async () => {
// Enable a premium feature for Tenant A only
await setTenantFeatureFlag(tenantAId, 'advanced_analytics', true);
await setTenantFeatureFlag(tenantBId, 'advanced_analytics', false);
const resA = await request(app)
.get('/api/features/advanced_analytics')
.set('Authorization', `Bearer ${tenantAToken}`);
const resB = await request(app)
.get('/api/features/advanced_analytics')
.set('Authorization', `Bearer ${tenantBToken}`);
expect(resA.body.enabled).toBe(true);
expect(resB.body.enabled).toBe(false);
});
it('should enforce plan limits independently per tenant', async () => {
// Tenant A is on Starter (5 users max), Tenant B on Pro (unlimited)
await setTenantPlan(tenantAId, 'starter');
// Fill Tenant A to their limit
for (let i = 0; i < 5; i++) {
await createUser(tenantAToken, `user${i}@tenant-a.com`);
}
// 6th user should be rejected for Tenant A
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${tenantAToken}`)
.send({ email: 'overflow@tenant-a.com' });
expect(res.status).toBe(402); // Payment Required or 403
expect(res.body.error).toMatch(/limit|upgrade/i);
});
});Database Isolation Pattern Testing
For pooled multi-tenant architectures, your ORM or query builder must always inject tenant_id into every query. A single missing WHERE tenant_id = ? clause can expose all tenants' data.
Testing Row-Level Security (PostgreSQL)
If you use PostgreSQL Row-Level Security (RLS), test that it actually enforces isolation even if the application layer fails:
-- Test RLS directly in your migration tests
DO $$
DECLARE
tenant_a_id UUID := '11111111-1111-1111-1111-111111111111';
tenant_b_id UUID := '22222222-2222-2222-2222-222222222222';
project_count INT;
BEGIN
-- Set tenant context to Tenant B
PERFORM set_config('app.current_tenant_id', tenant_b_id::TEXT, true);
-- Attempt to count Tenant A's projects (should be 0 due to RLS)
SELECT COUNT(*) INTO project_count
FROM projects
WHERE tenant_id = tenant_a_id;
ASSERT project_count = 0, 'RLS FAILURE: Tenant B can see Tenant A projects';
RAISE NOTICE 'RLS isolation test passed';
END $$;ORM Query Audit Test
Intercept all database queries during tests to ensure every query targeting tenant-owned tables includes a tenant filter:
// Custom Jest reporter that hooks into your ORM's query logger
const queryLog = [];
beforeEach(() => {
db.on('query', (query) => queryLog.push(query.sql));
});
afterEach(() => {
const tenantTables = ['projects', 'invoices', 'users', 'documents'];
queryLog.forEach(sql => {
const touchesTenantTable = tenantTables.some(t =>
sql.toLowerCase().includes(`from ${t}`) ||
sql.toLowerCase().includes(`join ${t}`)
);
if (touchesTenantTable && sql.toLowerCase().includes('select')) {
expect(sql.toLowerCase()).toContain('tenant_id');
}
});
queryLog.length = 0;
});Performance Testing Under Multi-Tenant Load
Noisy neighbor problems occur when one tenant's heavy usage degrades performance for others. Testing this requires simulating realistic concurrent load patterns.
k6 Multi-Tenant Load Test
// k6 load test: simulate 50 tenants with varied usage patterns
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
const tenants = new SharedArray('tenants', function () {
return JSON.parse(open('./test-tenants.json')); // pre-seeded tenant tokens
});
export const options = {
scenarios: {
normal_tenants: {
executor: 'constant-vus',
vus: 40,
duration: '5m',
},
heavy_tenant: {
executor: 'constant-vus',
vus: 10, // simulate one "noisy neighbor"
duration: '5m',
exec: 'heavyLoad',
},
},
thresholds: {
'http_req_duration{scenario:normal_tenants}': ['p95<500'],
'http_req_duration{scenario:heavy_tenant}': ['p95<2000'],
},
};
export default function () {
const tenant = tenants[__VU % tenants.length];
const res = http.get('https://api.yourapp.com/projects', {
headers: { Authorization: `Bearer ${tenant.token}` },
});
check(res, {
'status 200': r => r.status === 200,
'response time < 500ms': r => r.timings.duration < 500,
'correct tenant data': r => {
const body = JSON.parse(r.body);
return body.every(p => p.tenant_id === tenant.id);
},
});
sleep(Math.random() * 2);
}
export function heavyLoad() {
const tenant = tenants[0]; // heavy tenant always uses tenant[0]
// Simulate expensive report generation
http.post('https://api.yourapp.com/reports/generate',
JSON.stringify({ type: 'full', date_range: '365d' }),
{ headers: {
Authorization: `Bearer ${tenant.token}`,
'Content-Type': 'application/json'
}}
);
sleep(0.1);
}Testing Tenant Onboarding and Offboarding
Data lifecycle is often overlooked in multi-tenant testing. When a tenant is deleted, all their data must be purged — and none of it should remain accessible.
describe('Tenant Lifecycle', () => {
it('should completely purge all tenant data on account deletion', async () => {
const token = await createTenantAndLogin('delete-me@example.com');
const tenantId = await getTenantId(token);
// Create various resources
await createProject(token, 'My Project');
await createDocument(token, 'My Document');
await createApiKey(token, 'My API Key');
// Delete the tenant
await request(app)
.delete('/api/account')
.set('Authorization', `Bearer ${token}`)
.send({ confirmation: 'delete-me@example.com' });
// Verify all data is gone from the database
const projectCount = await db('projects').where({ tenant_id: tenantId }).count();
const documentCount = await db('documents').where({ tenant_id: tenantId }).count();
const apiKeyCount = await db('api_keys').where({ tenant_id: tenantId }).count();
expect(parseInt(projectCount[0].count)).toBe(0);
expect(parseInt(documentCount[0].count)).toBe(0);
expect(parseInt(apiKeyCount[0].count)).toBe(0);
});
});Key Takeaways
Testing multi-tenant SaaS applications requires a defense-in-depth approach. No single layer — not RLS, not ORM scoping, not API authorization — should be trusted in isolation. Test each layer independently and test the combination.
The most impactful tests you can write are:
- Cross-tenant IDOR tests for every resource type
- Feature flag and plan limit isolation tests
- Database query audits to catch missing
tenant_idfilters - Noisy neighbor performance tests under realistic concurrent load
- Complete data purge verification on tenant deletion
Build these into your CI pipeline and run them on every merge. A data leakage bug that reaches production in a multi-tenant SaaS is a business-ending event — catching it in code review is infinitely preferable.