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:
| 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 invoiceReturn 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 automaticallyRLS 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.