KI bewacht dein Dashboard und vergisst deine API
KI-Tools sichern oft nur das Frontend mit einem clientseitigen Redirect ab – und lassen den Route-Handler komplett ungeschützt. Wie der Exploit funktioniert, die PostgREST- und JWT-Mechanik dahinter und wie der serverseitige Fix aussieht.
Bei unseren Audits von Apps, die mit Lovable, Bolt und Cursor gebaut wurden, stoßen wir fast immer auf dasselbe Muster: Ein Dashboard leitet abgemeldete User brav auf /login um – während die dahinterliegende API-Route exakt dieselben Daten an jeden ausliefert, der sie anfragt. Der Redirect greift nur im Browser. Die Route selbst authentifiziert niemanden. Im schlimmsten Fall nutzt sie für Datenbankabfragen auch noch einen Service-Role-Key, wodurch nicht einmal Row-Level Security (RLS) abfangen kann, was der Handler schlicht ignoriert. Wir nennen diese reine Frontend-Prüfung den kosmetischen Wächter und das zugrundeliegende Architekturproblem den Page-vs-API-Split. Es ist dieselbe Fehlerklasse wie der unsignierte Webhook als Default, standardmäßig deaktivierte RLS oder öffentliche S3-Buckets: Die Vertrauensgrenze wird auf einer komplett anderen HTTP-Schicht gezogen als dort, wo die eigentlichen Daten gelesen werden.
Der Page-Wächter ist eine Render-Entscheidung, keine Vertrauensgrenze
Bittet man ein KI-Tool um „ein Dashboard, das nur eingeloggte Nutzer sehen können“, platziert es den Schutzmechanismus direkt in der React-Komponente:
// app/dashboard/page.tsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/hooks/use-auth'
export default function DashboardPage() {
const { user, loading } = useAuth()
const router = useRouter()
useEffect(() => {
if (!loading && !user) {
router.push('/login')
}
}, [user, loading, router])
if (loading) return <Spinner />
if (!user) return null
return <Dashboard userId={user.id} />
}
Das hat drei fatale Eigenschaften:
- Der Code läuft ausschließlich in der Client-Laufzeitumgebung. Der Server wertet ihn überhaupt nicht aus. Diese Prüfung hat also null Einfluss darauf, was ein HTTP-Endpoint tatsächlich zurückgibt.
- Die Zeile
if (!user) return nullverhindert zwar zuverlässig, dass die geschützte Komponente gerendert wird (es gibt also kein kurzes Aufblitzen von Inhalten). Dennoch bleibt es eine reine Render-Entscheidung im Browser. Sie kontrolliert das DOM, aber niemals die Response eines Endpoints. - Abgesichert wird hier lediglich ein React-Teilbaum. Die eigentlichen Daten werden per HTTP von einem Handler geladen, der völlig autark entscheiden muss, wer autorisiert ist. Genau in diesem Handler liegt die einzige Prüfung, auf die es sicherheitstechnisch ankommt.
Der Route-Handler authentifiziert niemanden
Und so sieht der dazugehörige Endpoint aus, den das Dashboard aufruft – meist generiert in einem separaten Prompt:
// app/api/projects/[id]/route.ts
import { NextResponse } from 'next/server'
import { createAdminClient } from '@/lib/supabase/admin' // service_role key
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const supabase = createAdminClient()
const { data: project } = await supabase
.from('projects')
.select('*')
.eq('id', params.id)
.single()
return NextResponse.json(project)
}
Kein getUser(), kein 401, keine Prüfung, wem die Daten gehören (Ownership-Prädikat). Obendrein wird der Service-Role-Client verwendet. Genau das macht aus einer fehlenden Auth-Prüfung eine massive Sicherheitslücke mit unbeschränktem Lesezugriff.
Ein Supabase-Access-Token ist ein normales JWT. PostgREST liest den role-Claim aus und führt vor der eigentlichen Query ein SET LOCAL ROLE mit genau dieser Postgres-Rolle aus. Der Aufruf von auth.uid() innerhalb einer Policy greift dann unter der Haube auf current_setting('request.jwt.claims', true)::json ->> 'sub' zu. Deine RLS-Policies sind für die Rollen authenticated und anon geschrieben – ein typisches Beispiel ist USING (auth.uid() = owner_id). Die Postgres-Rolle service_role hat jedoch das Privileg BYPASSRLS. Eine mit dem Service-Key abgesetzte Query ignoriert folglich alle Policies, inklusive genau des Ownership-Prädikats, auf das du dich verlassen wolltest. Der Admin-Client ist ein mächtiges Werkzeug für vertrauenswürdige Server-Prozesse (wie Cron-Jobs oder Webhooks, deren Signatur bereits verifiziert wurde). In einer Route, die direkt aus dem Browser aufrufbar ist, macht er aus „RLS schützt diese Tabelle“ schlichtweg ein „Jeder darf alles lesen“.
Selbst wenn du deine RLS-Regeln nach unserem letzten Beitrag wasserdicht gemacht hast: Diese Route hebelt sie by Design aus.
Der Exploit ist ein GET
Der Page-Wächter steht dem Angreifer überhaupt nicht im Weg. Dieser lädt nämlich nicht die Seite, sondern fragt direkt den Endpoint ab, den die Seite genutzt hätte:
# No cookie, no Authorization header. Just the id.
curl https://yourapp.com/api/projects/8f2a1c7e-4b90-4f1e-9c2d-a1b2c3d4e5f6
# 200, full project row
Wie groß der Schaden ist, hängt von deinem ID-Format ab. Eine UUIDv4 bietet 122 Bit Entropie, die Tabelle lässt sich also nicht einfach durchzählen. Das macht die IDs aber noch lange nicht geheim. Sie leaken ununterbrochen: in Referer-Headern an Dritte, in geteilten Links, in Error-Payloads, auf Support-Screenshots oder über andere Endpoints, die IDs ungefiltert ausspucken. Jede geleakte ID bedeutet vollen Zugriff auf den Datensatz über ein simples, unauthentifiziertes GET. Nutzt du hingegen ein bigserial für deine IDs, braucht es nicht mal einen Leak – man kann die Tabelle einfach absaugen:
for id in $(seq 1 100000); do curl -s "https://yourapp.com/api/projects/$id"; done
Wir haben das mit Referenz-Apps reproduziert, die wir in Lovable, Bolt und Cursor generiert haben. Das Frontend leitet brav um, die API liefert die Daten gnadenlos aus. Jedes einzelne Mal.
Warum das Modell den Wächter auf die Seite setzt
Dem Modell fehlt es nicht an theoretischem Wissen über Authentifizierung. Das Problem ist, wo die Anforderung in der Prompt-Sequenz auftaucht. Sagst du „Mach das Dashboard nur für eingeloggte Nutzer sichtbar“, ist das Dashboard das Subjekt – also landet der Redirect in der React-Komponente. Ein späterer, isolierter Prompt wie „Füge einen Endpoint zum Abrufen eines Projekts hinzu“ enthält weder einen Kontext zum Akteur noch Berechtigungsregeln. Folglich baut das KI-Modell einen dummen Fetch-by-ID-Endpoint. Da beide Anforderungen nie im selben Prompt stehen, geht die Autorisierung an der Schnittstelle zwischen Frontend und Backend komplett verloren.
Genau diese Schnittstelle ist der Page-vs-API-Split. Autorisierung ist ein Querschnittsthema (Cross-Cutting Concern), aber ein LLM generiert pro Turn meist nur eine Datei. Der „Wächter“ landet auf der Oberfläche, die du beschrieben hast, und eben nicht im Datenlesepfad, den du nicht explizit erwähnt hast. Versucht man das mit Prompts wie „Und prüf, ob der Nutzer auch der Eigentümer ist“ zu fixen, repariert das vielleicht kurzfristig die gerade fokussierte Route. Da aber oft weiterhin der Admin-Client genutzt wird, reicht ein einziges vergessenes .eq('owner_id', user.id), um die Lücke wieder aufzureißen – und kein Typsystem der Welt wird dich davor warnen.
Der Fix: Erst serverseitige Identität, dann Ownership
// app/api/projects/[id]/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' // request-scoped, carries the caller's JWT
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: project, error } = await supabase
.from('projects')
.select('*')
.eq('id', params.id)
.eq('owner_id', user.id) // authorization, in the query
.single()
if (error || !project) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(project)
}
Vier entscheidende Änderungen:
- Finger weg vom Admin-Client. Der Request-scoped Client reicht das JWT des Aufrufers als Rolle
authenticateddurch. Dadurch greift die RLS wieder, und der Service-Role-Bypass gehört der Vergangenheit an. getUser()klärt die Identität und wirft einen 401-Fehler, falls keine Session existiert. Das ist die Authentifizierung – also das Wer..eq('owner_id', user.id)klärt die Berechtigung. Das ist die Autorisierung – also das Was. Wenn die RLS dies zusätzlich auf Datenbankebene erzwingt: perfekt, doppelt genäht hält besser. Ohne RLS ist diese Zeile der einzige Schutzschild zwischen einem Angreifer und den Daten anderer Mandanten. In jedem Fall gehört diese Prüfung zwingend in die Query – genau dorthin, wo der Lesezugriff stattfindet.- Ein 404, kein 403, wenn die Berechtigung fehlt. Man sollte Angreifern niemals bestätigen, dass ein Datensatz überhaupt existiert, wenn sie ihn ohnehin nicht sehen dürfen.
getUser versus getSession, auf Token-Ebene
Selbst Entwickler, die sich sicher sind, „Auth sauber eingebaut“ zu haben, tappen hier oft in eine böse Falle.
getSession() schnappt sich lediglich das JWT aus dem Cookie oder Local Storage, dekodiert den Base64-Payload, prüft den exp-Claim (Ablaufdatum) und gibt es zurück. Es findet kein Netzwerkaufruf statt! Der Auth-Server wird also nie gefragt, ob das Token tatsächlich noch gültig ist. Die Funktion weiß nur, dass das Token strukturell korrekt und formell nicht abgelaufen ist. Das bedeutet: Ein Token von einem User, der längst ausgeloggt, gelöscht oder gebannt wurde, wird von getSession() weiterhin fröhlich akzeptiert. Der Token-Widerruf (Revocation) existiert nämlich nur auf dem Server – und der wurde ja nie kontaktiert.
getUser() hingegen schickt einen echten GET /auth/v1/user-Request samt Token an den Server. GoTrue verifiziert dort die Signatur gegen das JWT-Secret des Projekts (HS256 oder einen asymmetrischen Key bei neueren Setups) und stellt sicher, dass der User-Datensatz noch existiert und nicht gesperrt ist. Dieser Round-Trip ist essenziell für die Revalidierung. Genau deshalb rät Supabase in der eigenen Anleitung dringend davon ab, sich im Server-Code auf getSession() zu verlassen. Der Preis dafür ist ein zusätzlicher Request an deinen Auth-Host pro Aufruf. Falls das deine p99-Latenzen sprengt: Cache das Ergebnis von getUser() für kurze Zeit serverseitig (z.B. gekoppelt an einen Session-Key), aber wechsle niemals aus Bequemlichkeit zurück zu getSession().
Fehlende Checks dürfen gar nicht erst kompilieren (statt still zu versagen)
Eine einzelne reparierte Route löst das strukturelle Problem des Splits noch nicht. Die Schwachstelle lauert auf Handler-Ebene – und der eigentliche Security-Incident wartet im dreißigsten Endpoint, den irgendjemand schnell zusammenkopiert hat, ohne an die rettenden vier Zeilen Code zu denken. Die Lösung ist, die Identitätsprüfung zu zentralisieren. Eine Route darf gar nicht in der Lage sein, benutzerspezifische Daten abzurufen, ohne den User vorher zwingend authentifiziert zu haben:
// lib/with-auth.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import type { User } from '@supabase/supabase-js'
type Ctx = { params: Record<string, string> }
type AuthedHandler = (req: Request, ctx: Ctx & { user: User }) => Promise<Response>
export function withAuth(handler: AuthedHandler) {
return async (req: Request, ctx: Ctx) => {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return handler(req, { ...ctx, user })
}
}
// app/api/projects/[id]/route.ts
export const GET = withAuth(async (_req, { params, user }) => {
// `user` is only reachable through withAuth; there is no other way to get it here
...
})
Zwar macht das Flüchtigkeitsfehler nicht gänzlich unmöglich, aber es macht den authentifizierten Pfad zur absolut einzigen Quelle für das user-Objekt. Es wird zum Weg des geringsten Widerstands für Entwickler (und LLMs). Gleichzeitig bündelt es dreißig verstreute getUser()-Aufrufe in einer einzigen Funktion, die sich sauber in Code-Reviews prüfen lässt. Wichtig: Das Ownership-Prädikat (Wem gehören die Daten?) muss weiterhin pro Query definiert werden. Zentralisiert wird hier nur die Identität (Wer fragt an?).
Die Negativtests
Den Happy-Path-Test (Eigentümer ruft eigenes Projekt ab und erhält Status 200) generiert die KI oft von selbst. Die fatalen Bugs verstecken sich in den Szenarien, die sie nicht schreibt:
# 1. No session -> 401
curl -i https://yourapp.com/api/projects/SOME_ID
# Expect 401
# 2. Authenticated as A, fetching B's row -> 404 (not 200, not the row)
curl -i https://yourapp.com/api/projects/USER_B_PROJECT_ID \
-H "Cookie: $USER_A_SESSION_COOKIE"
# Expect 404
Wenn der erste Test einen 200er-Status wirft, ist der kosmetische Frontend-Wächter nach wie vor dein einziger Schutzmechanismus. Wenn der zweite Test erfolgreich Daten zurückgibt, hast du den Aufrufer zwar authentifiziert, ihm aber blindlings Zugriff gewährt, ohne ihn zu autorisieren.
Was das nicht abdeckt
Identität gepaart mit Ownership sichert reine Lesezugriffe ab. Das Konzept deckt aber keine Schreibpfade ab (POST und PATCH erfordern dasselbe Prädikat plus feldgranulare Regeln, was überhaupt beschrieben werden darf). Es schützt nicht vor Mass-Assignment-Vulnerabilities durch ein schlampiges select('*'), das plötzlich interne Spalten ans Frontend funkt, die den Client nichts angehen. Es verhindert auch nicht, dass der getUser()-Round-Trip auf stark frequentierten Routen zu einem Flaschenhals für die Verfügbarkeit wird. Zudem basiert das owner_id-Modell auf einfachem Single-Tenant-Ownership. Sobald du Projekte mit mehreren Mitgliedern oder Organisationen mit komplexen Rollen hast, mutiert das Prädikat zu einem komplexen Join gegen eine Membership-Tabelle. Spätestens dann ist RLS der einzig sinnvolle Ort, um diese Logik abzubilden. In unseren Audits behandeln wir diese Punkte als separate Findings.
Die Lektion
Ein Redirect ändert nur, was im Browser gerendert wird. Er ändert niemals, was der Server über die API zurückgibt. Eine Autorisierung, die nicht explizit im Datenlesepfad – also in der Datenbank-Query oder der RLS-Policy – verankert ist, existiert faktisch nicht für direkte HTTP-Requests.
Dieser architektonische Denkfehler ist übrigens nicht auf KI-generierten Code beschränkt. Als Metas Support-KI versehentlich fremde Instagram-Konten freigab, bestand der „Wächter“ lediglich aus einem Chat-Dialog, den Angreifer mit geschickten Prompts umgehen konnten. Die eigentliche, kritische Autorisierung fehlte weiterhin genau dort, wo es zählte: an der Stelle, wo auf die Daten zugegriffen wurde.