Was eigentlich schiefgelaufen ist
Supabase stellt deine Postgres-Tabellen über eine automatisch generierte REST-API bereit. Dein Frontend spricht mit dieser API über den Anon-Key (auch Publishable Key genannt). Dieser Key ist dafür gedacht, öffentlich zu sein. Er wird in deinem JavaScript-Bundle ausgeliefert, und jeder kann ihn in den DevTools lesen.
Dieses Design ist in Ordnung, solange auf jeder Tabelle Row Level Security (RLS) mit sinnvollen Policies aktiviert ist. RLS entscheidet, welche Zeilen eine bestimmte Anfrage sehen darf. Wie TheCyberSecGuru anmerkt, mit einem Zitat aus der Supabase-Dokumentation selbst: „Ohne RLS kann jeder mit deinem Anon-Key auf alle Daten zugreifen.“
Die Fehlerkette sieht also so aus:
- Ein KI-Tool (oder ein müder Mensch) generiert create table profiles (...).
- Niemand ergänzt enable row level security.
- Die App funktioniert im Test einwandfrei, denn der Bug besteht ja gerade darin, dass sie zu viel erlaubt.
- Jeder, der den Anon-Key aus deinem Bundle kopiert, kann select * auf diese Tabelle ausführen.
Wenn du Hintergrundwissen zur Funktionsweise von RLS willst, erklärt unser Guide zu Supabase Row Level Security die Grundlagen. Im Folgenden konzentrieren wir uns aufs Testen und Beheben.
So prüfst du deine App (5 Minuten)
1. Schau sie dir von außen an, so wie die Forscher. Öffne deine Live-Website, öffne die DevTools und durchsuche das geladene JavaScript nach supabase.co. Dort findest du deine Projekt-URL und direkt daneben den Anon-Key. Frag dann im Terminal eine Zeile aus einer Tabelle ab, die du für privat hältst:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=1" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"Was dir die Antwort sagt:
- Ein leeres Array [] oder ein Berechtigungsfehler: gut, anonyme Nutzer können diese Tabelle nicht lesen.
- Echte Zeilen: Du hast dasselbe Problem wie diese 7.403 Websites.
Wiederhole das für jede Tabelle mit Nutzerdaten: profiles, users, messages, orders, bookings, leads, waitlist und so weiter. Wartelisten- und Kontaktformular-Tabellen vergisst man leicht, und sie enthalten fast immer E-Mail-Adressen.
Wenn du schon im Bundle bist, prüf auch, ob der gefundene Key der Anon-Key ist und nicht der service_role-Key. Die Service-Rolle umgeht RLS vollständig. Steckt sie in deinem Frontend, behandle sie als geleakt und rotiere sie sofort: Alles in deinem Bundle kann jeder Besucher und jedes Drittanbieter-Skript auf der Seite lesen, wie die Nutzer von Polymarket erfahren mussten, und Bots jagen bereits nach geleakten Keys. Unser Beitrag zum Rotieren der Secrets deiner App ohne Ausfallzeit erklärt die Rotation, und API-Keys im clientseitigen JavaScript zeigt, was sonst noch gern in Bundles landet.
2. Prüf von innen. Liste im Supabase-SQL-Editor jede Tabelle im public-Schema auf und ob RLS aktiv ist:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;Jede Zeile mit rowsecurity = false ist mit dem Anon-Key lesbar (und möglicherweise beschreibbar). Der Security Advisor im Supabase-Dashboard markiert solche Tabellen ebenfalls. Lies seine Warnungen, statt sie wegzuklicken.
3. Such nach „RLS an, aber trotzdem offen“. Aktiviertes RLS ist noch nicht die Ziellinie. Liste deine Policies auf:
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public';Achte auf qual = true bei einer select-Policy, die für anon oder public gilt. Das ist eine Policy, die sagt „alle dürfen alles lesen“, und damit dasselbe wie gar kein RLS. KI-Tools generieren manchmal genau das, um eine Fehlermeldung verschwinden zu lassen.
So behebst du es
Aktiviere RLS für jede Tabelle:
alter table public.profiles enable row level security;
alter table public.messages enable row level security;
-- repeat for every table in publicMit aktivem RLS und ohne Policies ist die Tabelle gesperrt: Der Anon-Key bekommt nichts zurück. Das ist ein sicherer Ausgangszustand. Füge jetzt nur den Zugriff hinzu, den du wirklich brauchst.
Schreib eng gefasste Policies. Ein typisches Setup nach dem Muster „Nutzer dürfen nur ihre eigene Zeile sehen und bearbeiten“:
create policy "Read own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Update own profile"
on public.profiles for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);Darauf solltest du achten:
- Beschränke Policies auf authenticated statt public, außer die Daten sind wirklich öffentlich (Blogbeiträge, Produktlisten).
- Trenne Lesen und Schreiben. Eine Tabelle kann gefahrlos öffentlich lesbar sein und trotzdem streng abgesicherte Inserts brauchen.
- Öffentliche Tabellen leaken trotzdem Spalten. Soll eine profiles-Tabelle für Anzeigenamen öffentlich sein, ist eine E-Mail- oder Telefonspalte in derselben Tabelle ebenfalls öffentlich. Verschiebe private Felder in eine separate Tabelle oder stelle eine View bereit, die nur die unbedenklichen Spalten enthält.
- Views können RLS umgehen. In Postgres laufen Views standardmäßig mit den Rechten ihres Besitzers. Erstelle Views mit security_invoker = true, damit das RLS des Aufrufers greift.
- Storage ist eine eigene Baustelle. Öffentliche Buckets liefern Dateien an jeden aus, der die URL kennt. Hochgeladene Ausweise, Rechnungen und Avatare privater Nutzer gehören in private Buckets mit Storage-Policies.
Mach es zum Standard, nicht zur Aufräumaktion. Die Stichprobe der Forscher umfasst zehn Monate an neuen Apps, das heißt: Das passiert bei jeder neuen Tabelle wieder. Sorg dafür, dass man es kaum vergessen kann:
- Ergänze die Projektanweisungen deines KI-Tools um eine Regel: Auf jedes create table in public müssen enable row level security und explizite Policies folgen.
- Prüf generierte Migrationen vor dem Merge auf create table ohne passendes enable row level security.
- Wiederhole den Test von außen nach jeder Schemaänderung, nicht nur beim Launch.
Wenn du Firebase statt Supabase nutzt, gibt es dort dieselbe Fehlerklasse in Form offener Security Rules. Siehe Firebase Security Rules: Lass deine Datenbank nicht mehr offen.
Wenn du offengelegte Daten gefunden hast
Eine korrigierte Policy verhindert künftige Lesezugriffe, macht vergangene aber nicht ungeschehen. Sei ehrlich zu dir selbst, was erreichbar war und wie lange. Prüf deine Supabase-API-Logs auf ungewöhnliche Massenabfragen von unbekannten IPs, rotiere alle Secrets, die in lesbaren Tabellen gespeichert waren, und kläre deine Meldepflichten, falls personenbezogene Daten wie E-Mail-Adressen, Ausweise oder Zahlungsdaten offenlagen. In der EU greifen dann wahrscheinlich die Meldevorschriften der DSGVO.
Kurze Checkliste
Bevor du einen Haken dahinter setzt, stell sicher, dass:
- jede Tabelle in public rowsecurity = true hat.
- keine select-Policy auf Tabellen mit privaten Daten using (true) verwendet.
- Policies auf authenticated beschränkt und an auth.uid() gebunden sind.
- private Spalten nicht in öffentlich lesbaren Tabellen liegen.
- Views security_invoker = true verwenden.
- nur der Anon-Key in deinem Frontend steckt, niemals service_role.
- private Dateien in privaten Storage-Buckets liegen.
- du nach jeder Schemaänderung erneut von außen testest.
Prüf deine App von außen
Unsere Sicherheits-Checkliste für KI-gebaute Apps deckt die anderen Punkte ab, die du vor dem Launch prüfen solltest. Das Unbequeme an dieser Untersuchung ist, wie gewöhnlich die Methode war: öffentlicher Key, Leseanfrage, Daten kommen zurück. Genau diese Sicht hat ein Angreifer, und genau diese Sicht solltest du regelmäßig prüfen.
AppSafe scannt deine Live-App von außen. Der verifizierte vollständige Scan enthält reine Lese-Checks auf offene Supabase- und Firebase-Datenbanken und sucht nach API-Keys, die in deinen JavaScript-Bundles offenliegen, und geplante Scans können dich benachrichtigen, wenn sich nach einem Deploy etwas ändert. Neugierig, wie ein Report aussieht? Wirf einen Blick auf den Beispiel-Report.
Starte jetzt einen kostenlosen Scan deiner App und finde heraus, was ein Fremder sehen kann, bevor es jemand anderes tut.
