Testing Multi-Tenant SaaS Applications: Isolation, Data, and Performance

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:

  1. Cross-tenant IDOR tests for every resource type
  2. Feature flag and plan limit isolation tests
  3. Database query audits to catch missing tenant_id filters
  4. Noisy neighbor performance tests under realistic concurrent load
  5. 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.

Read more