Enforcing tenant isolation with Postgres row-level security

Create a policy USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint), enable and force row-level security, and set the tenant per transaction with set_config('app.tenant_id', :tenant, true) from a session after_begin listener — PostgreSQL then filters every statement, including the ones someone forgot to filter. This guide belongs to dynamic schema and multi-tenant routing.

Quick Answer

Shared-table multi-tenancy usually starts with a tenant_id filter on every query, and eventually a query ships without one. Row-level security moves the rule into the database, where it cannot be forgotten.

Who enforces the tenant boundary? Left: every query must remember where tenant_id equals the current tenant; one forgotten filter in a report, a raw SQL query or a new endpoint returns every tenant rows. Right: a row-level security policy on the table compares tenant_id with a per-transaction setting; queries without a filter still see only the current tenant, and a missing setting returns no rows at all. filters in application code .where(Order.tenant_id == tid) on every query, forever one forgotten filter leaks data raw SQL and reports bypass it row-level security policy USING (tenant_id = current tenant) enforced on every statement unfiltered queries stay isolated no setting → no rows Keep the application filter as well: it lets the planner use indexes predictably. RLS is the backstop.

Before — isolation depends on every query remembering:

from sqlalchemy import select

from shop.models import Order


async def recent_orders(session, tenant_id: int):
    return (await session.scalars(
        select(Order).where(Order.tenant_id == tenant_id).order_by(Order.id.desc()).limit(50)
    )).all()


async def export_all_orders(session):
    # Written in a hurry for a support ticket. No tenant filter.
    return (await session.scalars(select(Order))).all()

After — a policy in the database, the tenant set per transaction:

-- migration, run as the table owner
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);
import contextvars

from sqlalchemy import event, text
from sqlalchemy.orm import Session

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


@event.listens_for(Session, "after_begin")
def _set_tenant(session, transaction, connection) -> None:
    tenant_id = current_tenant.get()
    if tenant_id is None:
        return   # no tenant: the policy matches no rows
    connection.execute(
        text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
        {"tenant_id": str(tenant_id)},
    )

Now export_all_orders() returns only the current tenant's orders, and a code path that runs without a tenant sees nothing at all.

Execution Context & Async Workflow Integration

A row-level security policy is a predicate PostgreSQL adds to every query against the table, for every role the policy applies to. USING filters rows that SELECT, UPDATE and DELETE can see; WITH CHECK rejects INSERT and UPDATE rows that would not satisfy it. The predicate here reads a custom configuration setting, app.tenant_id, which any session can set and which the policy reads with current_setting().

From request to policy, per transaction Five steps. Middleware resolves the tenant from the request and stores it in a ContextVar. The handler uses a session as usual. When the session begins a transaction, an after_begin listener reads the ContextVar and runs SELECT set_config app.tenant_id with is_local true on that connection. Every statement in the transaction is filtered by the policy using current_setting. At commit or rollback the local setting disappears, so the pooled connection carries nothing to the next request. middleware current_tenant.set(42) ContextVar, per request session begins a transaction after_begin listener fires on the checked-out connection set_config('app.tenant_id', '42', true) transaction-local setting bound parameter, not formatting queries run policy: current_setting('app.tenant_id') every statement filtered COMMIT / ROLLBACK setting discarded with the transaction is_local=true is the safety property: the value cannot outlive the transaction or leak through the pool.

The second argument to current_setting('app.tenant_id', true) is missing_ok. When the setting was never set on the connection, it returns NULL instead of raising. There is a subtlety with pooled connections: once any earlier transaction on that connection has set the parameter locally, PostgreSQL remembers that the parameter exists, and afterwards it returns an empty string rather than NULL. Casting '' to bigint raises, so the policy wraps the value in NULLIF(..., ''). With that, an unset tenant becomes NULL in both cases, tenant_id = NULL is never true, and the policy matches no rows. That is the fail-closed behaviour you want: forgetting to set the tenant produces empty results, not every tenant's data.

The setting must be scoped to a transaction, which is what the third argument of set_config(..., true) does — is_local. A transaction-local setting disappears at COMMIT or ROLLBACK. The alternative, a plain SET app.tenant_id = 42, lasts for the whole database session, and with a connection pool the database session outlives your request: the next request that checks out that connection inherits tenant 42. SET LOCAL is also transaction-scoped, but it cannot take a bound parameter, and interpolating a tenant identifier into SQL text is exactly the kind of mistake this design is meant to remove.

after_begin is the right hook because it fires once per transaction, on the connection the transaction is using, before any of the transaction's statements. It is registered on the synchronous Session class, which every AsyncSession wraps, so it applies to async code without change; inside the listener, connection.execute() is synchronous because it already runs inside SQLAlchemy's greenlet bridge. The tenant itself comes from a ContextVar set by middleware, which asyncio copies into every task the request creates — the same mechanism described for per-request state in switching schemas per request with schema_translate_map.

The pattern also works behind PgBouncer in transaction pooling mode, because transaction-local settings are exactly as long-lived as a transaction-pooled server connection assignment. Session-level SET does not, for the same reason it leaks through SQLAlchemy's pool — and the related prepared-statement issues are covered in handling asyncpg prepared statement errors with PgBouncer.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
Policies exist but every tenant's rows are visibleThe application connects as the table owner or a superuser.Connect as a separate role; add FORCE ROW LEVEL SECURITY.
new row violates row-level security policy for table "orders"An insert or update wrote a tenant_id different from the current setting.Set tenant_id from the same ContextVar, or default the column from the setting.
unrecognized configuration parameter "app.tenant_id"current_setting('app.tenant_id') without missing_ok when nothing was set.Use current_setting('app.tenant_id', true).
invalid input syntax for type bigint: ""Once a connection has set the parameter in any earlier transaction, current_setting(..., true) returns '' rather than NULL.Wrap it: NULLIF(current_setting('app.tenant_id', true), '')::bigint.
A request sees another tenant's data intermittentlySession-level SET leaked the tenant through a pooled connection.set_config(..., true), which is transaction-local.
Queries return nothing in background jobsJobs run without a tenant in the ContextVar.Set the tenant per job, or give cross-tenant jobs a separate role with explicit policies.
Isolation tests pass but production leaksThe test database user is a superuser, which bypasses RLS.Run tests as the application role.
Ways RLS is silently bypassed Four tiles. A superuser bypasses all policies; the application must not connect as one. The table owner bypasses policies unless FORCE ROW LEVEL SECURITY is set. A role with BYPASSRLS bypasses them. A session-level SET instead of a transaction-local setting leaks the tenant to the next user of a pooled connection. superuser bypasses every policy app role is never superuser table owner bypasses unless FORCE FORCE ROW LEVEL SECURITY BYPASSRLS role bypasses every policy reserve for migrations/admin SET app.tenant_id (session) survives in the pool set_config(..., true) A test suite that connects as a superuser passes every isolation test while enforcing nothing.

The bypass rows matter most, because nothing reports them. PostgreSQL applies policies to every role except superusers, roles with BYPASSRLS, and — unless FORCE ROW LEVEL SECURITY is set on the table — the table's owner. Many deployments run the application as the role that ran the migrations, which owns every table, so policies are created, enabled and silently ignored. Check which role the application actually uses:

from sqlalchemy import text


async def rls_status(session) -> list[dict]:
    rows = await session.execute(text(
        "SELECT c.relname AS table_name, c.relrowsecurity AS enabled, "
        "       c.relforcerowsecurity AS forced, "
        "       pg_get_userbyid(c.relowner) = current_user AS app_is_owner, "
        "       (SELECT rolbypassrls OR rolsuper FROM pg_roles WHERE rolname = current_user) "
        "         AS app_bypasses "
        "FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
        "WHERE n.nspname = 'public' AND c.relkind = 'r'"
    ))
    return [dict(row._mapping) for row in rows]

Any row with enabled but app_bypasses, or app_is_owner without forced, is a table whose policy does nothing for this application.

Advanced: Performance, Defaults and Cross-Tenant Work

A policy is a predicate like any other, and PostgreSQL's planner treats it that way: current_setting() is a stable function, so tenant_id = current_setting(...)::bigint can use an index on tenant_id. Make sure that index exists, and for tables usually queried by tenant and time, lead composite indexes with tenant_id. Keeping the explicit .where(Order.tenant_id == tenant_id) in application queries is still worthwhile: it documents intent, and it gives the planner a plain parameter to estimate with.

Turning RLS on without an outage Four stages. Create a dedicated application role that does not own the tables. Deploy the after_begin listener so every transaction sets the tenant, and verify with logging that it is always set. Create policies and enable row-level security with FORCE in a migration run by the owner role. Switch the application to the new role and monitor for queries returning unexpectedly empty results. 1 · create app_user, grant table privileges, owner stays separate policies do not apply to the owner; the application must not be it 2 · deploy the after_begin listener first, log when no tenant is set fix every code path that runs without a tenant before anything is enforced 3 · migration: CREATE POLICY ...; ENABLE and FORCE ROW LEVEL SECURITY run by the owner; background jobs needing all tenants get an explicit role 4 · switch the connection string to app_user watch for empty results — they are the symptom of a missing tenant

The WITH CHECK clause rejects writes for the wrong tenant, and a column default can remove the need to set tenant_id in Python at all:

ALTER TABLE orders
    ALTER COLUMN tenant_id SET DEFAULT NULLIF(current_setting('app.tenant_id', true), '')::bigint;

In the model, declare it as server-generated so the ORM does not try to supply a value and reads back what the database chose:

from sqlalchemy import BigInteger, FetchedValue
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    tenant_id: Mapped[int] = mapped_column(BigInteger, server_default=FetchedValue(), index=True)
    total_cents: Mapped[int]

Some work legitimately spans tenants: billing runs, analytics exports, support tooling. Two designs keep that explicit. A separate database role with its own policy — for example USING (true) for a reporting role — used by a separate engine makes cross-tenant access a deployment decision rather than a code path. Or a job iterates tenants and sets each one in turn, which keeps even batch work inside the isolation model:

from shop.db import Session
from shop.tenancy import current_tenant


async def run_for_every_tenant(tenant_ids: list[int], job) -> None:
    for tenant_id in tenant_ids:
        token = current_tenant.set(tenant_id)
        try:
            async with Session() as session:
                await job(session)
                await session.commit()
        finally:
            current_tenant.reset(token)

Each iteration opens a new session, so each gets a new transaction and a fresh after_begin with the right tenant. Reusing one session across tenants would keep the first tenant's setting for as long as its transaction stayed open.

Testing Tenant Isolation

Isolation is a security property, and it deserves tests that would fail if it broke — which means tests that run under the same conditions as production. Most RLS test suites that pass while enforcing nothing have one flaw: they connect as a superuser or the table owner.

Tests that would fail if isolation broke Three bands, all running as the application role rather than a superuser. An unfiltered select with tenant 1 set returns only tenant 1 rows. A select with no tenant set returns no rows. After a session with a tenant closes, a new connection from the same pool reads the setting back as empty. unfiltered SELECT with tenant 1 set → only tenant 1 rows the query deliberately omits the filter a developer might forget no tenant set → no rows at all fail closed: current_setting(..., true) is NULL, which matches nothing next connection from the pool → setting is empty proves set_config(..., true) did not leak across requests Run every one as the application role. A superuser makes all three pass while enforcing nothing.

Create the application role in the test database the same way as in production, run migrations as the owner, and give the application-level fixtures an engine that connects as the application role. Then write tests from an attacker's point of view:

import pytest
from sqlalchemy import select, text

from shop.models import Order
from shop.tenancy import current_tenant


@pytest.mark.asyncio
async def test_unfiltered_query_sees_only_current_tenant(app_session_factory, seed_orders):
    await seed_orders(tenant_id=1, count=3)
    await seed_orders(tenant_id=2, count=5)

    token = current_tenant.set(1)
    try:
        async with app_session_factory() as session:
            orders = (await session.scalars(select(Order))).all()   # no filter on purpose
    finally:
        current_tenant.reset(token)

    assert {order.tenant_id for order in orders} == {1}
    assert len(orders) == 3


@pytest.mark.asyncio
async def test_no_tenant_sees_nothing(app_session_factory, seed_orders):
    await seed_orders(tenant_id=1, count=3)
    async with app_session_factory() as session:
        assert (await session.scalars(select(Order))).all() == []


@pytest.mark.asyncio
async def test_setting_does_not_leak_through_the_pool(app_engine, app_session_factory, seed_orders):
    await seed_orders(tenant_id=1, count=3)
    token = current_tenant.set(1)
    try:
        async with app_session_factory() as session:
            await session.scalars(select(Order))
    finally:
        current_tenant.reset(token)
    async with app_engine.connect() as conn:          # likely the same pooled connection
        leaked = await conn.scalar(text("SELECT current_setting('app.tenant_id', true)"))
    assert leaked in (None, "")

seed_orders must insert as a role that can write any tenant's rows — the owner, or with the tenant set per insert — otherwise the fixture itself is blocked by the policy. Add one more test that asserts rls_status() from the previous section reports no bypasses for the application role, so a future change to how the test database is provisioned cannot quietly turn the whole suite into a no-op. The general fixture patterns are in running tests against a Postgres testcontainer.

Frequently Asked Questions

Does row-level security replace tenant filters in queries?

It replaces them as the security boundary, not as a query-writing habit. Keep explicit filters for readability and predictable plans, and rely on the policy to catch the query that forgets.

Why set_config instead of SET LOCAL?

Both are transaction-scoped, but SET LOCAL does not accept bound parameters, so the tenant would have to be formatted into SQL text. set_config(name, value, true) is a function call and takes parameters normally.

Does RLS work with asyncpg prepared statements?

Yes. The policy reads the setting at execution time, so a cached plan for SELECT ... FROM orders still filters by whichever tenant the current transaction set.

Is schema-per-tenant or RLS better?

RLS keeps one schema and one set of migrations, which scales to many small tenants. Schema-per-tenant gives stronger physical separation and per-tenant maintenance at the cost of running every migration per schema. Many systems use RLS for most tenants and dedicated databases for a few large ones.