Skip to main content
Security for Application Engineers

Multi-Tenancy and Isolation

Ravinder··6 min read
SecurityAppSecMulti-TenancyIsolationData Security
Share:
Multi-Tenancy and Isolation

Cross-tenant data leaks are one of the most damaging classes of bugs in SaaS products. Unlike an XSS vulnerability that affects one user at a time, a missing tenant scope in a database query can expose every record in your system to any authenticated user. It is also one of the easiest bugs to introduce: a developer adds an endpoint, writes a query that filters by resource ID, and forgets that the resource ID is not globally unique — it is only unique within the tenant.

Isolation is not a feature you add. It is a property of every query, every cache key, every job queue, every log line. This post covers how to enforce it systematically.

The Isolation Spectrum

Multi-tenant architectures vary in how much infrastructure tenants share:

flowchart LR subgraph S1["Silo Model"] T1DB[("Tenant A DB")] T2DB[("Tenant B DB")] T1App["App instance A"] T2App["App instance B"] T1App --> T1DB T2App --> T2DB end subgraph S2["Schema-per-Tenant"] SharedDB[("Shared DB")] SchA["schema_a"] SchB["schema_b"] SharedDB --> SchA SharedDB --> SchB end subgraph S3["Shared Schema"] SharedDB2[("Shared DB")] Table["orders\n(tenant_id column)"] SharedDB2 --> Table end
Model Isolation strength Cost Typical fit
Silo (DB per tenant) Highest High Enterprise / compliance-heavy
Schema per tenant Medium-high Medium Mid-market SaaS
Shared schema + tenant_id Medium Low SMB SaaS, early-stage

Most high-growth SaaS products start with shared schema and migrate toward schema-per-tenant or silos for enterprise customers. The shared schema model has the lowest isolation — every bug in tenant scoping affects all tenants.

Enforcing Tenant Scope in Queries

The most common cross-tenant vulnerability: a developer queries by a resource ID without including the tenant ID.

# VULNERABLE — any authenticated user can read any invoice by guessing its ID
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, current_user=Depends(get_current_user), db=Depends(get_db)):
    invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
    if invoice is None:
        raise HTTPException(404)
    return invoice
 
# SAFE — always scope to the current tenant
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, current_user=Depends(get_current_user), db=Depends(get_db)):
    invoice = (
        db.query(Invoice)
        .filter(Invoice.id == invoice_id, Invoice.tenant_id == current_user.tenant_id)
        .first()
    )
    if invoice is None:
        raise HTTPException(404)  # same response for not found and unauthorized
    return invoice

Return 404 (not 403) when a resource exists but belongs to a different tenant — don't confirm the resource's existence to the requesting tenant.

Repository Pattern with Built-in Scoping

Rather than relying on every developer to remember the tenant_id filter, bake it into the data access layer.

from sqlalchemy.orm import Session
 
class TenantScopedRepository:
    """Base repository that automatically scopes all queries to the current tenant."""
 
    def __init__(self, db: Session, tenant_id: str):
        self.db = db
        self.tenant_id = tenant_id
 
    def _base_query(self, model):
        return self.db.query(model).filter(model.tenant_id == self.tenant_id)
 
    def get_by_id(self, model, resource_id: str):
        return self._base_query(model).filter(model.id == resource_id).first()
 
    def list(self, model, **filters):
        q = self._base_query(model)
        for key, value in filters.items():
            q = q.filter(getattr(model, key) == value)
        return q.all()
 
    def create(self, model, **fields):
        obj = model(tenant_id=self.tenant_id, **fields)
        self.db.add(obj)
        self.db.flush()
        return obj
 
# Usage — tenant_id is set at the DI boundary, not in business logic
class InvoiceService:
    def __init__(self, repo: TenantScopedRepository):
        self.repo = repo
 
    def get_invoice(self, invoice_id: str):
        return self.repo.get_by_id(Invoice, invoice_id)

This pattern makes the safe path the easy path. A developer using InvoiceService cannot accidentally write an unscoped query — the repository enforces it.

PostgreSQL Row-Level Security

For the most robust enforcement, push tenant scoping into the database itself using Row-Level Security (RLS). Even if application code has a bug, the database rejects cross-tenant reads.

-- Enable RLS on the invoices table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
 
-- Policy: users can only see rows matching their tenant
CREATE POLICY tenant_isolation ON invoices
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
 
-- Application: set the tenant context before each query
-- (use a connection pool that supports per-transaction settings)
from contextlib import contextmanager
 
@contextmanager
def tenant_context(db: Session, tenant_id: str):
    db.execute(
        "SET LOCAL app.current_tenant_id = :tid",
        {"tid": tenant_id}
    )
    try:
        yield db
    finally:
        pass  # SET LOCAL automatically resets at transaction end
 
# Usage
with tenant_context(db, current_user.tenant_id) as scoped_db:
    invoices = scoped_db.query(Invoice).all()  # RLS filters automatically

RLS provides defense-in-depth: even if the application layer has a scoping bug, the database enforces the boundary.

Cross-Tenant Risks Beyond Queries

Tenant isolation is not just about database queries.

Cache keys. A cached response must include the tenant ID. A global cache key like cache:invoice:{id} is shared across tenants; use cache:invoice:{tenant_id}:{id}.

def cache_key(tenant_id: str, resource_type: str, resource_id: str) -> str:
    return f"{resource_type}:{tenant_id}:{resource_id}"

Async job queues. Jobs must carry the tenant context and re-establish the scoped session when processing.

# Celery task — always include tenant_id in the task payload
@celery_app.task
def generate_report(tenant_id: str, report_params: dict):
    with tenant_context(db_session(), tenant_id) as db:
        # All DB access in this task is scoped to tenant_id
        data = InvoiceRepository(db, tenant_id).list(Invoice)
        ...

File storage. S3 keys and GCS object paths must be tenant-prefixed. Never use a flat namespace where documents/{document_id} is globally addressable.

def s3_key(tenant_id: str, document_id: str, filename: str) -> str:
    return f"tenants/{tenant_id}/documents/{document_id}/{filename}"

Webhooks and outbound callbacks. Ensure that tenant A cannot configure a webhook URL that causes your system to deliver tenant B's events to tenant A's endpoint. Validate that the webhook target belongs to the configuring tenant.

Testing Tenant Isolation

Isolation bugs are easy to miss in unit tests because most unit tests operate within a single tenant. Write cross-tenant integration tests explicitly.

import pytest
 
@pytest.mark.integration
def test_tenant_isolation_invoice(client, tenant_a_token, tenant_b_invoice_id):
    """Tenant A should not be able to read Tenant B's invoice."""
    response = client.get(
        f"/invoices/{tenant_b_invoice_id}",
        headers={"Authorization": f"Bearer {tenant_a_token}"}
    )
    # Must be 404, not 200 or 403
    assert response.status_code == 404
 
@pytest.mark.integration
def test_tenant_isolation_list(client, tenant_a_token, tenant_b_data):
    """Listing invoices as Tenant A must not include Tenant B's records."""
    response = client.get("/invoices", headers={"Authorization": f"Bearer {tenant_a_token}"})
    assert response.status_code == 200
    tenant_b_ids = {inv["id"] for inv in tenant_b_data}
    returned_ids = {inv["id"] for inv in response.json()}
    assert returned_ids.isdisjoint(tenant_b_ids), "Cross-tenant leak detected"

Make these tests part of your CI suite. A regression in isolation is a critical security bug.

Key Takeaways

  • Cross-tenant data leaks happen when developers filter by resource ID without including the tenant ID — return 404 (not 403) to avoid confirming a resource's existence to the wrong tenant.
  • Repository patterns that bake in tenant scoping make the safe path the default; developers using the repository cannot write unscoped queries accidentally.
  • PostgreSQL Row-Level Security adds database-level enforcement as defense-in-depth; even buggy application code cannot read cross-tenant rows.
  • Tenant isolation applies beyond database queries — cache keys, async job payloads, object storage paths, and webhook routing all need tenant context.
  • Write explicit cross-tenant integration tests in CI; single-tenant unit tests cannot catch isolation regressions.
  • As you grow upmarket, plan the migration from shared schema toward schema-per-tenant or silo models — the isolation requirements of enterprise customers almost always exceed what shared-schema enforcement can provide.
Share: