Read this article in English →
security · · Streatix

Unsere Lovable-App gab am ersten Tag die Daten jedes Nutzers preis

Wie ein mit Lovable gebautes SaaS mit deaktivierter Supabase-RLS ausgeliefert wurde, warum wir es nicht sofort bemerkt haben und wie der Fix aus drei Policies aussieht.

Wir auditieren KI-generierte Codebasen. Um herauszufinden, was diese Tools standardmäßig ausliefern, bauen wir gelegentlich absichtlich fehlerhafte Referenz-Apps. Eine davon – ein mit Lovable generiertes, mandantenfähiges Rechnungs-SaaS, wie es hunderte Gründer jede Woche live schalten – gab gleich am ersten Tag die Daten aller Nutzer an jeden beliebigen anderen Nutzer preis. Der Fix? Drei simple SQL-Policies. Den Bug zu finden, kostete uns dennoch fast einen ganzen Nachmittag, weil wir schlicht an der falschen Stelle gesucht haben.

Wir haben gebaut, was ein Gründer bauen würde

Die Referenz-App ist bewusst minimalistisch gehalten: Next.js App Router im Frontend, Supabase für Auth und Postgres, deployt auf Vercel. Dazu drei Tabellen (users, invoices, customers) mit genau den user_id-Fremdschlüsseln, die man hier erwarten würde. Zwei Testkonten, Alice und Bob, mit jeweils einer Handvoll Rechnungen in ihren Dashboards.

Unser Prompt an Lovable lautete: „build a multi-tenant invoicing tool with user accounts and an invoice list”. Das Ergebnis haben wir ungesehen übernommen und deployt. Kein Schema-Review, kein einziger Blick ins Supabase-Dashboard. Ziel war es schließlich zu testen, was im Standard-Setup herausfällt, wenn man nichts anfasst.

Der Login funktionierte reibungslos. Das Dashboard zeigte Alices Rechnungen, wenn sie sich einloggte, und Bobs Rechnungen bei Bob. Jeder sah ausschließlich seine eigenen Daten. Eine saubere Demo.

Die Entscheidung, die Lovable uns abgenommen hat

Lovable baute das Schema brav mit user_id-Spalten an jeder Tabelle auf, die Mandantenfähigkeit erforderte: invoices.user_id, customers.user_id. Auch die API-Routen filterten sauber nach dem authentifizierten Nutzer – an jedem select hing artig ein .eq('user_id', user.id).

Das wirkte zunächst beruhigend. Das Datenmodell verstand das Konzept der Mandantenfähigkeit, die Query-Schicht nutzte es, und wir gingen fest davon aus, dass die Datenbank die Zeilenzugehörigkeit (Row-Level Security) auch auf Datenbankebene erzwingt. Wozu sonst gäbe es die user_id-Spalte?

Genau diese Annahme war der Bug.

Das Symptom: Ein curl-Befehl legt alle Rechnungen offen

Wir loggten Alice ein, kopierten ihr Session-Cookie und feuerten einen manuellen Test ab:

curl https://reference-app.test/api/invoices \
  -H "Cookie: sb-access-token=ALICE_TOKEN"

Zurück kamen Alices Rechnungen. Fünf Zeilen. Alles wie erwartet.

Dann probierten wir etwas, das das Dashboard selbst nie tun würde: Wir nutzten die gültige Session, hängten aber einen Query-Parameter an, den das Frontend eigentlich gar nicht sendet. Beim Überfliegen des Codes war uns nämlich eine verdächtige Zeile im Route-Handler aufgefallen:

// app/api/invoices/route.ts
export async function GET(request: Request) {
  const supabase = createServerClient()
  const { data: { user } } = await supabase.auth.getUser()
  const userId = new URL(request.url).searchParams.get('user_id') ?? user.id
  const { data } = await supabase
    .from('invoices')
    .select('*')
    .eq('user_id', userId)
  return NextResponse.json({ invoices: data })
}

Die Zeile searchParams.get('user_id') ?? user.id erlaubt es dem Client, die user_id für den Datenbankfilter einfach zu überschreiben. Schlampig programmiert – aber exakt das, was Lovable generiert, wenn man „let admins view other users’ invoices” verlangt, ohne präzise Autorisierungsregeln zu definieren.

Also haben wir genau das ausgenutzt:

curl "https://reference-app.test/api/invoices?user_id=BOB_USER_ID" \
  -H "Cookie: sb-access-token=ALICE_TOKEN"

Bobs Rechnungen wurden ausgeliefert. Alice konnte also mit ihrer eigenen Session fröhlich Bobs Daten auslesen.

Das allein ist bereits ein kritischer API-Bug – eine fehlende Autorisierungsprüfung, die uns in jedem Audit sofort ins Auge springen würde. Doch das ist gar nicht der Bug, um den es in diesem Artikel geht. Es war nur der Auslöser, der uns zum eigentlichen Problem führte.

Unsere erste, falsche Theorie

Unsere erste Annahme: Der Route-Handler ist schuld. Der Fix schien trivial: Den Query-Parameter ignorieren, hart die ID des authentifizierten Nutzers forcieren, Patch pushen. Ein klassischer Audit-Befund auf Applikationsebene.

Wir wollten den Case schon abhaken, als jemand im Team stutzig wurde: „Moment mal. Selbst wenn die API Müll baut – warum spuckt Supabase überhaupt Datensätze aus, die nicht Alice gehören? Müsste die Datenbank das nicht von sich aus blocken?”

Also hakten wir nach. Statt den Umweg über die Next.js-App zu nehmen, funkten wir den Supabase-REST-Endpoint direkt an. Ausgestattet nur mit dem anon-Key – genau dem öffentlichen Schlüssel, der sowieso im Browser landet:

curl "$SUPABASE_URL/rest/v1/invoices?user_id=eq.BOB_USER_ID" \
  -H "apikey: $SUPABASE_ANON_KEY"

Bobs Datensätze kamen an. Kein Autorisierungsfehler, keine Filterung. Allein das ist ein massives Warnsignal: Eine Tabelle mit aktivierter RLS, aber ohne definierte Policies, greift auf ein „Default Deny” zurück und liefert gar nichts aus. Dass hier munter Daten an die anon-Rolle zurückflossen, konnte nur eines bedeuten: RLS griff überhaupt nicht. Ein einfaches SELECT im Supabase-SQL-Editor hätte dieses Problem verschleiert, da der Editor mit Superuser-Rechten (BYPASSRLS) läuft und alles anzeigt. Man hätte explizit ein SET ROLE anon; absetzen müssen, um die Sicht eines Angreifers zu simulieren. Der anonyme REST-Endpoint spiegelt exakt das wider, was ein Fremder im Netz erreichen kann – und dieses Tor stand sperrangelweit offen.

Spätestens da war klar: Das eigentliche Problem lag tief unter der API-Schicht.

Die Datenbank wusste nichts von Mandanten

Wir öffneten das Supabase-Dashboard, navigierten zur Tabelle invoices und warfen einen Blick ins Authentication-Panel. Ganz oben prangte ein Schalter:

Enable Row Level Security

RLS prevents anonymous access to rows by default.

Der Schalter stand auf „Aus“.

Wir klickten uns durch die anderen Tabellen. customers: aus. users: aus. Jede einzelne Tabelle, die Lovable generiert hatte, wurde komplett ohne RLS ausgeliefert.

Das ist das Anti-Pattern, das wir intern mittlerweile als Open-Database-Default bezeichnen. Supabase liefert Tabellen per SQL-Standard ohne RLS aus, Lovable schaltet sie nicht explizit ein – und schon mutiert die Datenbank zu einem öffentlichen Lese-Endpoint für jeden, der ein gültiges JWT besitzt. In der Praxis also: für jeden, der in der Lage ist, sich in der App zu registrieren.

Die user_id-Spalten, die Lovable so pflichtbewusst in die Schema-Definition geschrieben hatte, waren reine Dekoration. Sie dienten lediglich der API als Filterkriterium. Ohne RLS auf Datenbankebene war jede architektonische Annahme bezüglich einer sicheren Mandantenfähigkeit in dieser App hinfällig.

Die Ursache: Zwei Systeme verlassen sich blind aufeinander

Die Defaults von Supabase sind absichtlich permissiv. Legt man eine Tabelle per rohem SQL an – was eine von einer KI generierte Migration nun mal tut –, bleibt RLS deaktiviert. (Der grafische Tabellen-Editor im Dashboard schaltet RLS mittlerweile standardmäßig ein, der reine SQL-Pfad von Lovable aber eben nicht). Die Dokumentation ist hier eindeutig und fordert explizit dazu auf, RLS zu aktivieren. Dieser offene Default hat einen simplen UX-Grund: Mit aktiver RLS, aber ohne definierte Policies, liefert die Datenbank rigoros null Ergebnisse zurück. Ein Einsteiger, der einfach nur ein schnelles Projekt hochziehen will, würde frustriert gegen eine unsichtbare Wand laufen.

Auch Lovable aktiviert RLS nicht von selbst. Das Scaffolden der Tabellen resultiert in exakt so einer Migration:

CREATE TABLE invoices (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES users(id),
  amount integer NOT NULL,
  customer_email text NOT NULL,
  created_at timestamptz DEFAULT now()
);

Kein ENABLE ROW LEVEL SECURITY. Kein CREATE POLICY. Das Schema simuliert Mandantenfähigkeit zwar über den user_id-Fremdschlüssel, erzwingt sie aber nicht.

Beide Seiten haben für ihr Verhalten durchaus vertretbare Argumente: Supabase lässt die Tür für eine reibungslose Developer Experience offen. Lovable wiederum implementiert keine RLS, weil RLS im Prompt schlicht nicht gefordert war – die KI liefert stupide genau das, was verlangt wird. Das fatale Ergebnis für den Nutzer: Ein funktionierendes Dashboard, Code, der nach sauberer Multi-Tenancy aussieht, und eine Datenbank, der Mandantengrenzen völlig egal sind.

Der Fix: Drei einfache Policies

Mit drei simplen SQL-Statements zwingt man die Datenbank, die Mandantenfähigkeit auch tatsächlich zu leben:

-- 1. Enable RLS on the table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- 2. Allow users to read only their own rows
CREATE POLICY "Users read own invoices"
  ON invoices
  FOR SELECT
  USING (auth.uid() = user_id);

-- 3. Allow users to insert only rows owned by themselves
CREATE POLICY "Users insert own invoices"
  ON invoices
  FOR INSERT
  WITH CHECK (auth.uid() = user_id);

Die USING-Klausel greift beim SELECT (sowie auf der Lese-Seite von UPDATE und DELETE). Sie evaluiert nur dann auf true, wenn auth.uid() – die Supabase-Hilfsfunktion, die die User-ID aus dem JWT extrahiert – exakt mit der user_id-Spalte übereinstimmt. Alle anderen Zeilen werden lautlos gefiltert.

Die WITH CHECK-Klausel kommt bei INSERT und UPDATE zum Tragen. Sie blockiert gnadenlos jeden Schreibvorgang, bei dem die übergebene user_id nicht zum angemeldeten Nutzer passt. Das verhindert direkt den zweiten großen Standard-Bug: Clients, die beim Erstellen von Datensätzen einfach eine fremde user_id unterschieben.

Das Ganze muss für customers und alle weiteren mandantenabhängigen Tabellen wiederholt werden. Für Schreiboperationen benötigt man analog dazu noch entsprechend strukturierte UPDATE- und DELETE-Policies.

Rollt man diese Policies aus, läuft der direkte Aufruf über den anon-Key fortan ins Leere. Auch der Versuch, Bobs Zeilen mit Alices JWT abzufragen, bringt kein Ergebnis mehr. Der API-Exploit von vorhin verpufft wirkungslos – selbst wenn man den dilettantischen Next.js-Route-Handler im Code belässt. Die Datenbank macht ab sofort dicht.

Ein zweiter Fix: Schreibe Negativtests

Ein passender Negativtest, der dieses Desaster von Anfang an verhindert hätte, sieht so aus:

// __tests__/rls.test.ts
import { describe, it, expect } from 'vitest'
import { createClient } from '@supabase/supabase-js'

describe('RLS enforcement on invoices', () => {
  // A client authenticated as Alice: the public anon key plus Alice's access
  // token. PostgREST runs the query as the `authenticated` role with
  // auth.uid() resolving to Alice's id, exactly what a stranger's browser does.
  const alice = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
    global: { headers: { Authorization: `Bearer ${ALICE_ACCESS_TOKEN}` } },
  })

  it("user A cannot read user B's invoices via direct query", async () => {
    const { data } = await alice
      .from('invoices')
      .select('*')
      .eq('user_id', BOB_USER_ID)

    expect(data).toEqual([])
  })

  it('user A cannot insert an invoice owned by user B', async () => {
    const { error } = await alice
      .from('invoices')
      .insert({ user_id: BOB_USER_ID, amount: 100, customer_email: 'x@x.com' })

    expect(error).toBeTruthy()
  })
})

Gegen die Ursprungsversion der App gefeuert, wäre dieser Test sofort rot geworden. Eine KI schreibt solche Tests (noch) nicht von allein. Wir bei unseren Audits schon.

Trügerische Sicherheit

RLS behebt elegant das Kernproblem „Falscher Nutzer liest falsche Zeile“. Sie ist aber kein Allheilmittel. Eine saubere Policy macht noch lange keine ganzheitlich gehärtete, mandantenfähige Datenbankarchitektur. Folgende Angriffsvektoren fängt RLS nämlich nicht ab:

  • Service-Role-Keys umgehen RLS by Design. Wenn das Frontend-Bundle den service_role-Key leakt (was bei KI-generierten Apps beängstigend oft passiert), sind die oben genannten Policies völlig wertlos. Dieser Key hat absolute Adminrechte. Übergibt man ihn leichtsinnig an einen Route-Handler, ignoriert dieser jegliche RLS-Regeln – ein Problem, das wir in einem eigenen Artikel detailliert beleuchten. RLS einzurichten, ohne kompromittierte Keys sofort zu rotieren, ist reine Kosmetik.
  • Datenlecks über SQL-Joins. Wenn invoices sauber per RLS geschützt ist, customers aber nicht, und die API beide Tabellen joint, fließen die Kundendaten völlig ungeschützt ins Frontend. RLS gilt immer strikt pro Tabelle – die Netzabdeckung muss im gesamten Schema lückenlos sein.
  • Sicherheitsregeln, die nur gut aussehen. Eine USING (true)-Policy taucht zwar brav im Dashboard auf, schaltet die Tabelle aber faktisch auf Durchzug. Eine Policy, die die user_id mit einem JWT-Claim vergleicht, der in der Praxis gar nicht existiert, blockiert stillschweigend alle Zugriffe. Der typische Anfänger-Fix? Die Policy einfach löschen oder aufreißend permissiv machen – womit das ursprüngliche Ziel ad absurdum geführt wird.
  • Performance-Einbrüche. Schlecht geschriebene, nicht per Index gestützte RLS-Policies können performante Queries in quälend langsame Table Scans verwandeln. Wir haben schon Rollouts gesehen, die zwar das Security-Loch gestopft, aber am selben Nachmittag die Datenbank unter der Last begraben haben.
  • Storage-Buckets ticken anders. RLS schützt Tabellenzeilen, aber keine hochgeladenen Dateien. Ein öffentlicher Supabase-Storage-Bucket oder ein schlecht konfigurierter S3-Container serviert Rechnungs-PDFs an jeden, der den Link errät – völlig unabhängig davon, wie restriktiv die SQL-Policies sind. Diesen speziellen Fall zerlegen wir in unserem Beitrag über offene S3-Buckets.

Ein professionelles Security-Audit für eine App in genau diesem Zustand würde ordentlich Befunde werfen: den API-Bug, die komplett fehlende RLS, das lückenhafte Schema und sehr wahrscheinlich mindestens drei der eben genannten Folgeprobleme. Der Drei-Policy-Fix von oben ist das absolute Minimum – nicht die Ziellinie.

Die Spalten suggerieren Mandantenfähigkeit, die Datenbank weiß von nichts

Eine KI-generierte App voller user_id-Spalten erweckt den trügerischen Eindruck einer robusten Multi-Tenancy-Architektur. Das Datenmodell behauptet, sie sei mandantenfähig. Der API-Code verhält sich so. Doch die Datenbank selbst tut im Standard-Setup absolut nichts, um dies auch zu garantieren.

Wenn Sie das für Ihre eigene App wollen

Dreißigminütiges Audit, kostenlos. Wir rufen dieselben Endpunkte per curl auf und lesen dieselben Dateien. Liste in Ihrem Postfach binnen achtundvierzig Stunden.

Kostenloses Audit buchen