Safeguard
Security Guides•

How does Postgres row-level security isolate tenants in a SaaS app?

Postgres row-level security puts a policy on every query against a table, so an app bug cannot return another tenant's rows. How it works and its limits.

Safeguard Security Team
4 min read

Postgres row-level security (RLS) attaches a policy to a table, and the database then filters every read and write through that policy. In a multi-tenant app the policy compares each row's tenant_id with the current tenant, so a missing WHERE clause in application code cannot leak another tenant's data.

What RLS actually is

PostgreSQL's documentation describes row security policies as a way to restrict, per user, which rows normal queries can return and which rows insert, update and delete commands can touch. By default a table has no policies, so anyone with table privileges sees every row. Once you run ALTER TABLE ... ENABLE ROW LEVEL SECURITY, every normal access has to be allowed by a policy. If no policy exists, a default-deny policy applies and no rows are visible.

That default-deny behaviour is the useful part for SaaS. A forgotten policy fails closed, not open.

A minimal tenant policy

The OWASP Multi-Tenant Security Cheat Sheet gives a pattern that most teams adapt:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_policy ON orders
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

ALTER TABLE orders FORCE ROW LEVEL SECURITY;

The application sets app.current_tenant for each request after it has verified who the caller is. The policy expression is evaluated for each row before conditions from the user's query. A policy with only a USING clause also applies the same check to rows being inserted or updated, so one tenant cannot write a row into another tenant's space.

Because the cheat sheet leaves out the optional missing_ok argument, PostgreSQL raises an error if the setting does not exist. That is a deliberate fail-closed choice: a request with no tenant context errors out instead of returning everything.

Where RLS stops protecting you

RLS is strong, but it has edges you need to know about.

SituationWhat happens
Superuser or role with BYPASSRLSAlways bypasses row security
Table ownerNormally bypasses it, unless you use FORCE ROW LEVEL SECURITY
TRUNCATE and REFERENCESNot subject to row security
Tables with no policy while RLS is offAll rows visible to anyone with privileges

So the application's request path should connect with a least-privileged role that is neither a superuser nor a BYPASSRLS role. Keep privileged connections for migrations and explicitly authorized admin jobs. A privileged job has to constrain its own tenant set, because RLS will not do it for you.

RLS also does not replace application authorization. It answers "which tenant's rows?" It does not answer "may this user in this tenant delete invoices?" You still need role or attribute checks above it.

Steps to adopt it

  1. List every table that holds tenant-owned data and make sure each has a tenant_id column.
  2. Enable RLS and add a policy on each one.
  3. Use FORCE ROW LEVEL SECURITY so the owner role is also covered.
  4. Create a dedicated application role without BYPASSRLS.
  5. Set the tenant context inside each transaction (see the related post on connection pools).
  6. Test with that same role: tenant A must get zero rows for tenant B's data on read, update and delete.

Try it yourself

Click through it at your own pace, or open it full screen. Look at how a finding points at the exact line where a query is built, which is where a missing tenant filter would show up in review.

Safeguard runs SBOM and dependency scanning and applies reachability analysis, so advisories in your database drivers and ORM layers are ranked by whether your code can reach them. You can try the free scan at gold.safeguard.sh/scan.

Quick check

Does RLS replace tenant_id filters in my queries?

No. Keep the filters, because they help the planner and make intent obvious. Treat RLS as the backstop that still holds when someone forgets one.

Does RLS apply to my migration role?

Not if that role is a superuser or has BYPASSRLS. That is why migrations should run on a separate connection from the one that serves requests.

Sources

Last reviewed: 19 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.