Kostenloses Tool · kein Account

Row-Level Security auf jedem Postgres prüfen

Fünfzehn Checks für die Fehler, an denen eine Postgres-Datenbank in der Praxis leckt — nicht für die, die leicht zu prüfen sind. Läuft gegen Supabase, Neon, RDS, Cloud SQL oder einen Container auf deinem Laptop. Ein Befehl, nichts zu installieren, nichts zu registrieren.

~npx @rebasepro/rls-check
Read-only
SELECTs auf die Systemkataloge und sonst nichts. Deine Daten liest es nie.
Nichts verlässt die Maschine
Keine Telemetrie, kein Upload, kein Netzwerk jenseits des Connection-Strings, den du übergibst.
Rebase wird nicht gebraucht
Es liest die Datenbank, nicht deinen Code. Es braucht kein Rebase und fragt auch nicht danach.

Die fünfzehn Checks

Jeder Befund, den das Tool ausgibt, trägt die id unten, den Grund, warum er zählt, und das SQL, das ihn behebt. Die Severity ist die schlimmste Stufe, die ein Check melden kann — mehrere stufen sich je nach Fund herunter.

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.

So behebst du das →

Policy grants unconditional access

policy-always-true

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

So behebst du das →

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.

So behebst du das →

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.

So behebst du das →

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.

So behebst du das →

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.

So behebst du das →
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.

So behebst du das →

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.

So behebst du das →

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.

So behebst du das →
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.

So behebst du das →

RLS enabled with no policies (denies everything)

rls-enabled-no-policies

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

So behebst du das →

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.

So behebst du das →

Table privileges granted to PUBLIC

grant-to-public

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

So behebst du das →

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.

So behebst du das →

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.

So behebst du das →

Wie ein Lauf aussieht

Jeder Befund nennt die Relation, sagt, wer sie erreichen kann und was er bekäme, und druckt das SQL, das sie schließt. Oberhalb einer Severity deiner Wahl endet es mit einem Exit-Code ungleich null — es gehört also in die CI.

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

rls-check 0.24.0  ·  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
Fragen

Questions about rls-check

Ist die Ausführung auf einer Produktionsdatenbank sicher?

Es ist rein lesend – und das sehr strikt. Es führt ausschließlich SELECT-Abfragen auf Systemkatalogen aus — pg_class, pg_policies, pg_proc, information_schema — und sonst nichts. Es liest niemals deine Daten, schreibt nichts, ändert keine Einstellungen und sendet keine Netzwerkanfragen über den übergebenen Connection-String hinaus. Es gibt weder Telemetrie noch Uploads.

Muss ich Rebase verwenden?

Nein. Es weiß nichts über dein Framework und verlangt nichts von deiner Codebase — es liest die Datenbank. Es funktioniert mit Supabase, Neon, RDS, Cloud SQL, einem Container auf deinem Laptop oder allem anderen, was das Postgres-Wire-Protokoll spricht. Es benötigt kein Rebase und fragt nicht danach.

Als welche Rolle muss die Verbindung aufgebaut werden?

Als eine Rolle, die die Kataloge lesen kann — üblicherweise ein Superuser, der Table Owner oder eine Rolle mit BYPASSRLS. Das ist Absicht und wird im Bericht ganz oben ausgewiesen: Der Scan liest den tatsächlichen Katalog, gerade weil Row-Level Security ihn nicht einschränken kann, sodass die Befunde beschreiben, was andere Rollen erleben, nicht was diese Verbindung sieht.

Wie unterscheidet sich das vom Linter von Supabase?

Bei den offensichtlichen Fällen gibt es echte Überschneidungen, und es lohnt sich, beide auszuführen. Die Prüfungen hier, die anderswo meist fehlen, sind die indirekten Fälle: eine View oder Materialized View, die an der RLS ihrer Basistabelle vorbeiliest, weil sie als ihr Owner läuft; eine ungeschützte Many-to-Many-Join-Tabelle zwischen zwei geschützten Endpunkten; und eine einfache Spalte innerhalb einer EXISTS-Subquery, die Postgres an die innere Tabelle bindet — was einen Mandantenfilter zu einer Tautologie macht, obwohl er völlig korrekt aussieht.

Kann ich es in der CI ausführen?

Ja — genau dafür ist es gedacht. Es beendet sich mit einem Non-Zero-Exit-Code, wenn Befunde auf oder über einem gewählten Schwellenwert vorliegen, gibt maschinenlesbares JSON aus und unterstützt das Überspringen einzelner Checks via id, damit eine bewusste Ausnahme nicht jeden Build fehlschlagen lässt. Die ids sind genau aus diesem Grund eine stabile öffentliche API.

Warum verschenkt ein Backend-Unternehmen einen Security-Scanner?

Weil Rebase argumentiert, dass Autorisierung in die Datenbank gehört und nicht in die Schicht davor, und ein Tool, das Postgres RLS auditiert, genau dieses Argument in einer Form liefert, die du gegen deine eigenen Daten ausführen kannst, statt es nur zu lesen. Es ist wirklich nützlich, ganz egal, ob du dir jemals den Rest dieser Website ansiehst — und wenn es nichts findet, ist das ein guter Nachmittag und du schuldest uns nichts.

Und dann behebst du es einmal, im Code.

Ein Scanner sagt dir, wo die Policies falsch sind. Rebase kompiliert sie aus deinen Collection-Definitionen, sodass die nächste in einem Pull Request geprüft wird, bevor sie je eine Datenbank erreicht.

~pnpm dlx @rebasepro/cli init