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
--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.

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

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.

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.

The result is served at GET /api/admin/rls-audit, admin-gated like every other admin surface, 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.

@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 re-applies them on every boot: it drops each generated policy and creates it again 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 table with RLS off but no grant to an exposed role is not reported. 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. 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.

If a RESTRICTIVE policy covers the same command, this is downgraded to medium and reported as something to verify rather than a certainty, because restrictive policies AND after the permissive ones OR.

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.
  • 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.
-- 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.

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.

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.

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.

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.

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 ...))

An absence of this finding is not proof of safety. pg_policies.qual is Postgres’s own re-rendering of the parse tree, and it usually re-qualifies column references — so the original bare name is frequently no longer visible by the time the catalog is read. When this check does fire 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. 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, or high.

Without FORCE, the table’s owner is exempt from its own policies. 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 high when the owning role can log in, 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. 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.