TL;DR
Unten stehen die RLS-Policies, die die meisten Supabase-Apps brauchen, jede über die REST-API ausgeführt als Gast, als Besitzer der Zeile und als anderer angemeldeter Nutzer. Drei Ergebnisse sollten Sie kennen, bevor Sie etwas kopieren: Ein Update auf die Zeile eines anderen liefert 200 mit leerer Liste statt eines Fehlers; eine harmlos wirkende Insert-Policy auf einer Mitgliedschaftstabelle ließ einen Nutzer einer fremden Firma beitreten und deren Projekte lesen; und (select auth.uid()) statt auth.uid() brachte eine Abfrage auf 200.000 Zeilen von 156 ms auf 7,4 ms.
Ich bin Denys, Full-Stack- und KI-Entwickler, und die meisten Apps, die ich baue oder repariere, laufen auf Supabase. Snippets für eine Supabase RLS Policy gibt es überall; was sie tun, wenn eine echte Anfrage auf sie trifft, wird selten gezeigt. Also habe ich sie laufen lassen.
So habe ich getestet. Die meisten Anfragen liefen über einen lokalen Supabase-Stack (CLI 2.109.1, Postgres 17.6, PostgREST 14.14, Auth 2.192.0) mit dem Publishable Key und zwei echten angemeldeten Nutzern, A und B. Fälle ohne HTTP, etwa Syntaxfehler und Zeitmessungen, liefen auf einem einfachen lokalen Postgres 17.10 mit den Supabase-Rollen anon und authenticated und nachgebautem auth.uid(). Alles am 24. Oktober 2026, mit ausgedachten Zeilen.
Vor jeder Policy: Hat die Tabelle ein Grant?
Eine Policy entscheidet, welche Zeilen eine Rolle sieht. Ein Grant entscheidet, ob die Rolle die Tabelle überhaupt erreicht, und bei aktuellen Supabase-Projekten bekommen neue Tabellen nicht mehr automatisch Data-API-Grants (ab dem 30. Oktober 2026 für jedes Projekt). Auf meinem Stack antwortete eine Tabelle mit eingeschaltetem RLS und ohne Grant so:
| Anfrage | Status | Body |
|---|---|---|
Gast GET /invoices |
401 |
permission denied for table invoices, mit dem Hinweis GRANT SELECT ... TO anon |
Nutzer A GET /invoices |
403 |
dasselbe, mit dem Hinweis für authenticated |
Schritt eins für jede Tabelle, die die App im Browser liest:
alter table public.todos enable row level security;
grant select, insert, update, delete on public.todos to authenticated;
Vergeben Sie nur die Befehle, die der Browser braucht, und nur an authenticated, es sei denn, Gäste müssen die Tabelle lesen.
Welche Supabase RLS Policy zeigt Nutzern nur ihre eigenen Zeilen?
Eine Policy pro Befehl, jede auf angemeldete Nutzer beschränkt:
create policy "read own" on public.todos
for select to authenticated using ((select auth.uid()) = user_id);
create policy "add own" on public.todos
for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "edit own" on public.todos
for update to authenticated
using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
create policy "delete own" on public.todos
for delete to authenticated using ((select auth.uid()) = user_id);
A besitzt zwei Zeilen, B eine. Das kam zurück:
| Anfrage von | Was | Status | Body |
|---|---|---|---|
| Gast | alles lesen | 401 |
permission denied (kein Grant für anon) |
| A | alles lesen | 200 |
die 2 Zeilen von A |
| A | Zeile einfügen, die B gehört | 403 |
new row violates row-level security policy |
| A | Zeile von B ändern | 200 |
[] |
| A | Zeile von B löschen | 200 |
[] |
| A | Besitzer der eigenen Zeile auf B ändern | 403 |
new row violates row-level security policy |
Merken Sie sich die beiden Zeilen mit 200 []. RLS lehnt ein Update oder Delete auf Zeilen, die Sie nicht sehen, nicht ab; es filtert sie heraus, und null geänderte Zeilen gelten als Erfolg. Wenn Ihre App “kein Fehler” mit “gespeichert” gleichsetzt, sagt sie dem Nutzer, seine Änderung habe geklappt, obwohl nichts passiert ist. Fordern Sie die geänderten Zeilen an (.select() in supabase-js, Prefer: return=representation über HTTP) und prüfen Sie, dass eine zurückkam.
Warum braucht eine Update-Policy WITH CHECK?
using wählt, welche bestehenden Zeilen ein Nutzer anfassen darf; with check entscheidet, wie diese Zeilen danach aussehen dürfen. Ich habe drei Varianten derselben Update-Policy verglichen:
| Update-Policy | A gibt eine eigene Zeile an B ab |
|---|---|
using (uid = user_id) with check (uid = user_id) |
403, abgelehnt |
using (uid = user_id), ohne with check |
abgelehnt (Postgres verwendet using als Prüfung) |
using (uid = user_id) with check (true) |
200, Zeile gehört jetzt B |
Ein weggelassenes with check ist sicherer, als es aussieht, weil Postgres auf den using-Ausdruck zurückfällt. with check (true), das manche Generatoren schreiben, um “Updates zu erlauben”, ist genau das, was einen Nutzer eine Zeile in ein fremdes Konto verschieben lässt. Schreiben Sie die Prüfung vollständig aus.
Wie machen Sie Profile öffentlich, aber nur vom Besitzer änderbar?
create policy "public read" on public.profiles
for select to anon, authenticated using (true);
create policy "own edit" on public.profiles
for update to authenticated
using ((select auth.uid()) = id) with check ((select auth.uid()) = id);
Im Lauf auf dem einfachen Postgres: Der Gast las beide Profile, das Update des Gasts änderte 0 Zeilen, und update profiles set display_name = 'x' von A ohne Filter änderte genau 1 Zeile, nämlich die eigene von A. using (true) ist bei einem Select auf Daten, die öffentlich sein sollen, in Ordnung. Auf jeder anderen Tabelle entspricht es keinem RLS, und eine Policy ohne to-Klausel gilt auch für Gäste: Mein Gast las die “private” Zeile über eine using (true)-Policy ohne to.
Wie teilen Sie Zeilen innerhalb eines Teams?
Die übliche Form: eine Tabelle memberships und eine Policy auf den Daten, die die Teams eines Nutzers dort nachschlägt.
create policy "members read" on public.projects
for select to authenticated
using (org_id in (select org_id from public.memberships
where user_id = (select auth.uid())));
Diese Policy ist in Ordnung. Das Leck lag daneben. Ich gab memberships die Policy, die viele Tutorials vorschlagen, “Nutzer können ihre eigene Mitgliedschaft anlegen”:
create policy "join any org" on public.memberships
for insert to authenticated with check (user_id = (select auth.uid()));
Sie prüft, für wen die Zeile ist, nicht für welches Team. A, Mitglied von Firma 1, schickte eine Anfrage:
| Anfrage von A | Status | Body |
|---|---|---|
| Projekte lesen | 200 |
A roadmap |
Mitgliedschaft einfügen {user_id: A, org_id: 2} |
201 |
angelegt |
| Projekte erneut lesen | 200 |
A roadmap, B payroll |
Die Lösung: memberships gar keine Insert-Policy geben, sodass der Browser nicht hineinschreiben kann (im Postgres-Lauf wurde derselbe Insert dann mit 42501 abgelehnt), und Mitglieder über einen Einladungsablauf hinzufügen, der auf dem Server oder in einer Funktion läuft, die die Einladung prüft. Ein Nutzer in zwei Firmen ist die nächste Falle; die habe ich in Supabase Multi-Tenant-RLS: Nutzer in zwei Orgs, Datenleck beschrieben.
Was, wenn eine Policy ihre eigene Tabelle lesen muss?
“Mitglieder sehen ihre Teamkollegen” bedeutet eine Policy auf memberships, die memberships liest. Postgres lehnt das zur Abfragezeit ab:
ERROR 42P17: infinite recursion detected in policy for relation "members2"
Verlagern Sie die Abfrage in eine Funktion, die als ihr Besitzer läuft, in einem Schema, das die API nicht freigibt, mit festem search_path:
create schema private;
create function private.user_org_ids() returns setof int
language sql stable security definer set search_path = ''
as $$ select org_id from public.members2 where user_id = (select auth.uid()) $$;
revoke all on function private.user_org_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_org_ids() to authenticated;
create policy "see teammates" on public.members2
for select to authenticated using (org_id in (select private.user_org_ids()));
Danach sah A beide Mitglieder von Firma 1 und B nur sich selbst. Halten Sie die Funktion aus public heraus: Alles dort ist über die API aufrufbar.
Wie verlangen Sie MFA für sensible Zeilen?
Fügen Sie über der normalen eine restriktive Policy hinzu. Restriktive Policies werden mit and verknüpft, beide müssen also bestehen:
create policy "mfa only" on public.payouts as restrictive
for select to authenticated using ((select auth.jwt() ->> 'aal') = 'aal2');
Mit demselben Nutzer las eine Sitzung auf aal1 0 Auszahlungen und eine Sitzung auf aal2 1.
Welche Tabellen sollten RLS an und keine Policy haben?
Tabellen, die nur Ihr Server anfasst. RLS an ohne Policy verweigert den Browser-Rollen alles: Im Postgres-Lauf lasen der Gast und A beide 0 Zeilen, und der Insert von A wurde mit 42501 abgelehnt. RLS auszulassen ist das Gegenteil. Eine Tabelle mit RLS aus und Standard-Grants, gelesen mit dem Publishable Key und ohne Anmeldung:
| Anfrage vom Gast | Status | Body |
|---|---|---|
GET /leads |
200 |
beide Zeilen |
DELETE /leads?id=gt.0 |
200 |
beide Zeilen, gelöscht |
Darum geht es bei der Advisor-Warnung “RLS disabled in public”.
Kann eine Policy mehrere Befehle abdecken?
Nicht mit einer Liste. for update, delete ist ein Syntaxfehler (42601: syntax error at or near ","), auch wenn es noch in veröffentlichten Snippet-Sammlungen steht. for all ist gültig und wendet ein using und ein with check auf jeden Befehl an, was selten gewollt ist. Auch eine Insert-Policy mit using scheitert: only WITH CHECK expression allowed for INSERT.
Machen Policies Abfragen langsamer?
Das können sie. Ein Policy-Ausdruck läuft für jede Zeile, die Postgres betrachtet. Ich habe select * from big als Nutzer A auf 200.000 Zeilen von 1.000 Nutzern gemessen, davon 200 von A (Median aus 7 Läufen, Postgres 17.10):
| Policy und Index | Median |
|---|---|
auth.uid() = user_id, ohne Index |
156,3 ms |
(select auth.uid()) = user_id, ohne Index |
7,4 ms |
auth.uid() = user_id, Index auf user_id |
0,21 ms |
(select auth.uid()) = user_id, Index |
0,05 ms |
Das Einpacken des Aufrufs in select lässt Postgres ihn einmal pro Abfrage statt einmal pro Zeile berechnen; den Rest erledigt der Index. Supabases RLS-Performance-Leitfaden nennt dieselbe Größenordnung auf einer eigenen Tabelle mit 100.000 Zeilen (179 ms auf 9 ms). Viele Blogposts zitieren diese Zahlen als eigene; die obigen stammen aus meinem Lauf.
Woher sollten Rollen kommen?
Aus app_metadata oder aus Ihrer eigenen Tabelle, nie aus user_metadata. Ein angemeldeter Nutzer kann sein eigenes user_metadata mit supabase.auth.updateUser() ändern, daher lässt eine Policy, die auth.jwt() -> 'user_metadata' ->> 'role' prüft, ihn sich die Rolle selbst geben. Ein Registrierungs-Trigger, der eine Rolle aus den Registrierungs-Metadaten kopiert, tut dasselbe; dieser Fall steht in Supabase raw_user_meta_data: Registrierung wurde Owner.
Wie prüfen Sie Ihre eigenen Policies?
- Der kostenlose Supabase-Sicherheitscheck liest Ihr Projekt nur lesend und meldet Policies, die immer wahr sind, Schreib-Policies für Gäste, Policies, die Nutzer-Metadaten vertrauen, und Tabellen mit RLS aus, jeweils mit Korrektur-SQL.
- Kein Scanner weiß, welche Zeilen wem gehören. Dafür führen Sie Anfragen als zwei Nutzer aus, wie in diesem Artikel; die vollständige Methode für eine App mit Rollen steht in Supabase RLS testen.
- Was Supabase für Sie absichert und was bei Ihnen bleibt, steht in Wie sicher ist Supabase?.
- Dieselben Besitzer- und Team-Tests, auch auf Appwrite ausgeführt, stehen in Appwrite vs Supabase Sicherheit: dieselben Angriffe.
Grenzen. Ein lokaler Stack und ein Laptop, ausgedachte Daten, 24. Oktober 2026. Zeiten hängen von Hardware und Tabellenform ab; die Verhältnisse zählen mehr als die Millisekunden. Storage-Policies und Realtime sind hier nicht abgedeckt.
Unsicher, ob Ihre Policies halten?
Gern schaue ich mit Ihnen gemeinsam auf ein Schema. Ohne Verkaufsgespräch.
Kontakt aufnehmen