Multi-Tenancy for AI SaaS: Design Safely
Multi-tenancy is the ability to serve hundreds or thousands of customers from a single codebase and database, while keeping each customer's data completely isolated. For AI SaaS, this is critical: you cannot afford separate databases for each customer, yet you must guarantee that one customer's prompts, responses, and billing data never leak to another. This article covers the architectural patterns, database design, and isolation enforcement that make multi-tenancy safe.
What is Multi-Tenancy in SaaS?
Multi-tenancy is the sharing of application infrastructure and databases across multiple organizations (tenants) such that each tenant sees only its own data. Unlike single-tenancy, where Acme Corp runs on its own server and database, multi-tenancy allows Acme, BigCorp, and StartupXYZ to all run on the same codebase with a single database, significantly reducing operational cost. The challenge is isolation: data from Acme must be cryptographically or logically sealed off from BigCorp, so a SQL injection bug or privilege escalation cannot leak secrets across tenants. For AI SaaS, this means storing each customer's conversations, API keys, and usage logs in a way that makes it impossible to accidentally return them to the wrong user.
Database Schema Design for Isolation
Schema With Explicit Tenant ID
The simplest and most auditable approach is to add a tenant_id (or organization_id) column to every table that holds customer data.
-- Multi-tenant schema: every table has tenant_id
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
stripe_customer_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE prompts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
prompt_text TEXT NOT NULL,
model TEXT NOT NULL,
response TEXT,
usage_tokens INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- CRITICAL: Create composite indexes for fast tenant-scoped queries
CREATE INDEX idx_api_keys_org_id ON api_keys(organization_id);
CREATE INDEX idx_prompts_org_id ON prompts(organization_id);
CREATE INDEX idx_prompts_org_created ON prompts(organization_id, created_at DESC);
Every query must include the organization_id filter to avoid returning data from other tenants:
-- CORRECT: Returns only prompts for this organization
SELECT * FROM prompts
WHERE organization_id = $1 AND created_at > $2;
-- WRONG: Could leak data if organization_id filter is forgotten
SELECT * FROM prompts WHERE created_at > $2;
Row-Level Security (RLS) for Database-Enforced Isolation
PostgreSQL offers Row-Level Security (RLS), which enforces tenant isolation at the database level. Even if your application forgets the organization_id filter, the database itself will prevent the query from returning cross-tenant data.
-- Enable RLS on sensitive tables
ALTER TABLE prompts ENABLE ROW LEVEL SECURITY;
-- Create a policy that only allows access to a user's own tenant data
CREATE POLICY prompts_isolation ON prompts
USING (organization_id = CURRENT_SETTING('app.current_tenant_id')::uuid)
WITH CHECK (organization_id = CURRENT_SETTING('app.current_tenant_id')::uuid);
-- Before every query, set the tenant context
SET app.current_tenant_id = '550e8400-e29b-41d4-a716-446655440000';
-- Now SELECT * FROM prompts returns only this organization's rows
SELECT * FROM prompts;
RLS is a safety net: your application code still filters by organization_id, but if a bug or injection attack forgets the filter, the database blocks the leak.
Application-Level Isolation Patterns
Middleware for Tenant Context
Every request carries a tenant ID (from the JWT token, API key, or session). Use middleware to inject this context throughout the request lifecycle.
# FastAPI middleware: inject tenant_id into request state
from fastapi import FastAPI, Request
from fastapi.security import HTTPBearer, HTTPAuthCredentials
import jwt
import os
app = FastAPI()
security = HTTPBearer()
@app.middleware("http")
async def inject_tenant_context(request: Request, call_next):
"""Extract tenant_id from JWT and store in request state."""
try:
auth_header = request.headers.get("Authorization", "")
token = auth_header.replace("Bearer ", "")
payload = jwt.decode(
token,
key=os.environ["JWT_SECRET"],
algorithms=["HS256"]
)
request.state.tenant_id = payload.get("organization_id")
request.state.user_id = payload.get("user_id")
except Exception:
request.state.tenant_id = None
request.state.user_id = None
response = await call_next(request)
return response
@app.get("/api/v1/prompts")
async def list_prompts(request: Request):
"""All queries automatically scoped to request.state.tenant_id."""
if not request.state.tenant_id:
return {"error": "Unauthorized"}
# Database query automatically filters by tenant_id
db = get_db()
prompts = db.query(Prompt).filter(
Prompt.organization_id == request.state.tenant_id
).all()
return {"prompts": prompts}
API Key Scoping
When a customer makes an API call, their API key should encode their organization_id. Never trust user input to specify which organization they belong to.
# API key validation: extract organization from the key
def validate_api_key(api_key_string: str) -> dict | None:
"""Decode API key and return organization metadata."""
# API key format: sk_<org_id>_<random_secret>
try:
parts = api_key_string.split("_")
if len(parts) < 3 or parts[0] != "sk":
return None
org_id = parts[1]
secret = "_".join(parts[2:])
# Verify the secret in the database
db = get_db()
key_record = db.query(APIKey).filter(
APIKey.key_hash == hash_secret(secret)
).first()
if not key_record or key_record.organization_id != org_id:
return None
return {
"organization_id": org_id,
"key_id": key_record.id,
"rate_limit": key_record.rate_limit_per_minute
}
except Exception:
return None
Never accept an organization_id from the request body. Always derive it from authentication credentials.
Isolation Comparison: Strategies and Trade-Offs
| Strategy | Isolation Level | Complexity | Performance | Best For |
|---|---|---|---|---|
| Column-based filtering | Application-enforced | Low | High | Small teams, MVP |
| Row-Level Security (RLS) | Database-enforced | Medium | High | Medium teams, compliance-critical |
| Separate databases per tenant | Complete isolation | High | Medium | Enterprise, regulated industries |
| Separate schemas per tenant | Schema-isolated | Medium | High | Large deployments, schema customization |
Key Takeaways
- Add a
tenant_idororganization_idcolumn to every data table and enforce it at the application level. - Use PostgreSQL Row-Level Security (RLS) as a database-level safety net to prevent data leaks even if application logic fails.
- Extract tenant identity from authentication credentials (JWT token, API key) and store it in request context; never trust user input for tenant membership.
- Index all queries by
organization_idfirst to ensure fast lookups. - Test isolation with a pen-test scenario: try to access another tenant's data using valid credentials from a different organization.
Frequently Asked Questions
Can I use a JWT to encode the organization_id, or does the user choose it?
Encode the organization_id in the JWT at login time by your authentication service. The user cannot modify a JWT without invalidating its signature. Do not allow the user to specify organization_id in the request body; extract it from the token.
What happens if a tenant is deleted? Should I soft-delete?
Use hard delete with CASCADE ON DELETE for data cleanup. This ensures you comply with GDPR right-to-be-forgotten. Test the cascade thoroughly; a single missed foreign key reference can leave orphaned data. For audit trails, keep a separate immutable log table that is never deleted.
How do I handle shared data, like public LLM models or templates?
Create a special organization_id of null or a constant UUID for shared data. Before querying, check if the user has access to shared resources via a permissions table. This keeps shared data in the same schema while making it logically distinct.
Does RLS add latency?
RLS adds negligible overhead (<1 ms per query) if you index the tenant column. The database still uses your composite indexes; RLS just filters the result set before returning it to the application.