Skip to main content

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_local semantics);
  • 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 BYPASSRLS roles. RLS is not a defense against your own privileged credentials. Guard those roles operationally; use a dedicated BYPASSRLS engine 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.