Multitenancy Testing: Tenant Isolation Verification Strategies

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:

  1. Create resource as Tenant A
  2. Authenticate as Tenant B
  3. Attempt to access/modify Tenant A's resource
  4. 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.

Read more