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.
- 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.
Table exposed without row-level security
rls-disabledA 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-trueA 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-tautologyA 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-rlsA 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-rlsA 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-allowedA permissive INSERT/UPDATE/DELETE policy reachable without authentication whose check expression accepts any row, backed by a matching grant.
So behebst du das →Policy admits every signed-in caller to every row
policy-authenticated-tautologyA 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-subqueryA 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-unprotectedA 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 →RLS enabled but not forced for the table owner
rls-enabled-not-forcedA 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-policiesA table with row-level security enabled but not a single policy defined.
So behebst du das →Policies target roles nothing connects as
policy-role-unreachableEvery 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-publicA 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-pathA 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-throwsA 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.
$ 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
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.