What row-level security does in a shared-schema monolith, and when to choose it
In a shared-schema design, every tenant's rows live in the same tables, and each row carries a tenant_id column. The application is expected to add WHERE tenant_id = ... to every query. That works until one query skips the filter. It might be a hand-written report, an ORM call that bypasses the default scope, or a background job with no request context. A single omission like that exposes another tenant's data.
Row-level security (RLS) moves the filter into PostgreSQL. You attach policies to a table. The planner then adds the policy predicate to every query against that table, whatever SQL the application sends. Application scoping stays the first line of defence. RLS is the backstop that catches its mistakes.
| Model | Isolation strength | Operational cost | Migration complexity | Noisy-neighbor exposure | Per-tenant backup/restore, residency |
|---|---|---|---|---|---|
| Shared schema + RLS | Logical, enforced by policy | Lowest: one schema, one pool | Run once | High | Hard: rows must be extracted |
| Schema-per-tenant | Namespace-level | Moderate: catalog grows with tenants | Run once per schema | High: same instance | Possible per schema, same server |
| Database-per-tenant | Strongest | Highest: many databases and connections | Run once per database | Low if databases are on separate hosts | Native per database |
Choose shared schema with RLS if you run one monolith for many small or mid-sized tenants and want a single migration path and one connection pool.
Avoid it if any of these apply:
- Tenants need guaranteed CPU or I/O isolation.
- Contracts require data to stay in a specific region.
- Customers expect a restore of their own data alone to a point in time.
Policies cannot provide any of these.
How PostgreSQL enforces policies internally
A policy is a named catalog entry attached to one table. CREATE POLICY stores it in the pg_policy system catalog, and you can inspect it through the pg_policies view. Each policy records the command it applies to (SELECT, INSERT, UPDATE, DELETE or ALL), the roles it targets through its TO clause (default PUBLIC), and up to two expressions: USING and WITH CHECK.
When a query references a table that has RLS enabled, PostgreSQL looks up the policies that match the current role and command and rewrites the query before it is executed:
- Reads. The
USINGexpressions are attached to that table reference as security-barrier qualifications. This happens wherever the table appears: the top-levelFROM, a join, a subquery, a CTE, or the inside of a view. The application cannot write SQL that reaches the table without the predicate. - Writes. For
INSERTand the new row version produced byUPDATE, theWITH CHECKexpressions are evaluated against each row. If a check fails, the statement raises an error instead of silently skipping the row. - Combining policies. Permissive policies (the default) are combined with
OR. Policies declaredAS RESTRICTIVEare combined withAND, and are applied on top of the permissive result. If no permissive policy matches the role and command, no rows are visible or writable. That is the source of the default-deny behavior.
Because the policy expressions are evaluated at execution time, current_setting('app.tenant_id', true) is read for each query, not baked into a cached plan. Custom settings whose names contain a dot, such as app.tenant_id, do not need to be declared in advance. PostgreSQL accepts them as placeholder parameters, which is why this pattern needs no server configuration.
The row_security setting controls what happens for roles that would otherwise be filtered. With the default on, policies apply. With off, a query that would be affected by a policy raises an error instead of returning filtered results. Tools that must see all rows, such as backup jobs, use this so they fail loudly rather than produce an incomplete dump without warning.
Security-barrier qualifications have one planner consequence, covered in the performance section: a user-supplied condition that uses a function not marked LEAKPROOF cannot be evaluated before the policy predicate. Otherwise that function could observe rows the policy hides, for example by raising an error that includes their values.
How tenant context flows from request to policy
A request reaches the database through one fixed sequence, and each step has a specific job.
- Resolve the tenant from authenticated identity. Middleware derives the tenant from a verified source, such as a token claim or a session record, and never from an unchecked client header. If no tenant can be resolved, the request fails before it touches a connection.
- Borrow a pooled connection and open a transaction. The pool authenticates as a dedicated application role. That role does not own the tables and does not have
BYPASSRLS, so policies always apply to it. - Set
app.tenant_idtransaction-locally. UseSET LOCALorset_config('app.tenant_id', ..., true). Because the value is transaction-scoped, it cannot leak to the next request that borrows the same connection. - Run queries with an explicit filter. Application queries still include
WHERE tenant_id = $1. RLS appends its own predicate based oncurrent_setting('app.tenant_id', true). The explicit filter keeps each query correct and readable on its own. The policy catches the query someone forgets to filter. - Commit or roll back. Either outcome discards the setting, and the connection returns to the pool with no tenant context.
Two roles sit outside this path:
- Migration role: owns the tables and runs DDL from the deployment pipeline over its own connections, never through the request pool.
- Admin role: handles cross-tenant support and reporting. It uses separate credentials and is either granted
BYPASSRLSor matched by explicit policies. Its sessions are audited independently.
Keeping both roles out of the application's pool means a compromised request handler cannot reach either privilege.
Request path (app pool) Outside the request pool
+--------------------------------------+
| 1 Middleware: tenant from verified |--> none: reject, no connection
| token claim or session, not header |
+--------------------------------------+
|
v
+--------------------------------------+ +------------------------------+
| 2 Pooled conn, BEGIN as app_role | | Migration role |
| (not owner, no BYPASSRLS) | | owns tables, runs DDL from |
| 3 SET LOCAL app.tenant_id = <t> | | deploy pipeline, own conns |
| 4 SELECT ... WHERE tenant_id = $1 | +------------------------------+
| 5 COMMIT / ROLLBACK: setting dropped | |
+--------------------------------------+ |
| |
v v
+------------------------------------------------------------------------+
| PostgreSQL tables (RLS enabled) |
| policy for app_role: tenant_id = current_setting('app.tenant_id',true) |
+------------------------------------------------------------------------+
^
|
+------------------------------+
| Admin role (separate creds) |
| BYPASSRLS or explicit |
| policies; sessions audited |
+------------------------------+
Each request resolves its tenant, then runs one transaction on a pooled connection as a non-owner app role with a transaction-local app.tenant_id that commit or rollback discards, while the migration and admin roles reach the database over their own connections outside the request pool.
Enabling and forcing RLS with the right roles
Row-level security is off by default on every table. ALTER TABLE invoices ENABLE ROW LEVEL SECURITY turns it on, and from that point a table with no policies denies all rows to ordinary roles. That default-deny behavior is useful: a tenant-scoped table that someone forgot to write a policy for returns nothing rather than everything.
Enabling RLS does not apply to every role. The table owner bypasses policies unless you also run ALTER TABLE invoices FORCE ROW LEVEL SECURITY. Superusers and any role with the BYPASSRLS attribute skip policies even when RLS is forced. A common failure is an application that connects as the role that created the tables. Every policy then appears to work in review and does nothing in production. FORCE protects against the owner case, but you should not rely on it alone.
Separate ownership from access
Use two roles:
- Migration role. Owns the schema and tables, runs DDL, and is only used by the deploy pipeline.
- Application role. Has
LOGIN,NOSUPERUSERandNOBYPASSRLS, and owns nothing. Grant itUSAGEon the schema,SELECT,INSERT,UPDATEandDELETEon tenant tables, andUSAGEon the sequences it needs.
Do not grant the application role TRUNCATE. Row-level security policies do not apply to it, so a single statement would remove every tenant's rows.
ALTER DEFAULT PRIVILEGES, run as the migration role, gives new tables the correct grants automatically. It does not enable RLS on them. Your migration checklist, or a CI query against pg_class.relrowsecurity, must cover that step.
-- Run once as a superuser or a role with CREATEROLE.
-- Passwords/auth are configured separately (pg_hba.conf, secret store).
CREATE ROLE app_migrator LOGIN NOSUPERUSER NOBYPASSRLS;
CREATE ROLE app_user LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
-- The migration role owns the schema, so it owns every table it creates.
CREATE SCHEMA app AUTHORIZATION app_migrator;
GRANT USAGE ON SCHEMA app TO app_user; -- USAGE only, no CREATE
-- Run the rest as app_migrator. Future tables and sequences get grants
-- automatically. TRUNCATE is deliberately absent: policies do not apply to it.
ALTER DEFAULT PRIVILEGES FOR ROLE app_migrator IN SCHEMA app
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_migrator IN SCHEMA app
GRANT USAGE ON SEQUENCES TO app_user;
-- Default privileges do not turn on RLS. Every tenant table needs both lines.
CREATE TABLE app.invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
amount numeric NOT NULL
);
ALTER TABLE app.invoices ENABLE ROW LEVEL SECURITY; -- no policies yet: deny all
ALTER TABLE app.invoices FORCE ROW LEVEL SECURITY; -- owner is filtered too
-- CI check: any table in the schema without RLS enabled and forced fails.
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'app'
AND c.relkind IN ('r', 'p')
AND NOT (c.relrowsecurity AND c.relforcerowsecurity);
Creates a migration role that owns the schema and an application role with no ownership and no BYPASSRLS, sets default grants without TRUNCATE, enables and forces RLS on a tenant table, and adds a catalog query CI can use to catch tables where RLS was not turned on.
Writing fail-closed policies with USING and WITH CHECK
Every policy should read the tenant with current_setting('app.tenant_id', true). The second argument returns NULL instead of raising an error when the setting was never defined. A setting that was defined earlier in the session and later reset can come back as an empty string, and casting '' to uuid raises an error. Wrap the value as NULLIF(current_setting('app.tenant_id', true), '')::uuid. A missing or blank tenant then becomes NULL, and tenant_id = NULL is never true. The query returns zero rows instead of failing open or crashing.
USING filters which existing rows a statement can see. It applies to SELECT, to the rows targeted by UPDATE, and to DELETE. WITH CHECK validates the rows a statement writes, which covers INSERT and the new version of a row in UPDATE. For FOR ALL and FOR UPDATE policies, PostgreSQL reuses USING as the check if you omit WITH CHECK. Write it out explicitly anyway, because the gaps appear when policies are split per command. Permissive policies are OR'd together, so a convenience FOR INSERT ... WITH CHECK (true) lets tenant A insert rows with tenant_id set to tenant B. Those rows land in B's data, and A cannot even see them afterward.
Constraint checks ignore RLS. A unique index on email alone produces a duplicate-key error whose detail reveals that the address exists in another tenant. Scope it as (tenant_id, email). Foreign keys have the same problem. A plain customer_id reference succeeds even when it points at another tenant's customer. Add a unique constraint on (tenant_id, id) in the parent table and reference it with a composite key (tenant_id, customer_id), so a cross-tenant link fails the constraint.
CREATE TABLE customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
email text NOT NULL,
UNIQUE (tenant_id, id), -- target for composite foreign keys
UNIQUE (tenant_id, email) -- duplicate check never crosses tenants
);
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
customer_id uuid NOT NULL,
-- A cross-tenant link fails here, because RLS does not filter FK checks
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)
);
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers FORCE ROW LEVEL SECURITY; -- apply to the table owner too
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- Unset or '' becomes NULL, and tenant_id = NULL matches nothing (fail closed).
-- USING filters rows read by SELECT and targeted by UPDATE/DELETE.
-- WITH CHECK rejects INSERTed or UPDATEd rows tagged with another tenant.
CREATE POLICY tenant_isolation ON customers FOR ALL
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON orders FOR ALL
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
-- Do NOT add a permissive policy like this. Permissive policies are OR'd,
-- so it would let any tenant insert rows tagged with another tenant's id:
-- CREATE POLICY allow_insert ON orders FOR INSERT WITH CHECK (true);
Fail-closed tenant policies with explicit USING and WITH CHECK clauses, plus a tenant-scoped unique constraint and a composite foreign key that stop constraint checks from leaking or linking data across tenants.
Setting tenant context safely under connection pooling
Tenant context must live exactly as long as the transaction that uses it. set_config('app.tenant_id', $1, true) gives you that: the third argument makes the setting transaction-local, so it reverts at COMMIT or ROLLBACK. SET LOCAL app.tenant_id = ... has the same scope, but SET does not accept bind parameters, so you end up interpolating the value into SQL. Prefer set_config with a parameter.
Avoid session-level SET. It persists on the physical connection after your request finishes. A pool hands that connection to the next request, which then runs with the previous tenant's ID. Transaction-mode poolers make this worse because consecutive transactions from one client can land on different server connections, and each connection carries whatever session state the last user left behind.
Wrap every unit of work
Use one middleware or unit-of-work wrapper that opens the transaction, calls set_config first, runs the handler, and then commits or rolls back. Application code should never reach the database outside it.
Some paths commonly bypass this wrapper:
- Async jobs run without the request middleware. Each job needs to carry the tenant ID explicitly and open its own wrapped transaction.
- Streaming responses can keep reading rows after the handler's transaction has closed.
- ORM lazy loading after commit runs a new query with no tenant context.
- Startup or health-check queries may run before any context is set.
- Prepared statements under transaction pooling may run on a backend where your session never prepared them, and they can execute in a transaction that never set context.
Write an integration test against your actual framework, driver, and pooler to confirm each path behaves as expected. Do not assume it works.
from contextlib import contextmanager
from psycopg_pool import ConnectionPool
pool = ConnectionPool(
"postgresql://app_user@pgbouncer.internal:6432/appdb",
kwargs={"prepare_threshold": None},
)
@contextmanager
def tenant_tx(tenant_id: str):
"""The only path to the database. Tenant context lives for one transaction."""
with pool.connection() as conn: # commits on success, rolls back on exception
# Run this first so no query executes without context. The third argument
# (is_local = true) reverts the setting at COMMIT/ROLLBACK, so nothing
# persists on the pooled connection.
# Avoid session-level SET app.tenant_id: it survives into the next borrower.
conn.execute("SELECT set_config('app.tenant_id', %s, true)", (str(tenant_id),))
yield conn
def list_invoices(tenant_id: str):
with tenant_tx(tenant_id) as conn:
# Materialize rows inside the transaction. A cursor or generator that
# outlives the block (for example, a streaming response) reads without context.
return conn.execute("SELECT id, total FROM invoices").fetchall()
def send_reminders_job(payload: dict):
# Background jobs skip request middleware. Carry the tenant ID in the payload
# and open a wrapped transaction explicitly.
with tenant_tx(payload["tenant_id"]) as conn:
conn.execute("UPDATE invoices SET reminded_at = now() WHERE due < now()")
A psycopg 3 unit-of-work wrapper that sets transaction-local tenant context with set_config(..., true) before any query, disables server-side prepared statements for a transaction-mode pooler, and requires background jobs to pass the tenant ID explicitly.
Performance, indexing, and admin access
Keep explicit WHERE tenant_id = $1 filters in application queries and treat RLS as the backstop. If RLS alone scopes a query, a missing filter goes unnoticed in development. Explicit filters also give the planner a plain equality on an indexed column instead of a predicate it only sees after policy expansion.
Lead composite indexes with tenant_id, such as (tenant_id, created_at) or (tenant_id, status). Run EXPLAIN on representative queries as the application role with tenant context set. Confirm that the policy predicate appears in the plan and that index scans are still used.
Watch three costs:
- Per-row evaluation. The policy predicate is checked on every candidate row, so expensive expressions add up on large scans.
- Membership subqueries. A policy like
EXISTS (SELECT … FROM memberships …)runs a lookup per row unless the planner can turn it into a join. Index the membership table on the columns the subquery filters by. - Non-leakproof functions. The planner evaluates policy quals before user-supplied conditions that use non-leakproof functions or operators. This ordering can block index use and push-down for those conditions.
| Workload | Recommended access | Why |
|---|---|---|
| Migrations | Dedicated role with BYPASSRLS |
DDL and backfills span all tenants |
| Background jobs | Normal role, context set per job | Keeps jobs tenant-scoped |
| Analytics | Read-only role, narrow policy or BYPASSRLS |
Cross-tenant aggregates |
| Support tooling | Role with audited, scoped policy | Read access to a specific tenant |
Treat bypass-capable roles as high-value credentials. Store them separately from application secrets, and grant them only to the processes that need them. Log every session that uses them so cross-tenant access stays traceable.
Alternatives to row-level security
RLS is one way to enforce tenant isolation in a shared schema, not the only one. The main alternatives, and where each fits:
- Application-level scoping only. ORM default scopes, a repository layer that always injects
tenant_id, or a query builder that refuses to run without a tenant. This needs no database features and is easy to reason about in code. The weakness is the one this article starts with: any path that bypasses the layer, such as raw SQL, a reporting script, or a new ORM call, bypasses isolation too. Many teams run this and RLS together, which is the setup described here. - Tenant-filtered views. Grant the application role access only to views that filter on
current_setting('app.tenant_id', true), and none to the base tables. This works, but views run with the owner's privileges by default, every table needs a matching view, and writes through views add their own rules. Mark such viewssecurity_barrierso user-supplied conditions cannot be evaluated before the filter. RLS gives the same guarantee attached directly to the table, without a parallel set of objects to maintain. - Schema-per-tenant. Each tenant gets its own schema with identical tables, and the application selects one with
search_pathor schema-qualified names. Isolation comes from namespaces and grants rather than predicates, and per-tenant dumps become simpler. The costs are migrations that run once per schema, a catalog that grows with every tenant, and the samesearch_pathleakage risk under pooling that session-levelSEThas. - Database-per-tenant. Each tenant gets its own database, possibly on its own host. This is the only option in the comparison table that addresses noisy neighbors, data residency and single-tenant point-in-time restore. You pay with connection management, many migration runs, and cross-tenant reporting that has to aggregate across databases.
- Hybrid placement. Keep most tenants in a shared schema with RLS and move tenants with residency, performance or restore requirements to dedicated databases. Your code then has to route each tenant to the right connection target, but the shared path stays simple.
If your constraints are isolation of data visibility, a single codebase and one migration path, RLS combined with application scoping is usually the lowest-cost option. If your constraints are about resources, location or backups, policies cannot meet them, and one of the physical separation models is the right choice.
Testing isolation and checks before rollout
Run isolation tests as the application role, never as a superuser or table owner, because those bypass policies and every assertion would pass trivially. Seed two tenants, A and B, then set context to A and assert the following:
- Selecting B's rows by primary key returns nothing.
- Updates and deletes targeting B's rows report zero affected rows.
- An insert tagged with B's tenant ID raises a policy violation.
Then open a session with no tenant context and assert that it sees zero rows. A test that only checks "A cannot see B" misses a policy that has silently become true or a context variable that falls back to a permissive default.
Add a migration-time check that joins pg_class (relrowsecurity, relforcerowsecurity) with pg_policies and fails the build if any table with a tenant_id column lacks RLS, lacks FORCE, or has no policy. This catches the new table someone adds without policies.
Review three objects by hand:
- Views execute with the owner's privileges by default. A view owned by a bypass-capable role exposes every tenant's rows.
- SECURITY DEFINER functions have the same exposure.
- Foreign keys are checked without RLS. Use composite keys that include
tenant_idso a row cannot reference another tenant's parent.
On cost, the roles, the context wrapper, and this test suite take real upfront time. Illustrative estimate, assumed inputs: roughly two engineer-weeks to build, set against a review checklist that no longer has to verify a tenant filter on every query. The bigger saving is where isolation bugs surface: in CI rather than in an incident.
Where RLS saves engineering time
The time saved comes from moving a repeated, manual check into one enforced mechanism. Without RLS, tenant isolation is a property every query author and every reviewer has to re-establish. With RLS, it is established once per table and verified by automated checks.
The concrete savings fall into four areas:
- Code review. Reviewers stop treating every query, report and job as a potential cross-tenant leak. They still expect the explicit
WHERE tenant_id = $1, but a missed filter becomes a correctness or performance comment, not a security incident to escalate. - New code paths. Background jobs, admin scripts and ad hoc reports run through the same wrapper and inherit isolation. Nobody has to design a tenant-scoping approach for each new entry point.
- Incident response. A leak caused by a missing filter needs log analysis to work out which tenants saw which rows, customer notification and a postmortem. With RLS, the same bug returns zero rows or raises a policy error, which shows up as an ordinary defect.
- Audits and security questionnaires. You can show isolation with the policy definitions, the catalog check in CI and the isolation test suite, instead of arguing that every query in the codebase is correctly filtered.
Illustrative estimate, assumed inputs: suppose a team merges 40 changes a month that touch tenant-scoped queries, and reviewers spend an assumed 10 extra minutes per change confirming tenant filtering. That is about 400 minutes, or roughly 6.5 hours, of review attention a month. If RLS cuts that check to a quick look for the explicit filter, most of that time comes back. A single avoided cross-tenant incident, with assumed costs of several engineer-days for investigation and remediation, would be worth more than months of that review time. Substitute your own change volume and review times. The calculation matters more than these assumed inputs.
The savings depend on the pieces described above being in place. If the application connects as the table owner, if context is set at session level, or if the CI catalog check is missing, RLS gives a false sense of safety and the review burden does not go away. It just stops being done.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to postgresql row level security multi-tenant, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.