Sicherheit9 Min. Lesezeit

Die Supabase-RLS-Checkliste für KI-Apps

Was Row Level Security in Supabase ist, wie Sie sie einschalten, welche Policies die meisten Apps brauchen und was wir vor jedem Launch an jeder Tabelle prüfen.

  • 19. Aug. 2026
Die Supabase-RLS-Checkliste für KI-Apps

Mit Supabase spricht der Browser direkt mit Ihrer Datenbank. Deshalb greifen KI-Builder wie Lovable und Bolt darauf zurück, und deshalb zählt Row Level Security bei Supabase mehr als fast überall sonst. Der Browser hält einen Schlüssel, also muss die Datenbank selbst diejenige sein, die Nein sagt.

Das ist die Checkliste, die wir vor jedem Launch eines Supabase-Projekts durchgehen, und das Erste, was wir bei einer Rettung öffnen. Sie erklärt, was RLS ist, wie Sie sie einschalten, welche Policies die meisten Apps brauchen, auf welchen Wegen Daten trotzdem abfließen und wie Sie prüfen, dass alles hält.

Was Row Level Security ist

Row Level Security (RLS) ist eine Funktion von Postgres. Ist sie aktiv, wird jede Abfrage auf eine Tabelle durch Regeln gefiltert, die Policies heißen, und eine Zeile kommt nur zurück, wenn eine Policy es erlaubt. Stellen Sie sich eine Policy als WHERE-Klausel vor, die die Datenbank jeder Abfrage anhängt, egal wer sie schickt.

Supabase stellt das Schema public über eine API bereit, die jeder aufrufen kann, der die Projekt-URL und den anon-Schlüssel kennt. Beides steht in Ihrem Frontend-Code und ist damit öffentlich. Ohne RLS liefert diese API jede Zeile jeder Tabelle an jeden, der fragt.

Schritt 1. Für jede Tabelle einschalten

Tabellen, die im Tabelleneditor von Supabase angelegt werden, haben RLS meist aktiv. Tabellen aus SQL, aus einer Migration oder von einem KI-Tool oft nicht. Prüfen Sie alle, auch die von letzter Woche.

-- Which tables in the public schema have RLS off?
select tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

-- Switch it on
alter table public.profiles enable row level security;

Eine Tabelle mit aktiver RLS und ohne Policy liefert den Rollen anon und authenticated gar nichts. Das ist der sichere Ausgangspunkt. Jetzt geben Sie jeder Tabelle genau den Zugriff zurück, den sie braucht.

Schritt 2. Eine Policy pro Aktion

Schreiben Sie getrennte Policies für select, insert, update und delete statt einer für alles. Das liest sich leichter, und es fällt eher auf, wenn eine fehlt. Das häufigste Muster ist ein Nutzer, der nur seine eigenen Zeilen sieht und ändert.

create policy "Users read their own rows"
on public.projects for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users create rows for themselves"
on public.projects for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Users update their own rows"
on public.projects for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

using legt fest, welche bestehenden Zeilen eine Abfrage sehen darf. with check legt fest, welche neuen Werte sie schreiben darf. Eine Update-Policy braucht beides, sonst kann ein Nutzer seine Zeile jemand anderem zuschieben, indem er user_id ändert. Steht auth.uid() in einem select, berechnet Postgres den Wert einmal pro Abfrage statt einmal pro Zeile, was bei großen Tabellen zählt.

Schritt 3. Teams und öffentliche Inhalte

Teamzugriff läuft meist über eine Tabelle mit Mitgliedschaften. Die Policy fragt, ob der angemeldete Nutzer zu dem Team gehört, dem die Zeile gehört.

create policy "Members read their team's rows"
on public.documents for select
to authenticated
using (
  team_id in (
    select team_id from public.memberships
    where user_id = (select auth.uid())
  )
);

Auch die Tabelle memberships braucht eine eigene RLS, und eine Policy auf memberships, die memberships abfragt, läuft in eine Rekursion. Die übliche Lösung ist eine kleine security definer Funktion, die die Teams des aktuellen Nutzers zurückgibt und von beiden Policies genutzt wird. Inhalte, die jeder lesen darf, etwa veröffentlichte Beiträge, bekommen eine Policy für die Rolle anon, die auf eine Spalte filtert, die nur der Server setzen kann.

create policy "Anyone reads published posts"
on public.posts for select
to anon, authenticated
using ( published = true );

Schritt 4. Die Wege drumherum schließen

Die meisten Lecks, die wir finden, sind keine fehlende Policy. Es ist ein Weg, der die Policies ganz umgeht.

  • Der service role Schlüssel umgeht RLS vollständig. Er gehört auf den Server und nirgendwo sonst hin. In einer Vite-App landet jede Variable, die mit VITE_ beginnt, im Browser-Bundle, also durchsuchen Sie das gebaute JavaScript nach dem Schlüssel.
  • Views laufen mit den Rechten ihres Eigentümers, meist postgres, und ignorieren damit die RLS der Tabellen darunter. Ab Postgres 15 legen Sie sie mit security_invoker = true an.
  • Security definer Funktionen laufen mit den Rechten ihres Eigentümers und sind über die API als RPC aufrufbar. Geben Sie ihnen einen festen search_path, prüfen Sie auth.uid() darin und entziehen Sie anon das execute-Recht, wenn Gäste sie nie aufrufen sollen.
  • Nutzermetadaten kann der Nutzer selbst ändern. Eine Policy, die einer Rolle in user_metadata vertraut, lässt jeden zum Admin werden. Halten Sie Rollen in app_metadata oder in einer Tabelle, in die der Client nicht schreiben kann.
  • Storage-Buckets haben eigene Policies auf storage.objects. Ein öffentlicher Bucket liefert jede Datei an jeden, der den Link hat.

Schritt 5. Testen wie ein Angreifer

Policies zu lesen reicht nicht. Fragen Sie die API nach Daten, die Sie nicht bekommen dürften. Nur mit dem anon-Schlüssel, der ohnehin in Ihrem Frontend steht, fragen Sie direkt eine Tabelle ab.

curl 'https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*' \
  -H "apikey: YOUR_ANON_KEY"

Kommen dabei Zeilen zurück, die nicht öffentlich sein sollen, ist die Tabelle offen. Melden Sie sich danach als ein Nutzer an und fragen Sie per ID die Zeile eines anderen ab. Sie sollten ein leeres Ergebnis bekommen, keinen Fehler und nicht die Zeile. Im SQL-Editor können Sie außerdem innerhalb einer Transaktion als ein bestimmter Nutzer auftreten.

begin;
set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"USER-UUID","role":"authenticated"}', true);
select * from public.projects;  -- only this user's rows
rollback;

Auch der Security Advisor im Supabase-Dashboard markiert Tabellen im Schema public ohne RLS. Das ist ein guter Alarm. Ob Ihre Policies richtig sind, sagt er Ihnen aber nicht.

Die Checkliste

  • RLS ist auf jeder Tabelle im Schema public aktiv
  • Jede Tabelle hat eine ausdrückliche Policy für jede Aktion, die sie erlaubt
  • Policies vergleichen mit auth.uid(), nie mit einer Spalte oder Metadaten, die der Client setzen kann
  • Update-Policies haben using und with check
  • Der service role Schlüssel steht nur im Servercode
  • Views nutzen security_invoker, und security definer Funktionen prüfen, wer sie aufruft
  • Storage-Buckets haben Policies, und öffentliche Buckets enthalten nichts Privates
  • Ein angemeldeter Nutzer kann nicht die Zeile eines anderen lesen, indem er eine ID ändert

Diese Liste gehen wir bei einer Rettung vor allem anderen durch, denn eine einzige fehlende Policy bedeutet meist, dass jeder Datensatz in dieser Tabelle für jeden lesbar war, der nachgesehen hat. Es ist das günstigste Audit in der Softwareentwicklung und das teuerste, wenn man es auslässt.

Lassen Sie uns losbauen!