Cheatsheet for Postgres RLS-based multi-tenancy. Long-form: Postgres textbook Ch 8 .

Enable RLS on a table

ALTER TABLE users ENABLE ROW LEVEL SECURITY;

Tenant isolation policy

CREATE POLICY tenant_iso ON users
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);

USING for SELECT/UPDATE/DELETE. WITH CHECK for INSERT/UPDATE inserts.

Alembic migration

def upgrade():
    op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY")
    op.execute("""
        CREATE POLICY tenant_iso ON users
        USING (tenant_id = current_setting('app.tenant_id')::bigint)
        WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint)
    """)

Application role (NO BYPASSRLS)

CREATE ROLE app_user WITH LOGIN PASSWORD '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- DO NOT grant BYPASSRLS

App connects as app_user. RLS applies.

Admin role (BYPASSRLS) — restricted use

CREATE ROLE app_admin WITH LOGIN PASSWORD '...' BYPASSRLS;
GRANT ALL ON ALL TABLES IN SCHEMA public TO app_admin;

Use only for admin paths; audit every use.

Set tenant per request (FastAPI)

import contextvars
current_tenant: contextvars.ContextVar[int | None] = contextvars.ContextVar("tenant", default=None)

@event.listens_for(engine.sync_engine, "before_cursor_execute")
def set_tenant(conn, cur, stmt, params, ctx, executemany):
    tid = current_tenant.get()
    if tid is not None:
        cur.execute(f"SET LOCAL app.tenant_id = '{tid}'")
@app.middleware("http")
async def tenant_mw(request, call_next):
    tid = await resolve_tenant(request)
    token = current_tenant.set(tid)
    try:
        return await call_next(request)
    finally:
        current_tenant.reset(token)

Set tenant via dep

async def get_db(request: Request) -> AsyncSession:
    async with AsyncSessionLocal() as session:
        if tid := getattr(request.state, "tenant_id", None):
            await session.execute(text("SET LOCAL app.tenant_id = :t"), {"t": tid})
        yield session

Test isolation

async def test_rls(db_session):
    await db_session.execute(text("SET LOCAL app.tenant_id = '1'"))
    db_session.add(User(tenant_id=1, email="[email protected]"))
    await db_session.commit()
    
    await db_session.execute(text("SET LOCAL app.tenant_id = '2'"))
    rows = (await db_session.execute(select(User))).scalars().all()
    assert rows == []

CI test for cross-tenant access.

Policy for INSERT only

CREATE POLICY tenant_insert ON users FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);

For tables where reads are global but writes are tenant-scoped.

Multiple policies

-- Tenant isolation
CREATE POLICY tenant_iso ON posts
USING (tenant_id = current_setting('app.tenant_id')::bigint);

-- Author-only writes
CREATE POLICY author_only_writes ON posts FOR UPDATE
USING (author_id = current_setting('app.user_id')::bigint);

Multiple policies on the same table; combined with OR (default) or all-required (AS RESTRICTIVE).

FORCE RLS (apply to table owner too)

ALTER TABLE users FORCE ROW LEVEL SECURITY;

Without FORCE, the table owner bypasses RLS. With FORCE: owner is subject too.

Performance

RLS predicates added to every query. Make sure indexes cover them:

__table_args__ = (
    Index("ix_users_tenant_email", "tenant_id", "email"),
)

Tenant_id first in composite indexes since RLS prepends the filter.

Bypass for admin paths

async def get_admin_db() -> AsyncSession:
    async with AdminSessionLocal() as session:
        # AdminSessionLocal uses an engine with BYPASSRLS role
        yield session

@app.get("/admin/all-users", dependencies=[Depends(require_role("admin"))])
async def admin_list(db = Depends(get_admin_db)):
    return (await db.execute(select(User))).scalars().all()

Test for accidental bypass

def test_app_role_cannot_bypass():
    with engine.connect() as conn:
        role = conn.execute(text("SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user")).scalar()
        assert not role, "App role has BYPASSRLS — RLS broken"

Common mistakes

  • App role with BYPASSRLS — RLS useless.
  • Missing WITH CHECK — inserts can specify other tenant_id.
  • RLS without indexes — slow seq scans.
  • SET app.tenant_id (no LOCAL) — persists across connection lifetime in pool.

Read this next

If you want my RLS + tenant-context + admin role pattern, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .