OWASP Cheat Sheet Series: Multi Tenant Security Cheat Sheet

# Multi-Tenant Application Security Cheat Sheet

## Introduction

Multi-tenant applications serve multiple customers (tenants) from a shared infrastructure, codebase, and often shared databases. This architecture is the foundation of modern SaaS platforms, offering cost efficiency and simplified operations.

However, multi-tenancy introduces critical security challenges: a single vulnerability can expose all tenants' data, misconfigurations can leak data across tenant boundaries, and resource contention can impact availability.

This cheat sheet provides best practices to secure multi-tenant applications, ensure tenant isolation, and prevent cross-tenant attacks.

## Key Risks

- **Cross-Tenant Data Leakage**: Bugs or misconfigurations exposing one tenant's data to another.
- **Tenant Impersonation**: Attackers gaining access to another tenant's context or resources.
- **Broken Tenant Isolation**: Insufficient separation at database, cache, storage, or compute layers.
- **Insecure Direct Object References (IDOR)**: Accessing resources by manipulating tenant/resource IDs.
- **Noisy Neighbor Attacks**: One tenant exhausting shared resources, impacting others (DoS).
- **Privilege Escalation Across Tenants**: Exploiting admin functions to access other tenants.
- **Tenant Context Injection**: Manipulating tenant identifiers in requests, tokens, or headers.
- **Shared Resource Poisoning**: Cache poisoning, queue injection, or storage pollution affecting other tenants.
- **Insecure Tenant Onboarding/Offboarding**: Incomplete provisioning or data retention after deletion.
- **Audit & Compliance Gaps**: Insufficient tenant-specific logging for regulatory requirements.

## Best Practices

### 1. Tenant Identification & Context Management

- Establish tenant context early in the request lifecycle (middleware/interceptor).
- Use cryptographically secure, non-guessable tenant identifiers.
- Never trust client-supplied tenant IDs without validation.
- Bind tenant context to the authenticated user session.
- Propagate tenant context securely through all application layers.

<details>
<summary>Bad example: Trusting client-supplied tenant ID</summary>

```python
# Dangerous: Tenant ID from request header without validation/query parameterization
def get_tenant_data(request):
    tenant_id = request.headers.get("X-Tenant-ID")  # Attacker can modify!
    return db.execute("SELECT * FROM data WHERE tenant_id = :tid", {"tid": tenant_id})
```

</details>

<details>
<summary>Good example: Deriving tenant from authenticated session</summary>

```python
from functools import wraps
from contextvars import ContextVar
from typing import Optional

# Thread-safe tenant context
current_tenant: ContextVar[Optional[str]] = ContextVar('current_tenant', default=None)

class TenantContext:
    def __init__(self, tenant_id: str, user_id: str, roles: list):
        self.tenant_id = tenant_id
        self.user_id = user_id
        self.roles = roles
        self.is_validated = True

class TenantMiddleware:
    """Extract and validate tenant context from authenticated session."""
    
    async def __call__(self, request, call_next):
        # Get tenant from verified JWT claims - NOT from headers
        token_claims = request.state.verified_claims  # Set by auth middleware
        
        if not token_claims or "tenant_id" not in token_claims:
            return JSONResponse(status_code=401, content={"error": "Missing tenant context"})
        
        tenant_id = token_claims["tenant_id"]
        
        # Validate tenant exists and is active
        tenant = await self.tenant_service.get_active_tenant(tenant_id)
        if not tenant:
            return JSONResponse(status_code=403, content={"error": "Invalid tenant"})
        
        # Set tenant context for this request
        ctx = TenantContext(
            tenant_id=tenant_id,
            user_id=token_claims["sub"],
            roles=token_claims.get("roles", [])
        )
        token = current_tenant.set(ctx)
        
        try:
            response = await call_next(request)
            return response
        finally:
            current_tenant.reset(token)

def require_tenant(func):
    """Decorator ensuring tenant context is present."""
    @wraps(func)
    async def wrapper(*args, **kwargs):
        ctx = current_tenant.get()
        if not ctx or not ctx.is_validated:
            raise SecurityException("Tenant context required")
        return await func(*args, **kwargs)
    return wrapper
```

</details>

### 2. Database Isolation Strategies

Choose an isolation strategy based on security requirements, compliance needs, and operational complexity:

| Strategy | Isolation Level | Use Case |
|----------|----------------|----------|
| Separate Databases | Highest | Regulated industries, enterprise clients |
| Separate Schemas | High | Balance of isolation and manageability |
| Shared Tables (Row-Level) | Medium | Cost-sensitive, high tenant count |
| Hybrid | Variable | Different tiers for different customers |

<details>
<summary>Row-Level Security Implementation (PostgreSQL)</summary>

```sql
-- Enable RLS on tenant tables
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;

-- Create policy that restricts access to current tenant
CREATE POLICY tenant_isolation_policy ON orders
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

CREATE POLICY tenant_isolation_policy ON customers
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- Force RLS for table owners too (important!)
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
ALTER TABLE customers FORCE ROW LEVEL SECURITY;
```

</details>

<details>
<summary>Application-Level Enforcement (Python/SQLAlchemy)</summary>

```python
from sqlalchemy import event, Column, String
from sqlalchemy.orm import Session, Query
from sqlalchemy.ext.declarative import declared_attr
from contextlib import contextmanager

class TenantMixin:
    """Mixin that adds tenant_id to all models."""
    
    @declared_attr
    def tenant_id(cls):
        return Column(String(36), nullable=False, index=True)

class TenantAwareSession(Session):
    """Session that automatically filters by tenant."""
    
    def __init__(self, *args, tenant_id: str = None, **kwargs):
        super().__init__(*args, **kwargs)
        self._tenant_id = tenant_id
    
    @property
    def tenant_id(self):
        if not self._tenant_id:
            raise SecurityException("Tenant ID not set on session")
        return self._tenant_id

# Automatically add tenant filter to all queries
@event.listens_for(Query, "before_compile", retval=True)
def add_tenant_filter(query):
    tenant_id = current_tenant.get()
    if not tenant_id:
        raise SecurityException("No tenant context for query")
    
    for desc in query.column_descriptions:
        entity = desc.get('entity')
        if entity and hasattr(entity, 'tenant_id'):
            q