Security Model
Flask RLS is a security tool, so this page is explicit about three things: what the library guarantees, how each guarantee is verified, and where its protection ends.
What is guaranteed
Isolation is enforced by PostgreSQL, not by Python
Once a policy is applied, the predicate
tenant_id = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::text) is evaluated
by PostgreSQL on every access to the table — ORM queries, raw SQL, prepared statements,
queries written years later. There is no code path in your application that can "forget" the
filter, because the filter is not in your application.
Missing context fails closed
If no tenant context is set — no request, a forgotten g.tenant_id, a background job
without an override() — the GUC is unset, current_setting(..., true) returns NULL, and
the policy predicate is false for every row. The failure mode of Flask RLS is an empty
result set, never a cross-tenant leak. bypass() behaves the same way on protected
tables: it emits no context, so it sees zero rows.
To surface missing context during development instead of silently returning nothing, set
RLS_REQUIRE_CONTEXT = True and a missing context raises RLSContextRequiredError at
transaction begin.
Context cannot leak across pooled connections
Context is set with set_config(name, value, is_local := true), which is
transaction-scoped: PostgreSQL discards it at COMMIT/ROLLBACK. A connection returned
to the SQLAlchemy pool carries no tenant residue, even when a request dies mid-transaction —
there is no reset step to skip because the database performs it unconditionally.
override()/bypass() scopes are built on contextvars, so they are correct under
threads, greenlets, and asyncio.
Identifiers are validated; values are bound
Every identifier that reaches generated DDL — table, column, role, GUC name, cast — is
validated against a strict pattern first, and a malformed value raises PolicyError.
Context values never appear in SQL text at all: set_config() is called with bind
parameters. ExpressionPolicy predicates are compiled by SQLAlchemy's own PostgreSQL
dialect rather than by string assembly.
The owner-bypass gotcha is handled — with your cooperation
A table's owner bypasses RLS unless the table is marked FORCE ROW LEVEL SECURITY; Flask
RLS emits FORCE for every registered table. But superusers bypass RLS unconditionally,
and FORCE only helps if your application doesn't connect as a superuser. The supported
setup is two roles: an owner/migrator role for DDL and a plain app_user for the
application (setup).
How the guarantees are verified
The test suite runs against real PostgreSQL (via Testcontainers) as a non-owner role and asserts, for the policies it applies:
- tenant A cannot read tenant B's rows (isolation);
- with no context, queries return zero rows (fail-closed);
- after a committed
override(), the next transaction on the same pooled connection has no context (pool safety /is_localsemantics); - inserting a row for another tenant is rejected (
WITH CHECK); - a non-superuser table owner is still subject to policies on a
FORCEd table.
You should assert the same five things for your policies — the testing guide shows the fixtures.
What RLS does not protect against
Be honest about the boundary:
- Superusers and
BYPASSRLSroles. RLS is not a defense against your own privileged credentials. Guard those roles operationally; use a dedicatedBYPASSRLSengine only for explicit cross-tenant work. - A compromised application process. An attacker who can execute arbitrary code in your app can set any tenant context the app could set. RLS narrows blast radius per request; it is not a sandbox.
- Tables you didn't register. RLS is opt-in per table. New multi-tenant tables need a policy migration — make it part of your table-creation checklist.
- Non-PostgreSQL databases. RLS is a PostgreSQL feature; there is no SQLite fallback, which is why tests need real PostgreSQL.
- Timing/row-count side channels. As with any shared-table design, aggregate behavior (e.g., sequence gaps) can hint at other tenants' existence. If that matters for your threat model, see the architecture comparison.
Reporting a vulnerability
If you believe you've found a security issue in Flask RLS, please report it privately via GitHub Security Advisories rather than a public issue. Reports are acknowledged within 72 hours.