Skip to main content
All posts

Building Multi-Tenant SaaS with SQLAlchemy Row-Level Security

20 May 2026

Building Multi-Tenant SaaS with SQLAlchemy Row-Level Security

I’ve seen the same mistake twice: a founder builds a multi-tenant SaaS, implements tenant checks in their application code, and six months into production, a customer accidentally queries another tenant’s data. The bug wasn’t in the business logic—it was a forgotten .filter(User.tenant_id == current_tenant_id) in some API endpoint.

That’s why I’m obsessive about enforcing tenant isolation at the database layer. Application code lies. SQL doesn’t.

This is how CitizenApp prevents tenant data leaks: PostgreSQL Row-Level Security (RLS) + SQLAlchemy session management. Not belt-and-suspenders paranoia—it’s the only pattern I trust for production SaaS.

The Problem with Application-Layer Isolation

Most developers stop at this:

// TypeScript API endpoint
async function getUsersByTenant(tenantId: string) {
  return await db.user.findMany({
    where: { tenantId }
  });
}

This works until:

I’ve burned time debugging all four. The issue is trust—you’re relying on developers to remember a rule that should be enforced by infrastructure.

PostgreSQL Row-Level Security Is Your Safety Net

RLS makes it impossible to accidentally leak data. The database itself denies access before your application code even runs.

Here’s the pattern I use in CitizenApp:

-- Create a tenant table
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Create a users table with tenant_id
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email VARCHAR UNIQUE NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

-- Enable RLS on users table
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- Create a policy: users can only see rows where tenant_id matches their session variable
CREATE POLICY users_isolation ON users
  FOR SELECT
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Same for INSERT, UPDATE, DELETE
CREATE POLICY users_insert ON users
  FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY users_update ON users
  FOR UPDATE
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY users_delete ON users
  FOR DELETE
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

The magic is current_setting('app.current_tenant_id'). This is a session variable we set at connection time. PostgreSQL checks every query against it.

Integrating with SQLAlchemy

Here’s where the infrastructure meets the code. I use SQLAlchemy’s SessionLocal with an event listener to set the tenant context:

from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import sessionmaker, Session
from typing import Optional
import uuid

# Database setup
DATABASE_URL = "postgresql://user:password@localhost/citizenapp"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)

# Global context variable (use contextvars in production)
_current_tenant_id: Optional[str] = None

def set_tenant_context(tenant_id: str):
    """Set the current tenant for this request"""
    global _current_tenant_id
    _current_tenant_id = tenant_id

def get_tenant_context() -> str:
    """Retrieve the current tenant"""
    if not _current_tenant_id:
        raise ValueError("No tenant context set")
    return _current_tenant_id

# Event listener: set PostgreSQL session variable when connection is used
@event.listens_for(Engine, "connect")
def receive_connect(dbapi_conn, connection_record):
    cursor = dbapi_conn.cursor()
    tenant_id = get_tenant_context()
    cursor.execute(
        f"SET app.current_tenant_id = '{tenant_id}'"
    )
    cursor.close()

class TenantAwareSession(Session):
    """Custom session that enforces tenant context"""
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Verify tenant context exists
        _ = get_tenant_context()

In your FastAPI application:

from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthCredentials
import jwt

app = FastAPI()
security = HTTPBearer()

def get_current_tenant(credentials: HTTPAuthCredentials = Depends(security)) -> str:
    """Extract tenant from JWT token"""
    try:
        payload = jwt.decode(
            credentials.credentials,
            "your-secret-key",
            algorithms=["HS256"]
        )
        tenant_id = payload.get("tenant_id")
        if not tenant_id:
            raise HTTPException(status_code=401, detail="Invalid token")
        return tenant_id
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")

@app.get("/api/users")
async def list_users(tenant_id: str = Depends(get_current_tenant)):
    """List users for current tenant"""
    set_tenant_context(tenant_id)
    db = SessionLocal()
    try:
        # This query is automatically filtered by RLS
        # Even if you forgot the WHERE clause, PostgreSQL enforces it
        users = db.query(User).all()
        return users
    finally:
        db.close()

The critical detail: RLS runs at the database level, not in your ORM layer. Your query doesn’t need a .filter(User.tenant_id == tenant_id) because PostgreSQL rejects any rows that don’t match the session variable.

The Gotcha: Connection Pooling

This burned me for three hours on a Friday: if you use connection pooling (Pgbouncer, for example), session variables don’t persist across requests. Each request might get a different physical connection.

Solution: Set the tenant context before every query:

@app.get("/api/users")
async def list_users(tenant_id: str = Depends(get_current_tenant)):
    db = SessionLocal()
    try:
        # Set tenant context every time
        set_tenant_context(tenant_id)
        db.execute(text(f"SET app.current_tenant_id = '{tenant_id}'"))
        
        users = db.query(User).all()
        return users
    finally:
        db.close()

Or use a middleware to handle this globally (cleaner):

from starlette.middleware.base import BaseHTTPMiddleware

class TenantMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # Extract tenant from token
        token = request.headers.get("authorization", "").replace("Bearer ", "")
        payload = jwt.decode(token, "secret", algorithms=["HS256"])
        tenant_id = payload["tenant_id"]
        
        # Set context for this request
        request.state.tenant_id = tenant_id
        set_tenant_context(tenant_id)
        
        response = await call_next(request)
        return response

app.add_middleware(TenantMiddleware)

Why I Prefer This Over Application-Only Checks

  1. Defense in depth: A bug in your code can’t leak data
  2. Compliance: Auditors see RLS in the database layer
  3. Performance: PostgreSQL optimizes queries knowing they’re filtered
  4. Team safety: Junior devs can’t accidentally break isolation

The application-level checks are still good to have—they’re your first line of defense. But the database layer is where isolation actually happens.

CitizenApp processes multi-tenant data for 8+ AI features across thousands of requests daily. RLS has prevented exactly zero data leaks because it’s impossible to leak data that the database refuses to return.

That’s production peace of mind.

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch