Free tool · no account

Audit row-level security on any Postgres

Fifteen checks for the failures that make a Postgres database leak in practice — not the ones that are easy to check for. Works on Supabase, Neon, RDS, Cloud SQL, or a container on your laptop. One command, nothing to install, nothing to sign up for.

~npx @rebasepro/rls-check
Read-only
SELECTs against the system catalogues and nothing else. It never reads your data.
Nothing leaves the machine
No telemetry, no upload, no network beyond the connection string you pass it.
No Rebase required
It reads the database, not your codebase. It needs no Rebase and asks about none.

The fifteen checks

Each finding the tool prints carries the id below, the reason it matters, and the SQL that fixes it. Severity is the worst level a check can report — several grade themselves down depending on what they find.

Critical6 checks

Table exposed without row-level security

rls-disabled

A table with RLS disabled that also grants SELECT/INSERT/UPDATE/DELETE to a role an unauthenticated or untrusted caller can reach.

How to fix it →

Policy grants unconditional access

policy-always-true

A permissive policy whose USING or WITH CHECK expression is always true.

How to fix it →

Policy only checks that a caller id exists

policy-anonymous-tautology

A policy whose expression is `auth.uid() IS NOT NULL`-shaped: it separates signed-in from signed-out callers but scopes no rows.

How to fix it →

View reads past its base table's RLS

view-bypasses-rls

A view granted to an untrusted role that selects from an RLS-protected table and runs with its owner's privileges instead of the caller's.

How to fix it →

Materialized view exposes RLS-protected data

matview-bypasses-rls

A materialized view granted to an untrusted role whose defining query reads a table with row-level security enabled.

How to fix it →

Unauthenticated callers can write

anonymous-write-allowed

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

How to fix it →
High3 checks

Policy admits every signed-in caller to every row

policy-authenticated-tautology

A policy whose expression is only "the caller is signed in and not anonymous": it excludes signed-out callers correctly and scopes no rows between accounts.

How to fix it →

Unqualified column inside a policy subquery

unqualified-column-in-subquery

A bare column name in an EXISTS/IN subquery that exists on both the inner relation and the policy's own table, so Postgres binds it to the inner one.

How to fix it →

Many-to-many join table without RLS

junction-table-unprotected

A table that is essentially just two foreign keys, both pointing at RLS-protected tables, that has no row-level security of its own.

How to fix it →
Medium6 checks

RLS enabled but not forced for the table owner

rls-enabled-not-forced

A table with RLS enabled where the owning role is exempt from its own policies.

How to fix it →

RLS enabled with no policies (denies everything)

rls-enabled-no-policies

A table with row-level security enabled but not a single policy defined.

How to fix it →

Policies target roles nothing connects as

policy-role-unreachable

Every policy on a table names roles that do not exist, cannot log in, and that no login role inherits — so no policy ever applies and the table reads as empty.

How to fix it →

Table privileges granted to PUBLIC

grant-to-public

A SELECT/INSERT/UPDATE/DELETE privilege granted to PUBLIC on a table.

How to fix it →

SECURITY DEFINER routine with a mutable search_path

security-definer-mutable-search-path

A SECURITY DEFINER function or procedure that does not pin search_path, so the caller controls how its identifiers resolve.

How to fix it →

Policy calls current_setting() without missing_ok

current-setting-throws

A policy expression calling current_setting('x') with one argument, which raises rather than returning NULL when the setting is unset.

How to fix it →

What a run looks like

Every finding names the relation, says who can reach it and what they would get, and prints the SQL that closes it. Exits non-zero above a severity you choose, so it belongs in CI.

rls-check · production
$ npx @rebasepro/rls-check --role app_reader

rls-check 0.19.1  ·  read-only Row-Level Security audit
────────────────────────────────────────────────────────────────────────────────────────

Database  db.acme.internal:5432/production
Server    PostgreSQL 16.4
Platform  PostgreSQL (no platform markers)
Exposed   PUBLIC, app_reader (add yours with --role)
Scanned   1 schema · 3 tables · 2 policies · 15 checks

Note  This scan connected as a role that row-level security cannot constrain — a
      superuser, a table owner, or a role with BYPASSRLS. That is why it can read the
      true catalog, and it is also why nothing below describes what this connection
      experiences. The findings are about what OTHER roles get.

CRITICAL  ·  3 findings
────────────────────────────────────────────────────────────────────────────────────────

  [critical] view-bypasses-rls  public.billing_overview
      View public.billing_overview reads public.ledger without security_invoker and is
      readable by app_reader

      The view is owned by postgres and `security_invoker` is not set, so its query
      executes with postgres's privileges rather than the caller's. Row-level security
      on public.ledger is evaluated for postgres, not for the role that selected from
      the view.
      Impact  If this view is reachable over an API as app_reader, a caller reads rows
              from public.ledger that the policies on that table were written to
              withhold — the view is an unfiltered path around them.
      Fix
          ALTER VIEW "public"."billing_overview" SET (security_invoker = true);
          -- Callers then need their own SELECT privilege on public.ledger, and the
          -- policies there apply to them.
      Docs    https://rebase.pro/docs/rls-check#view-bypasses-rls

  [critical] policy-always-true  public.documents · policy "documents_read"
      Policy "documents_read" on public.documents is USING (true) for app_reader

      This permissive SELECT policy's USING expression is a constant truth, so it
      matches every row for app_reader. Permissive policies are ORed together, so this
      one alone satisfies the table's row filter no matter how strict the others are.
      Impact  If this table is reachable over an API as app_reader, a caller can read
              every row — the policy applies no scoping whatsoever.
      Fix
          -- Replace the constant with the scoping you intended, e.g.:
          ALTER POLICY "documents_read" ON "public"."documents"
              USING (user_id = auth.uid());
          -- or, if unconditional access really is intended, drop the policy and say so
          -- with an explicit grant instead:
          -- DROP POLICY "documents_read" ON "public"."documents";
      Docs    https://rebase.pro/docs/rls-check#policy-always-true

  [critical] rls-disabled  public.invoices
      public.invoices has row-level security disabled and is granted to app_reader

      Row-level security is not enabled on this table, so Postgres applies no per-row
      filter at all — policies, if any exist, are never consulted. app_reader holds
      DELETE, INSERT, SELECT and UPDATE on it.
      Impact  If this table is reachable over an API that connects as app_reader, a
              caller can read every row and delete, insert and update any row, with no
              tenant or owner scoping.
      Fix
          ALTER TABLE "public"."invoices" 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, for example:
          -- CREATE POLICY "invoices_owner_select" ON "public"."invoices"
          --     FOR SELECT TO "app_reader" USING (user_id = auth.uid());
      Docs    https://rebase.pro/docs/rls-check#rls-disabled

────────────────────────────────────────────────────────────────────────────────────────
Summary

  critical 3   high 0   medium 0   low 0   info 0
  3 confirmed · 0 worth checking · 15 checks run against 3 tables in 1 schema
  1 of 3 tables have row-level security disabled

  Exit code 1 — at least one finding is "high" or worse (--fail-on high).
  Scanned 2026-09-05T09:14:02.881Z · read-only, and nothing left this machine.

rls-check is free and maintained by the team behind Rebase — https://rebase.pro
Questions

Questions about rls-check

Then fix it once, in code.

A scanner tells you where the policies are wrong. Rebase compiles them from your collection definitions, so the next one is reviewed in a pull request before it ever reaches a database.

~pnpm dlx @rebasepro/cli init