Skip to content
EL
Contents

Secure database engineering

Does this row-level security actually isolate tenants?

DecisionExpert5 min read15 min hands-on#security#operations

The idea in 30 seconds

Row-level security looks like a one-line guarantee that tenants cannot see each other's data. It holds only if the login is not the table owner or a superuser, if the tenant setting cannot leak from one request to the next through a pooled connection, and if a test proves isolation as the real application login. Review an RLS rollout for those three things before anything else.

Helps to know first: PostgreSQL security and multi-tenancy (coming soon), Functions, procedures and triggers, Connection management and pooling (coming soon)

On this page

The situation

The shop is becoming a platform: many merchants (tenants) in one database. Every table has a tenant_id. Until now, every query had to remember WHERE tenant_id = ?, and last month one did not. A teammate opens a pull request to make the database enforce it.

Review taskEnforce tenant isolation with row-level security

Pull request: “Turn on row-level security so a missing WHERE tenant_id can never leak data again.”

-- migration
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::int);
-- (same for the other 14 tenant tables)
# request middleware: runs at the start of every web request
def set_tenant(conn, tenant_id):
    conn.execute("SET app.tenant_id = %s", [str(tenant_id)])
# config/production.yaml: unchanged
database:
  host: db
  name: shop
  user: shop_owner        # owns all tables; also runs migrations
  pool_size: 20

Tested locally: logged in as tenant 2, the invoices page shows only tenant 2’s invoices.

Would you approve it? Write the review you would leave.

Your call

How to review a security control

Find who it does not apply to. Every access control has exceptions: owners, superusers, BYPASSRLS, SECURITY DEFINER functions, replicas, backups and exports. Check which of them production uses.

Ask how it fails. A control that fails closed (an error, no rows) is noisy but safe. One that fails open (all rows, or the wrong rows) is silent. The session-level SET turned a forgotten filter into a wrong-tenant leak, the worst failure available.

Test as the real login. Security bugs hide in the gap between the login developers use and the one production uses. The test that matters connects with production’s role and settings.

Check yourself

  1. The PR’s policy is correct. Why did tenant 2 still see every tenant’s invoices in production?

    Show answer

    Production logs in as shop_owner, the table owner, and the tables are not FORCEd, so row-level security is bypassed entirely.

  2. Why is SET LOCAL safer than SET with a connection pool?

    Show answer

    SET LOCAL ends with the transaction, so the next request on the same connection starts without a tenant and fails closed. SET persists for the session, so a request that forgets to set it silently runs as the previous request’s tenant.

  3. Would FORCE ROW LEVEL SECURITY alone have fixed this if production logged in as a superuser?

    Show answer

    No. Superusers and roles with BYPASSRLS bypass row security even on forced tables. The application must use a login without those attributes.

Finished reading?

Saved in this browser only.

Sources

Where each claim on this page comes from.

  1. 1.
    PostgreSQL documentation: 5.9 Row Security Policies
    Primary· §5.9 Row Security Policies

    Superusers and roles with BYPASSRLS always bypass row security; table owners bypass it unless FORCE ROW LEVEL SECURITY is set.

  2. 2.
    PostgreSQL documentation: CREATE POLICY
    Primary· Description; Parameters: USING, WITH CHECK

    For ALL policies without WITH CHECK, the USING expression also decides which new rows may be written; with RLS enabled and no applicable policy, nothing is visible (default deny).

  3. 3.
    PostgreSQL documentation: SET
    Primary· Parameters: SESSION, LOCAL

    SET without LOCAL lasts for the rest of the session; SET LOCAL lasts only until the end of the current transaction.

  4. 4.
    PostgreSQL documentation: 9.28 System Administration Functions
    Primary· §9.28.1 Configuration Settings Functions: set_config

    set_config with is_local true applies the value only for the current transaction.

Go further

What to read next, and why it is worth the time.

  1. 1.
    PostgreSQL documentation: CREATE POLICY
    Primary· Notes; Table: Policies Applied by Command Type

    Which expression is checked for which command, which is where most real RLS mistakes hide.

Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.