{"slug": "building-multi-tenant-row-level-security-in-postgresql-a-production-pattern", "title": "Building Multi-Tenant Row-Level Security in PostgreSQL: A Production Pattern", "summary": "The article describes a production pattern for implementing multi-tenant row-level security (RLS) in PostgreSQL, arguing that application-layer tenant isolation is unreliable because bugs like missing authorization checks or refactoring errors can expose data across tenants. The author presents a database-enforced approach where PostgreSQL uses session variables (set after authentication) to automatically filter rows by tenant_id, preventing unauthorized data access regardless of application code mistakes. The pattern includes enabling RLS on tables, creating policies that reference session settings like `app.current_tenant_id`, and wiring this into frameworks like FastAPI and SQLAlchemy to eliminate the need for manual tenant filtering in queries.", "body_md": "# Building Multi-Tenant Row-Level Security in PostgreSQL: A Production Pattern\n\nMost multi-tenant SaaS applications implement tenant isolation in the application layer. You check `request.tenant_id`\n\nbefore querying, validate ownership in your service layer, maybe add a middleware that throws if the IDs don't match. It works—until it doesn't.\n\nI've watched this pattern burn production systems. A junior developer forgets one authorization check. A refactor moves logic around and the guard rails disappear. A cron job runs with elevated privileges and suddenly exports competitor data. These aren't hypotheticals—I've debugged all three in CitizenApp.\n\nDatabase-enforced Row-Level Security (RLS) flips the model: the database itself refuses to return rows that don't belong to your tenant, regardless of what code tries to access them. This is belt and suspenders, but the belt actually works.\n\n## Why Application-Layer Isolation Fails\n\nLet me be direct: **application layer isolation is a suggestion, not a guarantee.**\n\nConsider this typical FastAPI pattern:\n\n``` python\n@router.get(\"/users\")\nasync def list_users(\n    current_user: User = Depends(get_current_user),\n    db: Session = Depends(get_db)\n):\n    # Authorization happens here\n    return db.query(User).filter(User.tenant_id == current_user.tenant_id).all()\n```\n\nThis looks safe. But:\n\n-\n**Forgotten filters**: A new endpoint queries`User`\n\nwithout the tenant check. Easy mistake. -\n**Scope creep**: An admin panel needs to see all users across tenants—so you bypass the filter. Now that code path exists and someone copies it. -\n**N+1 relationships**: You load users, then loop through and load their audit logs. The second query forgets the tenant filter. -\n**Background jobs**: A Celery task runs as a \"system user\" with`tenant_id = None`\n\n. Now it can see everything.\n\nThe worst part? **These bugs are invisible until they're exploited.** Your tests pass because they run within a single tenant context. Your monitoring doesn't catch it because the data is technically being accessed correctly—just by the wrong person.\n\n## PostgreSQL RLS: Enforcement at the Source\n\nRLS policies live in the database. PostgreSQL evaluates them *before* returning any row. You cannot read data you're not allowed to read—the database won't let you.\n\nHere's the pattern I use:\n\n```\n-- Enable RLS on the users table\nALTER TABLE users ENABLE ROW LEVEL SECURITY;\n\n-- Create a policy that only allows access to your own tenant\nCREATE POLICY users_tenant_isolation ON users\n  FOR ALL\n  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);\n\n-- Create a separate policy for superusers (if needed)\nCREATE POLICY users_admin_all ON users\n  FOR ALL\n  USING (current_setting('app.is_admin')::boolean = true);\n\n-- Disable RLS for the database owner (migration scripts need this)\nALTER TABLE users FORCE ROW LEVEL SECURITY;\n```\n\nThe key is `current_setting()`\n\n. This is a PostgreSQL function that reads session variables. Your application sets these *after* authentication, and the database uses them to filter queries automatically.\n\n## Implementing with SQLAlchemy\n\nHere's how I wire this into FastAPI + SQLAlchemy:\n\n``` python\nfrom sqlalchemy import create_engine, text, event\nfrom sqlalchemy.orm import sessionmaker, Session\nfrom typing import Optional\n\nengine = create_engine(\"postgresql://...\", echo=False)\nSessionLocal = sessionmaker(bind=engine)\n\ndef set_rls_context(session: Session, tenant_id: str, is_admin: bool = False):\n    \"\"\"Set the RLS context before executing queries.\"\"\"\n    session.execute(\n        text(\"SET app.current_tenant_id = :tenant_id\"),\n        {\"tenant_id\": tenant_id}\n    )\n    session.execute(\n        text(\"SET app.is_admin = :is_admin\"),\n        {\"is_admin\": is_admin}\n    )\n\nasync def get_db(\n    current_user: User = Depends(get_current_user)\n) -> Session:\n    \"\"\"Dependency that creates a session with RLS context.\"\"\"\n    session = SessionLocal()\n    try:\n        set_rls_context(\n            session,\n            tenant_id=str(current_user.tenant_id),\n            is_admin=current_user.role == \"admin\"\n        )\n        yield session\n    finally:\n        session.close()\n```\n\nNow your query is simple:\n\n``` python\n@router.get(\"/users\")\nasync def list_users(db: Session = Depends(get_db)):\n    # No tenant filter needed—RLS handles it\n    return db.query(User).all()\n```\n\nPostgreSQL *silently filters* based on the session context. If the current user belongs to tenant `abc-123`\n\n, they see only users where `tenant_id = 'abc-123'`\n\n. Try to query `SELECT * FROM users`\n\n, and you get only your tenant's rows.\n\n## The SQLAlchemy Model\n\nYour models stay clean:\n\n``` python\nfrom sqlalchemy import Column, String, UUID, ForeignKey\nfrom sqlalchemy.orm import declarative_base\nimport uuid\n\nBase = declarative_base()\n\nclass User(Base):\n    __tablename__ = \"users\"\n\n    id = Column(UUID, primary_key=True, default=uuid.uuid4)\n    tenant_id = Column(UUID, ForeignKey(\"tenants.id\"), nullable=False)\n    email = Column(String, nullable=False)\n    role = Column(String, default=\"user\")\n```\n\nNo special ORM magic. SQLAlchemy doesn't need to know about RLS—that's the entire point. The database enforces it.\n\n## Cascading RLS Across Relationships\n\nThis is where it gets powerful. Your `organizations`\n\n, `projects`\n\n, `audit_logs`\n\n, and `invoices`\n\ntables all need RLS, but you only set the context once:\n\n```\nALTER TABLE organizations ENABLE ROW LEVEL SECURITY;\nCREATE POLICY org_tenant_isolation ON organizations\n  FOR ALL\n  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);\n\nALTER TABLE projects ENABLE ROW LEVEL SECURITY;\nCREATE POLICY project_tenant_isolation ON projects\n  FOR ALL\n  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);\n\nALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;\nCREATE POLICY audit_tenant_isolation ON audit_logs\n  FOR ALL\n  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);\n```\n\nAfter `set_rls_context()`\n\n, *all queries* across *all tables* respect the tenant boundary. A JOIN between projects and audit logs? Still filtered. A transaction that touches three tables? Still filtered. A future developer adds a new table and forgets the RLS policy? PostgreSQL will reject writes until they add it.\n\n## What I Missed (The Gotcha)\n\n**Migrations and script runners must disable RLS.** Your Alembic migrations run as the database owner, and if RLS is enforced, some operations fail. I handle this:\n\n``` python\n# alembic/env.py\ndef run_migrations_online():\n    with connectable.connect() as connection:\n        # RLS doesn't apply to superuser if FORCE ROW LEVEL SECURITY isn't set\n        # But for safety, disable it during migrations\n        connection.execute(text(\"ALTER ROLE myapp_user BYPASSRLS\"))\n\n        with connection.begin():\n            context.configure(connection=connection, target_metadata=target_metadata)\n            with context.begin_transaction():\n                context.run_migrations()\n```\n\nAlso: ** current_setting() returns NULL if not set.** This means a query with no context returns zero rows—which is actually the safe default, but confusing during local development. I always set a test context:\n\n``` python\n# conftest.py for pytest\n@pytest.fixture\ndef db_with_rls():\n    session = SessionLocal()\n    set_rls_context(session, tenant_id=\"test-tenant-123\")\n    yield session\n    session.close()\n```\n\n## The Outcome\n\nIn CitizenApp, implementing RLS was the moment I stopped worrying about authorization bugs. Not because I stopped making mistakes, but because the database makes those mistakes impossible.\n\nEvery endpoint, every background job, every future feature—they all inherit the same ironclad guarantee: you cannot read or modify another tenant's data, no matter what code path you take.\n\nThat's the only pattern worth building on.", "url": "https://wpnews.pro/news/building-multi-tenant-row-level-security-in-postgresql-a-production-pattern", "canonical_source": "https://dev.to/uaslimcreate/building-multi-tenant-row-level-security-in-postgresql-a-production-pattern-4n2k", "published_at": "2026-05-22 14:03:51+00:00", "updated_at": "2026-05-22 14:40:55.114290+00:00", "lang": "en", "topics": ["data", "cybersecurity", "developer-tools", "cloud-computing", "enterprise-software"], "entities": ["PostgreSQL", "CitizenApp", "FastAPI"], "alternates": {"html": "https://wpnews.pro/news/building-multi-tenant-row-level-security-in-postgresql-a-production-pattern", "markdown": "https://wpnews.pro/news/building-multi-tenant-row-level-security-in-postgresql-a-production-pattern.md", "text": "https://wpnews.pro/news/building-multi-tenant-row-level-security-in-postgresql-a-production-pattern.txt", "jsonld": "https://wpnews.pro/news/building-multi-tenant-row-level-security-in-postgresql-a-production-pattern.jsonld"}}