Skip to content

rls-check

rls-check reads a PostgreSQL database’s catalog and reports what is actually exposed: tables served with row-level security switched off, policies that evaluate to true for everyone, views that read straight past the RLS on their base tables, and join tables that were forgotten while both of their endpoints were locked down.

It works on any Postgres — Supabase, Neon, RDS, Cloud SQL, or a server you run yourself. It does not require Rebase, and it is useful whether or not you ever adopt it.

npx @rebasepro/rls-check

Run it in your project directory and it finds the database itself: DATABASE_URL, then POSTGRES_URL, then a .env beside you. Pass the connection string as an argument only when you cannot do that — npm echoes the command line before the program starts, and your shell records it, so a password in an argument ends up in two places rls-check cannot redact. $DATABASE_URL is no safer there: the shell expands it before npm ever sees it.

It is read-only by construction: it opens a read-only transaction and issues catalog queries. It writes nothing, and it sends nothing anywhere — there is no telemetry and no network call other than the one to your database.

# From the environment — DATABASE_URL, then POSTGRES_URL, then a .env in the cwd
npx @rebasepro/rls-check
# For a database that is not the one in your environment
DATABASE_URL="postgres://user:pass@host:5432/dbname" npx @rebasepro/rls-check
# As an argument. Works, but see the warning above about where the password lands
npx @rebasepro/rls-check "postgres://user:pass@host:5432/dbname"

If your password contains /, ? or #, percent-encode it. Those three end the URL’s authority section, so the split lands inside the credential — rather than print fragments of a password, rls-check refuses the string and says so.

@ and : need no encoding: the userinfo is split at the last @ and the user at the first :, which is what pg does too, so postgres://user:pa@ss@host:5432/db connects to host with the password pa@ss. Encoding them anyway is never wrong.

Option Meaning
--json Machine-readable output on stdout, and nothing else on stdout
--format <name> text (default), json (the same as --json) or agent: one JSON document for a coding agent, where every finding carries the failing table and policy, the SQL that fixes it and the equivalent Rebase securityRules
--html <path> Also write a self-contained HTML report there. One file, no network requests
--schema <name> Restrict the scan to a schema. Repeatable, or comma-separated
--role <name> Treat this role as one an untrusted caller arrives as, on top of anon, authenticated, web_anon and rebase_user. Repeatable, or comma-separated
--fail-on <severity> Exit 1 at or above this severity. Default high; none never fails
--only <id> Run only these checks. Repeatable, or comma-separated
--skip <id> Skip these checks. Repeatable, or comma-separated
--list-checks Print the catalog and exit
--timeout <ms> Statement timeout, default 15000
--quiet Findings only — no banner, no summary
--no-color Disable ANSI colour (also honours NO_COLOR and a non-TTY stdout)

An unknown id passed to --only or --skip is an error rather than a silent no-op, because a typo there quietly weakens the scan. A --role that is not in pg_roles is an error for the same reason: every check gates on a grant to an exposed role, so a name that matches nothing removes coverage without saying so. So is a --schema that names no schema, which would otherwise scan nothing and report it clean. Schema names are case-sensitive: Public is not public.

The report’s header names the roles the run treated as exposed, so you can see at a glance whether No findings covered the role your application connects as:

Exposed PUBLIC, anon, authenticated (add yours with --role)

When the scan connects as a role row-level security can constrain — not a superuser, not an owner, no BYPASSRLS — that role is added to the set and the report says so. Scanning as your application’s own role is the closest thing to asking the database what your API sees.

Code Meaning
0 No findings at or above the --fail-on threshold
1 At least one finding at or above the threshold
2 The scan could not run — bad arguments, connection refused, auth failure, timeout — or was incomplete: a catalogue read failed or a check crashed (diagnostics.degraded). This holds even with --fail-on none

1 and 2 are deliberately distinct: a broken connection must never look like a clean database.

- name: Audit RLS
run: npx @rebasepro/rls-check --fail-on high
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}

A new Rebase project does not pass that on day one, and it is not meant to. The scaffold’s defaultSecurityRules open reads to everyone — { operation: "select", access: "public" } in config/collections/index.ts — so posts, authors and tags each report a critical policy-always-true. access: "public" is about rows, not about who may call the API: a request with no token is still answered 401 while AUTH_REQUIRE is on. The finding is correct all the same, because that is the only thing standing in front of the data.

Decide which of those two it is before you wire this into CI:

  • the rules are a placeholder — replace them with the ones your data actually needs (security rules), and the findings go away;
  • the rows really are world-readable — say so once, with npx @rebasepro/rls-check --fail-on high --skip policy-always-true, and know what you gave up: --skip turns the check off everywhere, including on the table you add next month.

--json emits a stable object: scannedAt, database (host and name only — never credentials), serverVersion, platform, scannerIsPrivileged, exposedRoles, stats, findings and diagnostics. Each finding carries id, severity, title, target, detail, impact, fix, docs and confidence.

exposedRoles and diagnostics are part of the contract, not extras: every check gates on the exposed set, and diagnostics.degraded is how a consumer tells “nothing was wrong” from “the scan could not look”. Reading findings: [] without both is reading half the answer.

--format agent prints one JSON document for a coding agent that fixes what it finds. Every finding names its check id and failing object (target.table, target.policy, …) and carries a remediation: apply (sql; rebase-config when Rebase re-makes the policy from a collection’s securityRules at every boot; or manual), runnable sql written per check, the placeholders to fill in first, and rebase — the same fix as defineCollection({ securityRules: [...] }), or why rules cannot express it. Each finding’s key is stable across runs, so a re-run shows what was cleared. --json is unchanged.

A check you have to remember to run is a check that reports a clean database right up until the day it matters. The backend can run this one for you and serve the result to the admin panel:

import { scan } from "@rebasepro/rls-check";
import type { RebaseBackendConfig } from "@rebasepro/server";
const rlsAudit: RebaseBackendConfig["rlsAudit"] = {
enabled: true,
scan,
intervalMs: 24 * 60 * 60 * 1000, // the default
warnAtSeverity: "high" // the default
};

Pass that as rlsAudit in the object you hand initializeRebaseBackend.

On the runtime — rebase dev, rebase start and the published server image — there is no such object to hand over, so set REBASE_RLS_AUDIT=true instead, with the default interval and threshold. The scanner still has to come from your project: add @rebasepro/rls-check to its dependencies so the bundle carries it. The runtime ships no copy of its own, and a boot that asks for the audit without the package refuses before opening a connection, naming the package to add.

The result is served at GET /api/admin/rls-audit, gated on the schema:read scope, and each run logs one line — at warn when a finding reaches warnAtSeverity, at info otherwise:

⚠️ [rls-audit] RLS audit found 3 issue(s) — 1 critical, 2 medium. 1 table(s)
without RLS. Read the detail at GET /api/admin/rls-audit, or run
`npx @rebasepro/rls-check` for the full report.

@rebasepro/server has no database driver — that is what keeps it usable with Postgres, Mongo and Firebase alike. @rebasepro/rls-check carries pg, because it connects to Postgres. Importing it inside the server package would put a Postgres driver in every install, for a feature only some of them can use, so you hand the function in instead.

The audit is an owned singleton, like the cron scheduler. Every process that owns it runs its own scan, which is read-only and harmless but redundant — give it to one process:

ownership: { rlsAudit: false } // on every process but one

or, for a runtime configured by environment:

REBASE_RLS_AUDIT=false

The functions role already owns it as false — that process runs no timers at all. Naming one ownership override never moves another: turning off cron leaves the audit alone, and vice versa.

A process that serves the admin surface without owning the scan answers the endpoint honestly, saying the scan does not run there.

The default interval is a day, because the thing being watched is a schema: it changes on deploys, not on traffic. A failed scan is logged and recorded in the status — never thrown. Turning a security check into a new way for the server to fall over would be a bad trade in both directions.

Confirmed findings come first; heuristic ones are in a separate “worth checking” section. A heuristic check cannot see intent — a junction table you deliberately left open is not a bug — so those are phrased as questions and never mixed in with the certainties.

Watch for the privilege note. If the scan connects as a superuser, a table owner, or a role with BYPASSRLS, it says so. That role sees the true catalog, which is what makes the audit possible, but it also means nothing in the report describes what that connection experiences. The findings are about what other roles get.

On a Rebase database, change the rule, not the policy

Section titled “On a Rebase database, change the rule, not the policy”

Every policy on a Rebase deployment is compiled from a collection’s securityRules, and the runtime checks them on every boot: each generated policy carries a stamp (a rebase:<hash> comment) of what was compiled and what Postgres stored, and a table whose policies no longer match it is re-applied from the config. An ALTER POLICY run against one of them therefore survives exactly until the next restart, and the finding comes back with it — after you watched it disappear.

rls-check recognises those policies (a <table>_<operation>_<hash> name, or a call to rebase.uid() / rebase.roles() in the expression) and prescribes the rule instead of SQL. When you see a fix that says so:

  1. find the collection whose table it names, under config/collections/;
  2. change its securityRules — see security rules;
  3. if the collection declares none of its own, it inherits defaultSecurityRules from config/collections/index.ts, and that is the file to edit;
  4. redeploy — boot re-applies the policies — or run rebase db push.

A policy you wrote by hand, in a migration, is not touched by any of this, and its fix is still the SQL to run.

Severities below are the defaults; several checks adjust their own severity based on what they find, and the report always states the reason.

Table exposed without row-level security. Critical.

The table has RLS disabled and grants SELECT/INSERT/UPDATE/DELETE to a role an untrusted caller can reach (anon, PUBLIC, web_anon, rebase_user). Postgres applies no per-row filter at all, so policies — if any exist — are never consulted.

A foreign table with such a grant is reported too. Postgres cannot enable RLS on one, so the grant hands over whatever the remote server returns, and the suggested fix revokes the grant instead.

A table with RLS off but no grant to an exposed role is not reported, and neither is one in a schema that role has no USAGE on — Postgres answers “permission denied for schema” before it looks at the table. It is not reachable, and flagging it would be noise.

ALTER TABLE "public"."your_table" ENABLE ROW LEVEL SECURITY;

Enabling RLS with no policies denies every row to everyone but the owner, so add the policy you intend in the same migration — otherwise you have traded an exposure for a silent outage. See rls-enabled-no-policies.

Policy grants unconditional access. Critical.

A permissive policy whose USING or WITH CHECK expression is a constant truth: true, (true), 1 = 1, 1 > 0, NOT false, true OR <anything>, or a constant compared with itself through a cast. Permissive policies are ORed together, so a single one of these satisfies the table’s row filter no matter how strict every other policy is.

Like every check, it reports an exposure only when a role it applies to can reach the table: holds the privilege its command needs, and USAGE on the schema. USING (true) TO anon on a table anon holds nothing on is answered “permission denied” before the policy is consulted. That policy is reported as info, titled latent: nothing reaches the table through it today, and one GRANT would make it a critical exposure with no change to the policy. Info is below every --fail-on except info, so it never fails a default run.

When only the WITH CHECK of an UPDATE policy is constant — USING (user_id = rebase.uid()) WITH CHECK (true) — it is high: USING still decides which rows can be touched, and the check lets a touched row become anything, such as another user’s. On a FOR ALL policy the same check also admits any INSERT, so that stays critical.

If RESTRICTIVE policies on the same command (ALL for a permissive ALL) apply to every exposed role the permissive policy reaches, this is downgraded to medium and reported as something to verify rather than a certainty, because restrictive policies AND after the permissive ones OR. A restrictive policy that gates other roles or another command does not count: it leaves the permissive one open to everyone it misses.

ALTER POLICY "your_policy" ON "public"."your_table"
USING (user_id = rebase.uid());

On a Rebase database the fix is the collection’s rule rather than that statement — see change the rule, not the policy. A stock scaffold reports this check on posts, authors and tags by design.

Policy only checks that a caller id exists. Severity depends on the platform.

The expression is rebase.uid() IS NOT NULL-shaped (or auth.uid() on Supabase, and on a Rebase database provisioned before 1.0): it separates signed-in from signed-out callers, but scopes no rows. Every signed-in user reaches every row the policy covers.

The severity is platform-dependent, and this distinction matters:

  • On Supabase, auth.uid() returns NULL for anonymous callers, so this is a working authenticated-only check. Reported as low — a data-scoping gap between signed-in users, not an anonymous-access hole. That holds for auth.uid() (and the sub claim it reads) only: a signed-out request still carries the project’s anon key, so auth.role() is 'anon' and auth.jwt() is that key’s claims. The same shape built on either of them is true for signed-out callers, and is reported as critical.
  • On Rebase or PostgREST, where a blank caller id is coerced to an 'anonymous' sentinel, the expression is true for signed-out callers too. Reported as critical.
  • On an unrecognised platform, reported as medium, since whether it is a hole depends on whether your stack uses such a sentinel.

A database counts as Rebase when it has the rebase schema. The rebase_user role does not decide it, because roles belong to the whole server, and a server that hosts a Rebase app may hold other databases.

-- Scope to the row's owner rather than to the existence of an id
ALTER POLICY "your_policy" ON "public"."your_table"
USING (user_id = rebase.uid());
-- Or, if "any signed-in user" really is the intent, reject the sentinel explicitly
-- USING (rebase.uid() IS NOT NULL AND rebase.uid() <> 'anonymous');

The suggested SQL is printed with the caller-id function your database actually has: rebase.uid() on a Rebase database, auth.uid() on Supabase and PostgREST. Both spellings are recognised when reading policies, so a Rebase database mid-migration from the pre-1.0 auth schema is still checked.

Policy admits every signed-in caller to every row. High; critical for the role-test form below.

The corrected form of the check above — rebase.uid() IS NOT NULL AND rebase.uid() <> 'anonymous' — and the place people stop. It does exclude signed-out callers. What it does not do is scope any rows: what remains is every registered account may read every row of this table, which is a different sentence from the one it is usually meant to be. On a Rebase project this is a rule whose condition is policy.authenticated() and nothing else, which the database stores as rebase.uid() <> ALL (ARRAY['anonymous', 'anon']). A rule whose condition is only policy.registered() is reported too: its extra NOT rebase.is_anonymous() keeps guests out and scopes no rows either, so every account that is not a guest reads every row. With the default --fail-on high, a CI run that scans such a policy now fails.

A policy whose expression only tests the caller’s role — auth.role() = 'authenticated', (auth.jwt() ->> 'role') = 'authenticated', either way round — is reported under this id too. It is TO authenticated USING (true) written as an expression, so it gets the severity policy-always-true gives that: critical, or high when only the WITH CHECK of an UPDATE policy is the role test.

That is the shape that turns a users table into a directory of every address on the platform, readable by anyone who signed up — and where registration is open, “anyone who signed up” is anyone at all. It is reported separately from the anonymous form because the fix is different and so is the severity, and because you may legitimately want to silence one and not the other.

-- Scope to the row
ALTER POLICY "your_policy" ON "public"."your_table"
USING (user_id = rebase.uid());
-- Or, where members of a shared group really may see each other's rows, say which group
-- USING (EXISTS (SELECT 1 FROM memberships m
-- WHERE m.org_id = your_table.org_id AND m.user_id = rebase.uid()));

If the table genuinely is readable by every account — a shared price list, a list of countries — keep the policy and silence the finding with rls-check --skip policy-authenticated-tautology.

View reads past its base table’s RLS. Critical.

A view granted to an untrusted role that selects from an RLS-protected table without security_invoker = true. The view runs with its owner’s privileges, so it reads the base table as the owner and the caller’s policies never apply. This is the most common way a carefully locked-down table leaks.

ALTER VIEW "public"."your_view" SET (security_invoker = true);

On PostgreSQL below 15 the option does not exist at all, so every such view behaves this way. There the finding is reported as heuristic, and the fix is to move the logic into a function or upgrade.

The owner is normally exempt from the base table’s policies, but not always. When the view’s owner is not a superuser, holds no BYPASSRLS, and does not own the base table (or owns it with FORCE ROW LEVEL SECURITY), the policies apply to the owner. The view then returns the rows those policies give the owner, to every caller alike. That can be fewer rows than a caller should see, or more. The finding is still reported, because the rows do not depend on who asks, but as heuristic rather than certain. The fix is the same.

Materialized view exposes RLS-protected data. High.

Materialized views cannot have row-level security, and the data in them is a stored snapshot taken by whoever refreshed it. If one is granted to an untrusted role and its defining query reads an RLS-protected table, no policy can help — revoke the grant, or move the matview into a schema that untrusted roles cannot reach. The suggested fix revokes every grant that reaches an untrusted role, from the role each grant names: a grant to a role that anon inherits is not removed by REVOKE … FROM anon.

REVOKE ALL ON "public"."your_matview" FROM "anon";

Unauthenticated callers can write. High.

A permissive INSERT/UPDATE/DELETE/ALL policy reachable without authentication whose check expression accepts any row, backed by a matching grant.

The “accepts any row” condition is essential and deliberately narrow. Supabase grants anon and authenticated full DML by default, so a policy targeting those roles is not by itself a problem — a textbook FOR INSERT TO public WITH CHECK (user_id = auth.uid()) is correct and is not reported.

A policy reaches an unauthenticated caller through TO public, through a role named anon or web_anon, or through a role that one of those is a member of: TO app_writer counts when anon inherits app_writer.

On Rebase there is no anon role. Every request runs as rebase_user, including a signed-out one when the API admits it (AUTH_REQUIRE=false, or auth.requireAuth: false) and every guest account from anonymous sign-in. The database cannot see that setting, so on Rebase a write that only rebase_user reaches is reported as heuristic. With the API gate on and guest sign-in off, only signed-in users reach the policy, and they can still write any row. Scope the collection’s write rule to the caller (an ownerField) or to roles.

Unqualified column inside a policy subquery. High, heuristic.

A bare column name inside an EXISTS/IN subquery that exists on both the inner relation and the policy’s own table. Postgres binds it to the inner table, so the correlation to the outer row you meant to write silently disappears and the predicate becomes trivially satisfiable — or trivially unsatisfiable, denying every row to everyone.

-- The bug: `id` binds to memberships, not organizations
USING (EXISTS (SELECT 1 FROM memberships WHERE id = organizations.id ...))
-- Qualify it
USING (EXISTS (SELECT 1 FROM memberships m WHERE m.org_id = organizations.id ...))

What the catalog shows is not what you wrote. pg_policies.qual is Postgres’s own re-rendering of the parse tree, and inside a subquery it always qualifies column references, so the bare name is never visible by the time the catalog is read. What survives is its effect: the bare org_id bound to the inner table, and the stored predicate compares that table’s column with itself — m.org_id = m.org_id. That self-comparison is what this check finds on a live database.

An absence of this finding is not proof of safety. A bare name compared with a different inner column (organization_id = id) is stored as m.organization_id = m.id, which reads exactly like a comparison somebody meant. When this check fires it is strong evidence; when it does not, it has proved nothing.

Many-to-many join table without RLS. High, heuristic.

A table that is essentially just the two endpoints of two foreign keys, both pointing at tables that do have RLS, with no row-level security of its own — and readable or writable by a role an untrusted caller arrives as. Both sides of the relation are locked and the edge between them is open — which is enough to enumerate the relation even when neither endpoint can be read.

Heuristic because a junction table is inferred from its shape. If yours is deliberately public, --skip junction-table-unprotected.

RLS enabled but not forced for the table owner. Medium, high, or critical.

Without FORCE, the table’s owner is exempt from its own policies, and so is every member of the owning role. That is harmless when the owner is a provisioning role nothing connects as, and serious when your application connects as the owner — so this is critical when a role an untrusted caller arrives as owns the table or is a member of the owning role, high when the owning role can log in or a role that can is a member of it, and medium otherwise.

If the owner is a superuser or has BYPASSRLS, it stays medium and says so: FORCE cannot constrain such a role, and implying otherwise would be misleading.

ALTER TABLE "public"."your_table" FORCE ROW LEVEL SECURITY;

RLS enabled with no policies. Medium.

Not a security hole — the opposite. RLS on with zero policies denies every row to everyone but the owner. It is reported because it is an invisible failure: the API returns [], and an empty table is indistinguishable from a filtered one. This configuration has silently served empty collections in production for weeks at a time.

Policies target roles nothing connects as. Medium.

Every policy on the table names roles that do not exist, cannot log in, and that no login role inherits transitively. The policies look correct and apply to nobody, so the table reads as empty.

The classic case is policies written TO authenticated — a Supabase role name — on a database whose requests actually arrive as some other role.

Table privileges granted to PUBLIC. Medium.

A DML privilege granted to PUBLIC, on a table or a foreign table. Even with RLS enabled this widens who policies get evaluated for, and it is almost never deliberate.

REVOKE ALL ON "public"."your_table" FROM PUBLIC;

SECURITY DEFINER routine with a mutable search_path. Medium.

The routine executes as its owner — often a superuser — while the caller controls how its identifiers resolve. That is the standard privilege-escalation shape, and anything the routine touches is read with the owner’s rights, bypassing RLS.

ALTER FUNCTION "public"."your_function"() SET search_path = pg_catalog, public;

Policy calls current_setting() without missing_ok. Low, heuristic.

current_setting('app.tenant_id') with a single argument raises when the setting is unset, rather than returning NULL. So instead of denying the row, the request errors — the caller sees a 500 rather than an empty result, and middleware that retries 5xx will retry a request that can never succeed.

ALTER POLICY "your_policy" ON "public"."your_table"
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

Being clear about the limits is the point — a security tool that overstates its coverage is worse than none.

  • It is a static catalog audit. It reads pg_class, pg_policies, pg_depend and friends. It does not connect as your anon role and try to read your data, so it cannot confirm that an exposure is reachable through your API.
  • It cannot prove a policy is correct. It finds shapes that are known to be wrong. A policy that passes every check here can still express the wrong business rule.
  • A clean report is not a security certification. In particular, see the note on unqualified-column-in-subquery: Postgres rewrites policy expressions, so some bugs are no longer visible in the catalog at all.
  • It does not check application-level authorization, API keys, network exposure, secrets handling, or anything outside the database.
  • Security Rules (RLS) — defining row-level security in Rebase collections, which compiles to the policies this tool audits.
  • Backend only — why a table with no policy is not served at all in a headless project.
  • REST API — the surface a permissive policy exposes.