Skip to main content

Row-Level Security Policy: Prevent RLS Bypass and Enforce Tenant Isolation

The Problem

PostgreSQL Row-Level Security (RLS) attaches a predicate to a table, restricting what every query sees to only the rows that satisfy it. The predicate typically references session states, such as current_setting('app.tenant_name') or auth.uid(), which is how many multi-tenant applications isolate one tenant's data from another's.

Unlike most security features, RLS fails open in two ways. A table with RLS disabled returns every row that a basic SELECT grant allows. A table with RLS enabled will still return every row when queried by a superuser, a role with BYPASSRLS, or the table owner (unless FORCE ROW LEVEL SECURITY is also set). Many applications connect to the database with credentials that hold one of these privileges, making this default behavior insecure and the failure silent.

In stacks that expose the schema directly over HTTP (Supabase, PostgREST, Hasura, Neon Data API), a single missing ENABLE ROW LEVEL SECURITY turns a table into a public endpoint. CVE-2025-48757 is the most visible case: 170+ Lovable-generated Supabase apps shipped with RLS disabled on exposed tables, letting unauthenticated attackers read and write arbitrary rows.

The common thread is that RLS lives outside the software development lifecycle. Policies are applied directly to the database, rarely reviewed alongside the application code that depends on them, and almost never tested against a real instance. RLS is hypothetically more secure than application-layer authorization, but not being in the SDLC makes it practically worse.

Enter: Atlas

Atlas closes this gap by managing RLS the same way it manages tables: as declarative schema, version-controlled, linted in CI, tested against a real database, and monitored for drift. The rest of this guide walks through each step of managing RLS as code.

To get started, install Atlas:

To download and install the latest release of the Atlas CLI, simply run the following in your terminal:

curl -sSf https://atlasgo.sh | sh

Define RLS as Code

RLS has three knobs, and all three have to be set correctly for the policy to hold:

  1. ENABLE ROW LEVEL SECURITY on the table. Without it, policies are ignored.
  2. FORCE ROW LEVEL SECURITY to apply policies to the table owner. Without it, the owner bypasses RLS.
  3. CREATE POLICY with the right USING (read filter) and WITH CHECK (write filter) expressions.

Missing any one of these is a silent failure. Atlas encodes all three in one place so they stay in sync:

schema.pg.hcl
table "orders" {
schema = schema.public
column "id" { type = serial }
column "tenant_id" { type = integer, null = false }
column "total" { type = numeric }
primary_key { columns = [column.id] }

row_security {
enabled = true // ENABLE ROW LEVEL SECURITY
enforced = true // FORCE ROW LEVEL SECURITY
}
}

policy "tenant_isolation" {
on = table.orders
as = RESTRICTIVE
for = ALL
to = ["app_writer"]
using = "(tenant_id = current_setting('app.current_tenant')::integer)"
check = "(tenant_id = current_setting('app.current_tenant')::integer)"
}

A few choices in the schema above are worth a closer look:

  1. AS RESTRICTIVE is not the default. PostgreSQL combines permissive policies with OR, so adding a policy can only widen access. Restrictive policies combine with AND and can only narrow it. For tenant isolation you want the latter, otherwise one stray policy can quietly hand one tenant access to another's rows.
  2. Both USING and WITH CHECK are set. USING filters reads and the rows visible to UPDATE and DELETE. WITH CHECK validates rows produced by INSERT and UPDATE. Setting USING alone is a classic bug: a tenant cannot read another tenant's rows, but can still write rows tagged with another tenant's ID.
  3. FORCE ROW LEVEL SECURITY (enforced = true) extends the policy to the table owner. Without it, the role that runs migrations and the DBA running an ad-hoc query both bypass RLS without warning.

For the full declarative workflow, see the Security as Code guide.

Enforce RLS in Development and CI

The schema above defines RLS for one table. The risk is the next table, added in a PR six months from now, where someone (a teammate or an AI agent) forgets the row_security block. To catch that, we want the same checks to run on every change, whether written by a developer or an agent, locally before commit and in CI before merge, asserting the three invariants from the previous section:

  1. ENABLE ROW LEVEL SECURITY is set on every table that needs it.
  2. FORCE ROW LEVEL SECURITY is set so the table owner cannot bypass policies.
  3. Every RLS-enabled table has a policy, and that policy has WITH CHECK on writes (and RESTRICTIVE when it isolates tenants).

Atlas provides a rule language for expressing custom schema policies. We will encode each of the three invariants above as a rule in schema.rule.hcl, building the file up one rule at a time.

1. Enforce ENABLE ROW LEVEL SECURITY on every table

Without it, policies are ignored and the table is open to anyone with SELECT. The rule below asserts that every table in the public schema has RLS enabled:

schema.rule.hcl
predicate "schema" "is_public" {
name { eq = "public" }
}

predicate "table" "in_public" {
schema {
predicate = predicate.schema.is_public
}
}

predicate "table" "rls_enabled" {
row_security_enabled { eq = true }
}

rule "schema" "require-rls-enabled" {
description = "All tables in public must have RLS enabled"
table {
match {
predicate = predicate.table.in_public
}
assert {
predicate = predicate.table.rls_enabled
message = "table ${self.name} is missing row_security { enabled = true }"
}
}
}
How this rule reads
  • match scopes the rule to tables in the public schema, via the in_public predicate.
  • assert requires row_security_enabled = true on each matched table.
  • Tables that fail produce the message above, with ${self.name} replaced by the offending table.

Together: every table in public must have RLS enabled.

When bad_no_rls lands in a PR without a row_security block, the lint step explodes:

  require-rls-enabled rule violated:
-- schema.pg.hcl:14: table bad_no_rls is missing row_security { enabled = true }

This is the rule that would have blocked CVE-2025-48757: the Supabase apps with missing RLS would have failed lint and never reached production.

2. Enforce FORCE ROW LEVEL SECURITY

Without FORCE, the table owner (often the same role your migration tool uses) bypasses every policy. Add a second rule that requires enforced = true on any table with RLS turned on:

schema.rule.hcl
predicate "table" "rls_enforced" {
row_security_enforced { eq = true }
}

rule "schema" "require-rls-enforced" {
description = "Tables with RLS must also have FORCE ROW LEVEL SECURITY"
table {
match {
predicate = predicate.table.rls_enabled
}
assert {
predicate = predicate.table.rls_enforced
message = "table ${self.name} has RLS enabled but not enforced; the owner bypasses every policy"
}
}
}
How this rule reads
  • match reuses the rls_enabled predicate from rule 1, so this rule only sees tables that already turned RLS on.
  • assert requires row_security_enforced = true on each of them.
  • Tables without RLS are ignored here, because rule 1 already catches them.

A table that turns RLS on but skips enforced = true lights this up:

  require-rls-enforced rule violated:
-- schema.pg.hcl:21: table rls_no_force has RLS enabled but not enforced; the owner bypasses every policy

3. Enforce policy correctness

The third knob has three sub-checks. RLS enabled with no policy attached is default-deny, which is safe but almost always a configuration mistake. Policies that cover INSERT or UPDATE must set WITH CHECK, otherwise a tenant can still write rows tagged with another tenant's ID. And tenant-isolation policies should be RESTRICTIVE, so a future PERMISSIVE policy added in a "minor" PR cannot silently widen access.

schema.rule.hcl
predicate "table" "has_any_policy" {
exists {
policy {
condition = true
}
}
}

rule "schema" "rls-requires-policy" {
description = "Tables with RLS enabled must have at least one policy"
table {
match {
predicate = predicate.table.rls_enabled
}
assert {
predicate = predicate.table.has_any_policy
message = "table ${self.name} has RLS enabled but no policy attached"
}
}
}

predicate "policy" "covers_writes" {
for { in = ["ALL", "INSERT", "UPDATE"] }
}

predicate "policy" "has_check" {
check { ne = "" }
}

rule "schema" "policies-require-check" {
description = "Write policies must have WITH CHECK"
table {
policy {
match {
predicate = predicate.policy.covers_writes
}
assert {
predicate = predicate.policy.has_check
message = "policy ${self.name} covers writes but has no WITH CHECK expression"
}
}
}
}

predicate "policy" "is_tenant_policy" {
name {
match = ".*tenant.*"
}
}

predicate "policy" "is_restrictive" {
restrictive { eq = true }
}

rule "schema" "tenant-policies-restrictive" {
description = "Policies named *tenant* must be RESTRICTIVE"
table {
policy {
match {
predicate = predicate.policy.is_tenant_policy
}
assert {
predicate = predicate.policy.is_restrictive
message = "policy ${self.name} must be RESTRICTIVE"
}
}
}
}
How these rules read
  • rls-requires-policy walks every RLS-enabled table and uses exists { policy { ... } } to require at least one attached policy.
  • policies-require-check iterates each policy under each table, matches those that cover writes (ALL, INSERT, UPDATE), and requires a non-empty WITH CHECK expression.
  • tenant-policies-restrictive matches policies whose name contains tenant and requires restrictive = true. A future PERMISSIVE policy named tenant_* would fail lint.

A schema that violates all three at once produces a single, dense lint report:

  rls-requires-policy rule violated:
-- schema.pg.hcl:21: table rls_no_force has RLS enabled but no policy attached
-- schema.pg.hcl:33: table rls_no_policy has RLS enabled but no policy attached
policies-require-check rule violated:
-- schema.pg.hcl:65: policy writes_no_check covers writes but has no WITH CHECK expression
tenant-policies-restrictive rule violated:
-- schema.pg.hcl:51: policy tenant_perm_bad must be RESTRICTIVE

Wire the rules into atlas.hcl

atlas.hcl is the project configuration file Atlas reads when you pass --env <name>. Each env block bundles the inputs Atlas needs to operate on a given target: where the desired schema lives (src), which dev database to use (dev), and which policies to enforce (lint).

Custom rules like the ones we just wrote are registered as rule "hcl" blocks under lint, so the same schema.rule.hcl can be loaded into one environment, many, or none:

atlas.hcl
env "ci" {
src = "file://schema.pg.hcl"
dev = "docker://postgres/17/dev"
lint {
rule "hcl" "rls" {
src = "file://schema.rule.hcl"
}
}
}

From this point on, atlas schema lint (and migrate lint) fail whenever any of these rules break, whether run locally before commit or in CI before merge.

Block Roles and Grants That Bypass RLS

The rules so far focus on the schema: whether RLS is on, whether policies are attached, whether write paths are checked. They never check who runs the queries. In PostgreSQL, the user that connects to the database (called a role) can have privileges that skip RLS no matter how the policy is written:

  1. A role granted BYPASSRLS ignores every policy, on every table.
  2. A SUPERUSER role bypasses RLS along with every other authorization check.
  3. A policy granted to PUBLIC (every role on the cluster) or postgres (the default superuser) applies far more widely than the application role you wrote it for.

Since Atlas inspects roles and users alongside tables, we can write rules against them in the same language we used above. Two rules close the first two gaps:

  • no-unexpected-bypassrls restricts BYPASSRLS to a short allowlist of audited roles (e.g. admin_break_glass, replicator) and forbids it on login users entirely.
  • no-superuser-app-roles forbids SUPERUSER on any role whose name starts with app_.
schema.rule.hcl
// BYPASSRLS allowed only on an audited allowlist; never on a login user.
predicate "role" "bypass_rls_allowed" {
or {
bypass_rls { eq = false }
name { in = ["admin_break_glass", "replicator"] }
}
}

predicate "user" "no_bypass_rls" {
bypass_rls { eq = false }
}

rule "schema" "no-unexpected-bypassrls" {
description = "BYPASSRLS is allowed only on a short, reviewed allowlist"
role {
assert {
predicate = predicate.role.bypass_rls_allowed
message = "role ${self.name} has BYPASSRLS but is not in the allowlist"
}
}
user {
assert {
predicate = predicate.user.no_bypass_rls
message = "user ${self.name} has BYPASSRLS; bypass should be granted to a role, not a login user"
}
}
}

// SUPERUSER not allowed on application roles (named app_*).
predicate "role" "is_app_role" {
name { match = "^app_.*" }
}

predicate "role" "not_superuser" {
superuser { eq = false }
}

rule "schema" "no-superuser-app-roles" {
description = "Application roles must not be SUPERUSER"
role {
match {
predicate = predicate.role.is_app_role
}
assert {
predicate = predicate.role.not_superuser
message = "role ${self.name} is SUPERUSER and will bypass RLS"
}
}
}

A typical run against a cluster where someone left BYPASSRLS on app_writer and gave app_admin SUPERUSER looks like this:

  no-unexpected-bypassrls rule violated:
-- role app_writer has BYPASSRLS but is not in the allowlist
-- user leftover_debugger has BYPASSRLS; bypass should be granted to a role, not a login user
no-superuser-app-roles rule violated:
-- role app_admin is SUPERUSER and will bypass RLS

Write Unit Tests for Your RLS Policies

Static lint rules prove RLS is declared. They don't prove it works. Tenant A could still read Tenant B's data if the USING predicate is wrong, the current_setting key is misspelled, or the policy applies to the wrong role. The fix is a real test against a real database, which is exactly what atlas schema test runs.

Why we SET ROLE inside the test

The docker://-based dev URL connects as the default postgres superuser, and SUPERUSER bypasses RLS unconditionally. If we asserted directly under that connection, every test would pass even with RLS disabled. The fix is a single line: SET ROLE app_tester; switches PostgreSQL's current_user to a non-privileged role, and RLS uses current_user for its policy check. From that point on, RLS applies.

schema.test.hcl
test "schema" "tenant_1_sees_only_its_rows" {
exec {
sql = <<-SQL
-- Create a non-superuser role for the test and grant just enough.
CREATE ROLE app_tester NOLOGIN;
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_tester;
GRANT USAGE ON SCHEMA public TO app_tester;

-- Seed two tenants, two rows each (still as superuser, RLS bypassed).
INSERT INTO orders (id, tenant_id, total) VALUES
(1, 1, 100), (2, 1, 200),
(3, 2, 300), (4, 2, 400);

-- Switch to the non-superuser role so RLS actually applies.
SET ROLE app_tester;
SET app.current_tenant = '1';
SQL
}

// Restore the connecting role so subsequent tests don't run as app_tester.
cleanup {
sql = "RESET ROLE"
}

assert {
sql = "SELECT count(*) = 2 FROM orders"
error_message = "Tenant 1 should see exactly 2 rows"
}

assert {
sql = "SELECT bool_and(tenant_id = 1) FROM orders"
error_message = "Tenant 1 saw rows from another tenant (RLS bypass)"
}
}

Run it in CI:

atlas schema test --env ci --dev-url "docker://postgres/17/dev" .

A passing run looks like this:

-- PASS: tenant_1_sees_only_its_rows (9ms)
PASS

If you flip row_security { enabled = false } on the table, the same test fails immediately, because the count(*) returns 4 rather than 2:

-- FAIL: tenant_1_sees_only_its_rows (7ms)
schema.test.hcl:17:
Error: expected a true value, but got false: Tenant 1 should see exactly 2 rows
FAIL

This is also the test that would have caught CVE-2023-39418 (MERGE skipping WITH CHECK): an extra INSERT covering a foreign tenant would actually get stored on an affected PostgreSQL version, and a final count assertion would notice.

Next Steps

Watch for production drift

Everything we built so far protects the schema as it is declared in the repo. Production databases still see direct changes the lint rules never saw: an on-call engineer disables RLS to debug an incident, a one-off backfill job runs as postgres, a backup restore loses a policy. None of these appear in a PR. To close the loop, run drift detection against the live database on a schedule and diff it against the schema in the repo:

Wire the rules and tests into CI

Running atlas schema lint and atlas schema test locally helps the author of a PR. Wired into your CI provider as a required check, the same commands fail any PR that disables RLS, drops a required policy, or grants BYPASSRLS to an app role, before it can merge:

Go deeper

Once the rules, tests, and CI gates are in place, the references below cover the wider security workflow Atlas supports and the full surface of the rule and test languages used above: