PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 33: Security

Row-level security for multi-tenant data

Level 6: Design and Security25 min

Row-level security (RLS) adds a WHERE clause the database enforces on every query against a table. It is the standard way to keep tenants' data apart in a shared schema, and to restrict which rows each role may see or change.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Enable RLS and write USING and WITH CHECK policies
  • Pass the tenant through a session setting and test policies with SET ROLE
  • Know the bypasses: owners, superusers, BYPASSRLS and FORCE ROW LEVEL SECURITY

conceptThe concept

ALTER TABLE t ENABLE ROW LEVEL SECURITY turns on default-deny: without a matching policy, a non-owner sees no rows. CREATE POLICY ... USING (expr) filters rows that can be read, updated or deleted; WITH CHECK (expr) validates rows being inserted or updated. Policies can target specific commands and roles, and several permissive policies are OR-ed together (restrictive ones are AND-ed).

A common pattern stores the tenant in a custom setting: the application runs SET app.tenant_id = '42' (or set_config) after taking a connection, and the policy uses current_setting('app.tenant_id'). With a transaction-mode pooler, use SET LOCAL inside each transaction.

Table owners and superusers bypass RLS, as do roles with BYPASSRLS. ALTER TABLE ... FORCE ROW LEVEL SECURITY applies policies to the owner too.

whyWhy it matters

One missing WHERE tenant_id = ? in application code leaks another customer's data. RLS makes the database refuse to return it, whatever the application forgets.

Use caseReal-world scenario

A B2B version of the shop hosts many merchants in the same tables. Each request sets the merchant id, and policies ensure a merchant can only see and change its own orders.

Try itExamples

Run and edit these in the interactive lesson.

Tenant isolation with a session setting
BEGIN;
CREATE ROLE merchant_app NOLOGIN;
GRANT USAGE ON SCHEMA public TO merchant_app;
GRANT SELECT, UPDATE ON orders TO merchant_app;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY orders_by_customer ON orders
  TO merchant_app
  USING (customer_id = current_setting('app.customer_id')::int)
  WITH CHECK (customer_id = current_setting('app.customer_id')::int);

SET LOCAL app.customer_id = '42';
SET LOCAL ROLE merchant_app;
SELECT count(*) AS visible_orders, min(customer_id), max(customer_id) FROM orders;
UPDATE orders SET status = 'cancelled' WHERE customer_id = 7 RETURNING id;
ROLLBACK;
Only customer 42's 17 orders are visible, and the UPDATE aimed at customer 7 matches zero rows: other tenants' rows do not exist for this role.
WITH CHECK blocks writing into another tenant
BEGIN;
CREATE ROLE merchant_app NOLOGIN;
GRANT USAGE ON SCHEMA public TO merchant_app;
GRANT SELECT, UPDATE ON orders TO merchant_app;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY orders_by_customer ON orders TO merchant_app
  USING (customer_id = current_setting('app.customer_id')::int)
  WITH CHECK (customer_id = current_setting('app.customer_id')::int);
SET LOCAL app.customer_id = '42';
SET LOCAL ROLE merchant_app;
UPDATE orders SET customer_id = 7 WHERE id = (SELECT min(id) FROM orders);
Moving an order to another customer violates the WITH CHECK expression.
The owner bypasses RLS unless forced
BEGIN;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
SELECT count(*) AS owner_sees FROM orders;
SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'orders';
ROLLBACK;
With RLS enabled and no policies, a normal role would see zero rows. The session user here owns the table (and is a superuser), so it sees everything.

internalsUnder the hood

Policies are added to the query as security barrier quals, so user-supplied functions cannot see rows before the policy filters them (unless marked LEAKPROOF). Policy expressions run for every row; index the tenant column and keep the expression simple so it can use the index.

Avoid / preferDo this, not that

Avoid
-- every query in the app remembers to add WHERE tenant_id = $1
Prefer
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant ON orders USING (tenant_id = current_setting('app.tenant_id')::int);

One forgotten WHERE clause leaks another tenant's data. A policy applies to every query automatically.

Avoid
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;  -- app connects as table owner
Prefer
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

Table owners bypass RLS unless it is forced, so the policy silently does nothing for an owner connection.

PitfallsCommon mistakes

  • Testing RLS as the table owner or a superuser and concluding it works (or does not). Test with SET ROLE to the application role.
  • Using session-level SET for the tenant with a transaction-pooling PgBouncer. The next client on that server connection inherits it. Use SET LOCAL.
  • Forgetting WITH CHECK, so a tenant can insert or move rows into another tenant's space.

LimitsWhen not to use it

Avoid complex policies with subqueries over large tables on hot paths; they run for every row considered. Keep policies to simple comparisons on indexed columns.

SecuritySecurity notes

Use current_setting('app.tenant_id', true) so a missing setting returns NULL and hides rows instead of erroring, and set it with SET LOCAL inside each transaction when using a transaction-mode pooler.

ChallengeTry it yourself

On a copy of orders, enable row-level security so a role cust sees only the orders of the customer in the app.customer_id setting. Show what customer 42 sees.

Show one solution
One solution
BEGIN;
CREATE TABLE my_orders AS SELECT * FROM orders;
CREATE ROLE cust NOLOGIN;
GRANT SELECT ON my_orders TO cust;
ALTER TABLE my_orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY own_orders ON my_orders FOR SELECT TO cust
  USING (customer_id = current_setting('app.customer_id', true)::int);
SET LOCAL app.customer_id = '42';
SET LOCAL ROLE cust;
SELECT count(*) AS visible_orders FROM my_orders;
RESET ROLE;
ROLLBACK;

InterviewQuestions you should be able to answer

How would you implement multi-tenancy with row-level security?
Add tenant_id to every tenant-owned table (indexed, usually leading in composite indexes). Enable RLS and create policies USING (tenant_id = current_setting('app.tenant_id')::bigint) WITH CHECK (same). The application connects as a non-owner role without BYPASSRLS and sets the tenant per transaction with SET LOCAL or set_config(..., true), which works with transaction-mode pooling. Use FORCE ROW LEVEL SECURITY if the owner role is ever used by the app, and test policies with SET ROLE.
Who bypasses row-level security?
Superusers, roles with the BYPASSRLS attribute, and the table owner (unless FORCE ROW LEVEL SECURITY is set on the table). Also anything not subject to policies, such as referential integrity checks. pg_dump sets row_security = off and fails if policies would hide rows, unless run by a role that bypasses RLS.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation