Ein vermeintlich harmloser Fehler in der Implementierung von Row-Level-Sicherheit in Supabase-Projekten kann zu massiven Performance-Problemen führen, wenn Tabellen wachsen. Ein Entwicklerteam entdeckte dies erst spät: 76 seiner RLS-Policies nutzten die Funktion auth.uid() direkt im USING- oder WITH CHECK-Bereich – ein Muster, das bei kleinen Datensätzen unsichtbar bleibt, aber bei großen Tabellen zu erheblichen Latenzzeiten führt.
Das Problem betrifft vor allem Tabellen mit vielen Zeilen pro Nutzer, wie etwa chapter_progress in einem LMS oder quiz_attempts in einer Bildungsplattform. Die Lösung ist einfach, aber entscheidend: Die Funktion muss einmalig zu Beginn der Abfrage ausgewertet werden, statt für jede Zeile erneut.
Warum auth.uid() pro Zeile neu berechnet wird
Viele Entwickler gehen davon aus, dass Funktionen wie auth.uid() in Supabase konstant sind. Doch in PostgreSQL sind solche Funktionen, die mit dem Attribut STABLE markiert sind, nur innerhalb einer Abfrage stabil – nicht aber für die gesamte Laufzeit des Systems. Ohne explizite Anweisung an den Query-Optimierer wird die Funktion für jede Zeile neu ausgeführt.
Ein typisches Beispiel aus dem Equip-Projekt vor der Korrektur:
CREATE POLICY "assignments_update_teacher"
ON public.assignments
FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1
FROM public.profiles p
WHERE p.id = auth.uid()
AND p.role IN ('teacher', 'admin')
)
);Für eine Tabelle mit 50 Einträgen mag dies kaum spürbar sein. Doch bei Tabellen mit tausenden oder Millionen Zeilen summiert sich der Overhead: Jede Zeile löst eine erneute Auswertung der Funktion aus, inklusive JWT-Kontextprüfung und Claim-Extraktion. Die Folge sind langsamere Abfragen, längere Ladezeiten und ein unnötiger Ressourcenverbrauch.
Die Lösung: Ein Subquery erzwingt die einmalige Ausführung
Die Korrektur erfordert nur wenige Zeichen. Durch das Einbetten von auth.uid() in ein Subquery wird die Funktion einmalig zu Beginn der Abfrage ausgeführt und das Ergebnis als Konstante für alle Zeilen verwendet:
CREATE POLICY "assignments_update_teacher"
ON public.assignments
FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1
FROM public.profiles p
WHERE p.id = (SELECT auth.uid())
AND p.role IN ('teacher', 'admin')
)
);Der PostgreSQL-Query-Optimierer erkennt das Subquery (SELECT auth.uid()) als eine einmalige Ausführung und zieht es in einen sogenannten InitPlan. Dieser wird nur einmal pro Abfrage ausgeführt und liefert einen konstanten Wert, der für alle Zeilen verwendet wird. Die Performance-Unterschiede lassen sich mit EXPLAIN ANALYZE sichtbar machen:
- Ohne Subquery: Die Funktion wird pro Zeile aufgerufen.
- Mit Subquery: Der
InitPlanwird einmalig ausgeführt, und die Abfrage referenziert einen konstanten Wert ($0).
Warum der Fehler so lange unentdeckt blieb
Der entscheidende Faktor ist die Skalierung. In der Entwicklungsphase und bei kleinen Produktionsdatenbeständen funktioniert das Muster problemlos. Tests und Benutzer nehmen keinen Unterschied wahr, sodass der Fehler lange unbemerkt bleibt. Erst wenn Tabellen wie quiz_attempts oder chapter_progress stark wachsen, wird das Problem spürbar.
Besonders betroffen sind zwei Szenarien:
- Tabellen mit vielen Einträgen pro Nutzer: Beispielsweise Fortschrittsverfolgung in Lernplattformen oder Quizversuche in Bildungsanwendungen.
- Many-to-Many-Beziehungen: Tabellen wie
enrollments, die bei fast jeder authentifizierten Abfrage durchlaufen werden.
Kleinere Tabellen wie Kurskataloge oder Taxonomien bleiben auch mit dem ursprünglichen Muster performant. Die Gefahr liegt darin, dass Entwickler ihr Feedback – "es funktioniert" – fälschlicherweise als Bestätigung für die Korrektheit der Implementierung interpretieren.
Wie das Equip-Team den Fehler fand: Der Supabase-Advisor
Supabase bietet ein integriertes Tool namens Advisors, das Performance-Warnungen direkt im Dashboard anzeigt. Einer dieser Advisors ist auth_rls_initplan, der automatisch alle RLS-Policies scannt und solche mit direktem auth.()-Aufruf ohne Subquery als problematisch markiert.
Es gibt zwei Wege, den Advisor zu nutzen:
- Supabase CLI: Mit dem Befehl
supabase db advisorskönnen Entwickler die Performance-Empfehlungen direkt in ihrem Terminal anzeigen. - Supabase-Dashboard: Im Bereich Performance Advisors werden alle Warnungen aufgelistet, inklusive der betroffenen Policies.
Die Ausgabe des Advisors ist eine einfache Liste mit den Namen der betroffenen Policies. Die Korrektur erfolgt durch das erneute Erstellen der Policies mit dem angepassten Subquery-Muster:
DROP POLICY "assignments_update_teacher" ON public.assignments;
CREATE POLICY "assignments_update_teacher"
ON public.assignments
FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1
FROM public.profiles p
WHERE p.id = (SELECT auth.uid())
AND p.role IN ('teacher', 'admin')
)
);Das Equip-Team hat diese Korrektur in einer einzigen Migration mit 76 Policies umgesetzt. Der Aufwand war minimal, aber der Performance-Gewinn bei großen Datensätzen enorm.
Ein weiterer Fallstrick: PL/pgSQL-Funktionen in Policies
Ein oft übersehener Aspekt betrifft die Verwendung von Helferfunktionen in RLS-Policies. Viele Entwickler nutzen Funktionen wie is_admin() oder is_teacher(), um die Policies DRY zu halten. Doch wenn diese Funktionen in PL/pgSQL geschrieben sind, kann der Optimierer sie nicht inline ausführen.
Ein Beispiel für eine problematische Implementierung:
CREATE FUNCTION public.is_admin()
RETURNS boolean
LANGUAGE plpgsql STABLE
AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM public.profiles p
WHERE p.id = (SELECT auth.uid())
AND p.role = 'admin'
);
END;
$$;Diese Funktion wird trotz des STABLE-Attributs für jede Zeile neu ausgeführt, da PL/pgSQL-Funktionen nicht in den Query-Plan integriert werden können. Die Lösung liegt in der Verwendung von SQL-Funktionen, die der Optimierer inline ausführen kann:
CREATE FUNCTION public.is_admin()
RETURNS boolean
LANGUAGE sql STABLE
AS $$
SELECT EXISTS (
SELECT 1
FROM public.profiles p
WHERE p.id = (SELECT auth.uid())
AND p.role = 'admin'
);
$$;Durch den Wechsel von PL/pgSQL zu SQL wird die Funktion in den Query-Plan integriert und nur einmal pro Abfrage ausgeführt.
Fazit: Ein kleiner Tipp mit großer Wirkung
Die Korrektur von 76 RLS-Policies in einem einzigen Schritt zeigt, wie leicht Performance-Probleme übersehen werden können – besonders, wenn sie in der Entwicklungsphase unsichtbar bleiben. Der Schlüssel liegt darin, den Supabase-Advisor regelmäßig zu nutzen und die Implementierung von RLS-Policies von Anfang an zu optimieren.
Mit minimalem Aufwand lässt sich der Fehler beheben und die Performance großer Tabellen deutlich verbessern. Wer seine RLS-Policies bereits jetzt überprüft, spart sich später möglicherweise kostspielige Nachbesserungen und bietet Nutzern eine reibungslosere Erfahrung.
KI-Zusammenfassung
Learn how wrapping auth.uid() in a subquery can reduce PostgreSQL row checks in Supabase RLS policies and improve query performance at scale.