Context
RLS policies read the current identity from PostgreSQL session variables (GUCs). Flask RLS sets those variables per transaction so your policies can filter rows.
How does Flask RLS set the tenant context per transaction?
On each transaction begin, Flask RLS resolves the active context and, for every key, runs:
SELECT set_config('rls.tenant_id', :value, true)
The third argument (is_local) is true, so the setting is transaction-scoped:
PostgreSQL discards it automatically on COMMIT/ROLLBACK. It therefore cannot leak
across pooled connections — there is nothing to reset. This is what makes the mechanism
safe under SQLAlchemy connection pooling: a pooled connection returned by one request
carries no residue into the next, because the database itself already cleared the setting
when the transaction ended. There is no cleanup handler to forget, and no failure mode where
an exception skips the reset.
SET LOCAL?SET LOCAL rls.tenant_id = :value is invalid — SET cannot take a bind parameter.
set_config() is an ordinary function call, so both the GUC name and value are bound,
eliminating string interpolation.
Where does the context come from?
Flask RLS resolves context from providers — callables returning {key: value}. The
built-in provider reads Flask g:
@app.before_request
def set_rls_context():
g.tenant_id = current_tenant_id()
g.user_id = current_user_id()
Register additional providers for custom keys:
from flask import request, has_request_context
@rls.context_provider
def request_ip():
if has_request_context():
return {"ip": request.remote_addr}
return {}
A key named ip becomes the GUC rls.ip, which a policy can read with
current_setting('rls.ip', true).
What happens if no context is set?
Queries return zero rows. When nothing resolves — no request, no provider value, no
override() — the GUCs stay unset, current_setting('rls.tenant_id', true) returns NULL,
and every policy predicate evaluates false. Flask RLS fails closed by design: a forgotten
g.tenant_id produces an empty result, never a cross-tenant leak. Set
RLS_REQUIRE_CONTEXT = True to turn that silence into a loud RLSContextRequiredError.
The full resolution order, from highest precedence to lowest:
bypass()active → emit nothing.override()values → win over providers for the keys given.- Registered providers → merged in registration order.
- Nothing resolvable → GUCs unset → fail closed (zero rows).
Scopes for non-request code
Outside a request there is no g. Use the scope managers:
# privileged identity switch — jobs, CLI commands, tests
with rls.override(tenant_id="acme", user_id=42):
generate_report()
# emit no context (fail-closed on RLS tables) — for work on non-RLS tables
with rls.bypass():
create_new_tenant()
Both are implemented with contextvars, so they are correct under threads, greenlets, and
asyncio without leaking across requests.
bypass() is not god-modeOn an RLS-protected table, bypass() emits no context, so it still sees zero rows.
True cross-tenant access requires a PostgreSQL role with the BYPASSRLS attribute — run
such work through a separate engine bound to that role.