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:
- macOS + Linux
- Homebrew
- Docker
- Windows
- CI
- Manual Installation
To download and install the latest release of the Atlas CLI, simply run the following in your terminal:
curl -sSf https://atlasgo.sh | sh
Get the latest release with Homebrew:
brew install ariga/tap/atlas
To pull the Atlas image and run it as a Docker container:
docker pull arigaio/atlas
docker run --rm arigaio/atlas --help
If the container needs access to the host network or a local directory, use the --net=host flag and mount the desired
directory:
docker run --rm --net=host \
-v $(pwd)/migrations:/migrations \
arigaio/atlas migrate apply \
--url "mysql://root:pass@:3306/test"
Download the latest release and move the atlas binary to a file location on your system PATH.
GitHub Actions
Use the setup-atlas action to install Atlas in your GitHub Actions workflow:
- uses: ariga/setup-atlas@v0
with:
cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }}
Other CI Platforms
For other CI/CD platforms, use the installation script. See the CI/CD integrations for more details.
Define RLS as Code
RLS has three knobs, and all three have to be set correctly for the policy to hold:
ENABLE ROW LEVEL SECURITYon the table. Without it, policies are ignored.FORCE ROW LEVEL SECURITYto apply policies to the table owner. Without it, the owner bypasses RLS.CREATE POLICYwith the rightUSING(read filter) andWITH 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:
- Atlas DDL (HCL)
- SQL
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)"
}
CREATE TABLE "orders" (
"id" serial PRIMARY KEY,
"tenant_id" integer NOT NULL,
"total" numeric
);
ALTER TABLE "orders" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "orders" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "orders"
AS RESTRICTIVE
FOR ALL
TO "app_writer"
USING (tenant_id = current_setting('app.current_tenant')::integer)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::integer);
A few choices in the schema above are worth a closer look:
AS RESTRICTIVEis not the default. PostgreSQL combines permissive policies withOR, so adding a policy can only widen access. Restrictive policies combine withANDand 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.- Both
USINGandWITH CHECKare set.USINGfilters reads and the rows visible toUPDATEandDELETE.WITH CHECKvalidates rows produced byINSERTandUPDATE. SettingUSINGalone is a classic bug: a tenant cannot read another tenant's rows, but can still write rows tagged with another tenant's ID. 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:
ENABLE ROW LEVEL SECURITYis set on every table that needs it.FORCE ROW LEVEL SECURITYis set so the table owner cannot bypass policies.- Every RLS-enabled table has a policy, and that policy has
WITH CHECKon writes (andRESTRICTIVEwhen 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:
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 }"
}
}
}
matchscopes the rule to tables in thepublicschema, via thein_publicpredicate.assertrequiresrow_security_enabled = trueon each matched table.- Tables that fail produce the
messageabove, 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:
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:
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"
}
}
}
matchreuses therls_enabledpredicate from rule 1, so this rule only sees tables that already turned RLS on.assertrequiresrow_security_enforced = trueon 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:
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.
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"
}
}
}
}
rls-requires-policywalks every RLS-enabled table and usesexists { policy { ... } }to require at least one attached policy.policies-require-checkiterates each policy under each table, matches those that cover writes (ALL,INSERT,UPDATE), and requires a non-emptyWITH CHECKexpression.tenant-policies-restrictivematches policies whose name containstenantand requiresrestrictive = true. A futurePERMISSIVEpolicy namedtenant_*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:
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:
- A role granted
BYPASSRLSignores every policy, on every table. - A
SUPERUSERrole bypasses RLS along with every other authorization check. - A policy granted to
PUBLIC(every role on the cluster) orpostgres(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-bypassrlsrestrictsBYPASSRLSto a short allowlist of audited roles (e.g.admin_break_glass,replicator) and forbids it on login users entirely.no-superuser-app-rolesforbidsSUPERUSERon any role whose name starts withapp_.
// 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.
SET ROLE inside the testThe 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.
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:
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:
Drift Detection Guide
The full walkthrough: what drift is, how Atlas detects it, and how to remediate it.
Pre-deployment Check
Block a versioned-migration deploy when the target database has drifted from the expected schema.
Continuous Monitoring
Run drift checks on a schedule from Atlas Cloud and get notified the moment RLS, a policy, or a grant changes.
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:
GitHub
Run schema lint, schema tests, and migration linting on every PR with GitHub Actions.
GitLab
Atlas CI/CD components for merge-request linting and migration deployment in GitLab pipelines.
Bitbucket
Atlas Bitbucket Pipes for running schema lint and migration steps in Bitbucket pipelines.
Azure DevOps
Run schema lint and migration deployment as Azure DevOps pipeline tasks.
CircleCI
Atlas orbs for running schema lint and migration steps inside CircleCI workflows.
OpenTofu / Digger
Plan and apply schema changes from your IaC workflow with the Atlas Terraform provider on OpenTaco (Digger).
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:
- PostgreSQL Security as Code: the full roles + permissions + RLS workflow
- Custom Schema Policy: every predicate, operator, and rule attribute
- Schema Testing: full assertion syntax and test configuration