Complete Guide: Multi-Tenant Flask with PostgreSQL RLS
This is the full walkthrough — from an empty project to a Flask application whose tenant isolation is enforced by PostgreSQL and proven by tests. The Quick Start compresses this into four steps; this guide explains each decision along the way.
What you'll build: a minimal invoicing API where every tenant sees only its own
invoices, enforced by Row-Level Security — meaning even a raw
SELECT * FROM invoices returns only the current tenant's rows.
1. Project setup
mkdir rls-demo && cd rls-demo
python -m venv .venv && source .venv/bin/activate
pip install flask flask-sqlalchemy "flask-rls[alembic]" psycopg alembic
You need a running PostgreSQL 12+ (any recent version; the library is tested against 12 through 17). For local work:
docker run -d --name rls-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:16-alpine
2. Database roles: the step everyone skips
RLS has one rule that surprises people: a table's owner bypasses its policies unless the
table is marked FORCE ROW LEVEL SECURITY — and superusers bypass RLS always. If your app
connects as the same role that ran CREATE TABLE, your policies can silently do nothing.
So use two roles: one that owns the schema (runs migrations), one that the application connects as:
-- as postgres/superuser:
CREATE ROLE migrator LOGIN PASSWORD 'migrate-secret';
CREATE ROLE app_user LOGIN PASSWORD 'app-secret';
CREATE DATABASE invoicing OWNER migrator;
\c invoicing
GRANT USAGE ON SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
Flask RLS emits FORCE ROW LEVEL SECURITY for every registered table as a second line of
defense, but the dedicated non-owner app_user is what makes the security model clean. The
security model page covers this in depth.
3. The application
# app.py
from flask import Flask, g, jsonify, request
from flask_sqlalchemy import SQLAlchemy
from flask_rls import RLS
db = SQLAlchemy()
rls = RLS()
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = (
"postgresql+psycopg://app_user:app-secret@localhost/invoicing"
)
db.init_app(app)
with app.app_context():
rls.init_app(app, engine=db.engine)
@app.before_request
def set_rls_context():
# Resolve the tenant however your app does: subdomain, JWT claim,
# API-key lookup. For the demo, a header.
g.tenant_id = request.headers.get("X-Tenant-ID")
@app.get("/invoices")
def list_invoices():
invoices = Invoice.query.all() # no tenant filter — RLS supplies it
return jsonify([{"id": i.id, "amount": i.amount} for i in invoices])
return app
class Invoice(db.Model):
__tablename__ = "invoices"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Text, nullable=False, index=True)
amount = db.Column(db.Numeric, nullable=False)
Two things to notice:
- The route handler has no tenant filter. That's the point — after this guide, the database adds it for you.
tenant_idis indexed. The RLS predicate istenant_id = <current tenant>on every query, so this index is what keeps policies cheap.
On each transaction begin, Flask RLS reads g.tenant_id and runs
SELECT set_config('rls.tenant_id', :value, true). The true makes it transaction-scoped —
PostgreSQL discards it on COMMIT/ROLLBACK, which is why pooled connections can't leak
context between requests (details).
4. Policies as a migration
Initialize Alembic and register the RLS operations in migrations/env.py:
# migrations/env.py — add near the top
from flask_rls.alembic import * # noqa: F401,F403 registers op.enable_rls, ...
Write the migration (run migrations as migrator, the owner role):
# migrations/versions/xxxx_enable_rls_on_invoices.py
from alembic import op
from flask_rls import TenantPolicy
def upgrade():
op.enable_rls("invoices")
op.force_rls("invoices")
op.create_policy("invoices", TenantPolicy("tenant_isolation", "tenant_id"))
def downgrade():
op.drop_policy(
"invoices", "tenant_isolation",
policy=TenantPolicy("tenant_isolation", "tenant_id"),
)
op.no_force_rls("invoices")
op.disable_rls("invoices")
Prefer to see the SQL before trusting a library with your security? Register the policy on the extension and dump it:
flask rls sql --table invoices
ALTER TABLE "invoices" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "invoices" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "invoices" AS PERMISSIVE FOR ALL TO PUBLIC
USING (tenant_id = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::text))
WITH CHECK (tenant_id = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::text));
The predicate wraps current_setting in a scalar subquery so PostgreSQL evaluates it once
per statement instead of once per row — the
InitPlan pattern.
5. See it work
Seed two tenants and query as each:
curl -s -X GET localhost:5000/invoices -H "X-Tenant-ID: acme" # only acme rows
curl -s -X GET localhost:5000/invoices -H "X-Tenant-ID: globex" # only globex rows
curl -s -X GET localhost:5000/invoices # [] — fail closed
The third call is the important one: no tenant header, zero rows. Missing context in
Flask RLS fails closed — the GUC is NULL, the predicate matches nothing. During
development you can make this loud instead of silent:
app.config["RLS_REQUIRE_CONTEXT"] = True # missing context raises RLSContextRequiredError
6. Prove it with tests
Trust nothing you haven't asserted. The essential suite (full guidance):
from sqlalchemy import text
def test_isolation(engine, rls):
with rls.override(tenant_id="acme"):
with engine.connect() as conn:
rows = conn.execute(text("SELECT tenant_id FROM invoices")).all()
assert {r.tenant_id for r in rows} == {"acme"}
def test_fail_closed(engine, rls):
with rls.bypass(): # emits no context
with engine.connect() as conn:
rows = conn.execute(text("SELECT * FROM invoices")).all()
assert rows == []
def test_with_check_blocks_cross_tenant_writes(engine, rls):
with rls.override(tenant_id="acme"):
with engine.begin() as conn, pytest.raises(Exception):
conn.execute(text(
"INSERT INTO invoices (tenant_id, amount) VALUES ('globex', 10)"
))
Note the raw SQL: these tests deliberately bypass the ORM to prove the database enforces
isolation. Run them as app_user, not the owner — the Flask RLS test suite itself does this
against real PostgreSQL via Testcontainers.
7. Background jobs, CLI commands, admin
Outside a request there's no g, so context fails closed. Be explicit:
with rls.override(tenant_id="acme"): # act as one tenant
generate_monthly_statement()
with rls.bypass(): # no context — non-RLS tables only
create_tenant_record()
bypass() is not god-mode: on RLS-protected tables it sees zero rows. True cross-tenant
access (admin dashboards, analytics) should go through a separate engine bound to a
PostgreSQL role with BYPASSRLS — a deliberate, auditable choice rather than a default.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Policies exist but every row is visible | App connects as table owner without FORCE | Connect as non-owner role; Flask RLS emits FORCE — verify with \d invoices |
| Every query returns zero rows | Context never set | Set g.tenant_id in before_request; use RLS_REQUIRE_CONTEXT to make it loud |
| Slow queries after enabling RLS | Missing index on tenant_id | Index every policy column |
| Works in tests, empty in production | Tests ran as superuser | Run tests as the app's non-owner role |
Where to next
- Choosing a multi-tenant architecture — RLS vs schema-per-tenant vs database-per-tenant
- Migrating an existing app — adopting RLS incrementally with a rollback plan
- Security model — the exact guarantees and their limits
- API Reference