Zum Inhalt springen

Sicherheitsgrundlagen

"Database error saving new user" in Supabase: wo du nachsiehst

"Database error saving new user" heißt: etwas an deiner Supabase-Registrierung ist fehlgeschlagen. Der echte Fehler steht in deinen Logs.

Vlad Tkachenko11 Min. Lesezeit
Ein Eingangsbuch an einem Tresen, dessen neueste Zeile schon durchgestrichen ist, und dahinter ein zweites Buch in einem Raum mit verriegelter Tür.

Kurz gesagt

  • "Database error saving new user" heißt, dass Supabase die Registrierung angenommen hat, etwas daran fehlgeschlagen ist und das Ganze wieder zurückgenommen wurde. Es wurde kein Konto angelegt.
  • Die Meldung ist absichtlich nichtssagend, und die echte steht längst irgendwo. Supabase schickt dich in der eigenen Fehlerhilfe erst in die Auth-Logs und dann in die Postgres-Logs für die Zeile darunter.
  • Fast immer ist es ein Trigger auf auth.users, der die Tabelle nicht erreicht, in die er schreibt. Die Lösung ist eine security definer Funktion, und worauf es ankommt, ist, wie viel diese Funktion darf.

Jemand will ein Konto in deiner App anlegen und bekommt eine Antwort, in der es gar nicht um ihn geht:

Database error saving new user

Niemand kann sich registrieren. Das Formular lief, als du es gebaut hast, dein Projekt ist oben, und das Einzige, was du hast, ist ein Satz ohne Tabelle, ohne Spalte und ohne Grund.

Hier ist der Punkt, den ein Guide nach dem anderen falsch macht: diese Meldung wurde gebaut, um dir nichts zu sagen, und dein echter Fehler steht längst woanders geschrieben. Such den Satz, und man reicht dir einen Block SQL zum Einsetzen, meistens irgendwessen Version einer Funktion, die in eine Tabelle mit fremden Spaltennamen schreibt. Das klappt entweder, oder du hast jetzt zwei Probleme statt einem. Die Zeile, die sagt, was schiefging, liegt in den Logs deines eigenen Projekts, und sie nennt die Tabelle.

Was "Database error saving new user" bedeutet

Supabase hat das neue Konto angenommen und dann wieder zurückgenommen, weil etwas, das an der Registrierung hängt, fehlgeschlagen ist.

Eine Registrierung schreibt eine Zeile, in eine Tabelle namens auth.users, die Supabase für dich verwaltet. In den meisten Projekten hängt an diesem Schreibvorgang noch etwas: ein Trigger, der die neue Person in eine eigene profiles-Tabelle kopiert, ein Constraint, ein Fremdschlüssel. Wirft dieses angehängte Etwas einen Fehler, rollt die Datenbank den gesamten Vorgang zurück, und dazu gehört die Zeile in auth.users. Es bleibt kein halbfertiges Konto übrig. Es steht aber auch nichts in der Antwort, was dir sagt, welches angehängte Etwas es war.

Stell dir einen Tresen mit einem Hinterzimmer vor. Jemand trägt sich vorn in das Buch ein, und ein Angestellter nimmt den neuen Namen mit nach hinten, um ihn in ein zweites Buch zu übertragen. Kommt der Angestellte nicht in den Raum, in dem dieses zweite Buch liegt, dann gilt die Anmeldung nicht: die Zeile vorn wird durchgestrichen, und der Person wird gesagt, es habe ein Problem gegeben. Was ihr niemand sagt, ist, dass das Problem eine verschlossene Tür zwölf Meter weiter war.

Die Unschärfe ist Absicht, und sie ist richtig so. Dein Registrierungsformular steht offen im Internet. Eine Meldung, die deine Tabellen benennt, würde jedem deine Datenbank beschreiben, der bereit ist, eine E-Mail-Adresse einzutippen.

Lautet die Antwort stattdessen email rate limit exceeded, liegt es nicht an deiner Datenbank: Supabase hat sich geweigert, die Bestätigungsmail zu verschicken, und dieser Fehler hat eine eigene Seite.

Die Zeile in auth.users wird geschrieben und wieder herausgenommen, sodass von dem Fehlschlag nichts übrig bleibt außer einer Zeile in einem Log, das die Antwort nie erwähnt.

Wo steht der echte Fehler?

In den Logs deines Projekts, in zwei Schritten, und Supabase gibt in der eigenen Fehlerhilfe die Reihenfolge vor.

Reproduzier ihn einmal, bevor du suchst, damit du eine Uhrzeit zum Abgleichen hast. Versuch eine Registrierung in deiner Live-App, lass sie scheitern und merk dir den Zeitpunkt.

Dann im Supabase-Dashboard:

  1. Öffne Logs und lies die Auth-Logs. Such den Eintrag zu dieser Uhrzeit. Er trägt den Fehler, den Auth selbst aufgeschrieben hat, und der ist genauer als der Satz, den deine App bekommen hat.
  2. Zeigt dieser Eintrag auf die Datenbank, geh zurück zu Logs und öffne die Postgres-Logs. Die Zeile zum selben Zeitpunkt ist dein echter Fehler, geschrieben im gewöhnlichen Vokabular von Postgres: eine Spalte, eine Relation, ein Typ, eine Berechtigung.

Supabase nennt in der Fehlerhilfe zu dieser Meldung drei Ursachen. Du hast einen Trigger oder eine Trigger-Funktion auf der Tabelle auth.users, die einen Fehler hat. Du hast auf auth.users ein Constraint gelegt, das nicht erfüllt wird. Oder du benutzt Prisma, und es hat die Berechtigungen auf auth.users kaputt gemacht. Aus derselben Notiz stammt auch der zweistufige Weg durch die Logs oben.

Bei einer App aus Lovable, Bolt, Replit oder v0 deckt die erste Ursache die meisten Fälle ab, denn eine neue Registrierung in eine profiles-Tabelle zu kopieren ist das Muster, zu dem jeder Builder greift, und ein Trigger ist, wie es gemacht wird.

Ursache 1: Der Trigger erreicht deine Tabelle nicht

Die Datenbankrolle, die deinen Trigger ausführt, darf nicht aus dem auth-Schema heraus, und dein Trigger schreibt genau dorthin.

Supabase erklärt das auf dem eigenen Diskussionsboard, und die Formulierung lohnt sich genau. Die Datenbankrolle, die die Authentifizierung verwaltet, supabase_auth_admin, hat nur die Berechtigungen, die sie für ihre eigenen Aufgaben braucht, und wenn ein von supabase_auth_admin ausgeführter Trigger mit etwas außerhalb des auth-Schemas umgeht, gibt das einen Berechtigungsfehler.

Das ist der Ausweis des Angestellten. Er öffnet das vordere Büro, weil dort seine Arbeit liegt. Das zweite Buch liegt in einem Raum, für den der Ausweis nie ausgestellt wurde, und eine verschlossene Tür sagt nichts darüber, warum sie verschlossen ist.

Im Postgres-Log kommt das als Berechtigungsfehler an, der die Tabelle nennt, in die dein Trigger schreiben wollte.

Die Lösung, und was du damit abgibst

security definer an der Funktion, die sie mit den Rechten dessen laufen lässt, der sie angelegt hat, statt mit denen dessen, der sie aufruft.

Supabase sagt es in einem Satz: eine security definer Funktion behält die Rechte des Datenbankbenutzers, der sie angelegt hat, und solange das die Rolle postgres ist, sollten deine Auth-Trigger mit Tabellen außerhalb umgehen können. Der Angestellte trägt nicht mehr den eigenen Ausweis, sondern den des Chefs.

Das ist eine echte Übertragung von Befugnissen, also lohnt es sich zu wissen, was du da hingeschrieben hast, bevor du es einsetzt. Jede Anweisung in dieser Funktion läuft jetzt als Eigentümer. Supabase setzt im eigenen Beispiel den search_path auf leer, mit security definer set search_path = '', und deshalb nennt das Insert dort public.profiles vollständig statt nur profiles. Mit leerem search_path löst ein nackter Tabellenname überhaupt nichts mehr auf. Einer Funktion mit den Rechten des Eigentümers kann dann niemand still ihr Ziel gegen eine andere Tabelle tauschen, je nachdem, was der search_path des Aufrufers gerade sagt.

Welchen Ausweis die Funktion trägt, ist der ganze Unterschied. security definer ist die untere Spur, und das ist der Ausweis des Eigentümers.

Was das nicht erlaubt, ist, beim nächsten abgelehnten Zugriff wieder nach security definer zu greifen. Lass die Funktion eine Sache tun und zurückkehren, so wie Supabase es vormacht.

Ursache 2: Eine Regel auf der Tabelle, in die du schreibst

Row Level Security auf deiner profiles-Tabelle kann das Insert ablehnen, und die Registrierung scheitert dann auf genau dieselbe Weise.

Supabase sagt dir in der Anleitung zu diesem Muster, dass du Row Level Security für diese Tabelle anschalten sollst, und diese Anweisung ist richtig. Für deinen Trigger heißt das, dass das Insert erlaubt sein muss, entweder durch eine Policy, die es abdeckt, oder durch eine Funktion, die als jemand läuft, für den die Policies nicht gelten.

Hier hört das auf, ein Bug zu sein, und wird eine Entscheidung, die jemand um elf Uhr abends trifft. Niemand kann sich registrieren, der Fehler sagt nichts, und im Dashboard gibt es einen Schalter mit einer klar formulierten Beschriftung, der den Fehler verschwinden lässt: Row Level Security auf profiles ausschalten. Der nächste Versuch klappt. Was ebenfalls passiert, ist, dass eine Tabelle mit deinen Nutzerinnen und Nutzern, mit allem, was du neben ihren Namen darin hältst, jedem antwortet, der danach fragt.

Dieser Teil ist von außen sichtbar, und deshalb können wir ihn zählen. Unser Scanner liest Live-Apps so, wie es ein Fremder täte. Hinter 8.435 der Apps, die er benotet hat, haben wir ein Supabase-Projekt gefunden. Bei 3.680 davon hat die Frage "kann jemand ohne Konto diese Tabelle lesen" überhaupt eine brauchbare Antwort bekommen, und 2.096 haben mit Ja geantwortet. Bei 394 von ihnen war die offene Tabelle benannt wie eine Tabelle voller Menschen.

Ein Teil davon ist Absicht, denn eine veröffentlichte Speisekarte und eine Seite mit Anzeigen liegen in Tabellen, die ein Fremder lesen soll, und unser Scanner kann die nicht von einer Kundentabelle unterscheiden, die jemand geöffnet hat, um einen Fehler loszuwerden. Er berichtet, was geantwortet hat. Wenn du lieber deine eigene Antwort siehst, als darüber nachzudenken: unser kostenloser Scan liest deine Live-App und sagt dir, welche Tabellen geantwortet haben. Das dauert etwa 20 Sekunden und braucht kein Konto: App scannen.

Die Policy, die ein Trigger braucht, ist um einiges enger als die Regeln abzuschalten. Row Level Security für jede Tabelle anschalten zeigt, wie man eine schreibt, und "new row violates row-level security policy" ist die Meldung, die du bekommst, wenn eine Policy ein Insert ablehnt, das du mit eigenen Augen siehst.

Die anderen Ursachen, und wie jede im Log aussieht

Vier weitere, und die Zeile im Log unterscheidet sie in wenigen Worten.

Eine Tabelle, die es nicht gibt. Supabase deckt das mit ab: eine Trigger-Funktion, die auf eine Tabelle zeigt, die nicht existiert oder gelöscht wurde. Eine profiles-Tabelle, die gelöscht und unter anderem Namen neu gebaut wurde, reißt jede Registrierung mit, und das Log nennt die fehlende Relation.

Ein Constraint auf auth.users. Ein Check oder ein Fremdschlüssel auf dieser Tabelle, den die neue Zeile nicht erfüllt. Das lohnt sich auch dann zu suchen, wenn du sicher bist, keinen Trigger angelegt zu haben, denn ein Fremdschlüssel zwischen deiner eigenen Tabelle und dem Auth-Nutzer ist der häufigste Weg, auf dem diese Ursache ankommt. Mit seiner Standardeinstellung lehnt derselbe Fremdschlüssel später auch das Löschen dieser Person ab, und diese Meldung hat einen eigenen Beitrag.

Ein Wert mit dem falschen Typ. Dieser taucht wortwörtlich auf. Ein Trigger, der Registrierungsfelder aus raw_user_meta_data kopiert, liest sie mit ->>, und ->> gibt Text zurück, wie der Wert beim Hineingehen auch ausgesehen haben mag. Diesen Text in eine date-Spalte zu schreiben ergab in einem berichteten Fall column "birth_date" is of type date but expression is of type text. Die Reparatur ist ein Cast, mit der Extraktion zuerst in Klammern:

(new.raw_user_meta_data->>'birthDate')::date,
(new.raw_user_meta_data->>'height')::integer,

Von Hand eingefügte Zeilen. Supabase deckt auch Konten ab, die direkt per SQL statt über die Auth-API angelegt wurden und bei denen Pflichtspalten im auth-Schema als NULL stehen bleiben statt als leere Zeichenketten. Hat jemand Nutzer mit einem insert eingespielt, ist das eine andere Reparatur als alle oben.

Eine weitere Zeile, die dir dort begegnen kann: duplicate key value violates unique constraint. Sie heißt, dass die Zeile, die dein Trigger schreibt, schon da war, etwa weil deine App dasselbe Profil nach der Registrierung noch einmal einfügt, und dieser Fehler hat einen eigenen Beitrag.

Und noch eine: null value in column … violates not-null constraint. Dein Trigger hat einen leeren Wert in eine Spalte geschrieben, die einen verlangt, meist ein Registrierungsfeld, das das Formular nie geschickt hat, und auch sie hat einen eigenen Beitrag.

Die letzte, die dir begegnen kann, ist insert or update on table "profiles" violates foreign key constraint. Meist heißt sie, dass der Trigger mit before insert angelegt wurde, sodass die Person, für die er ein Profil schreibt, noch nicht gespeichert ist. Das Muster von Supabase weiter unten nutzt after insert, und diese Meldung hat einen eigenen Beitrag.

Die funktionierende Fassung, vollständig

Fang bei dem Muster an, das Supabase veröffentlicht, und nicht bei einem Suchergebnis, denn vier Dinge darin tragen die Last, und eine Paraphrase lässt meistens eines davon weg.

create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, first_name, last_name)
  values (new.id, new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data ->> 'last_name');
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

Die vier sind security definer, der leere search_path, das vollständig geschriebene public.profiles und das return new, mit dem die Registrierung weiterläuft. Deine eigenen Spaltennamen kommen in das Insert, und jede Spalte, die du dort nennst, muss existieren und muss annehmen, was ->> liefert.

Supabase stellt der Anleitung den Satz voran, den man wiederholen sollte: wenn der Trigger fehlschlägt, kann er Registrierungen blockieren, also teste deinen Code gründlich. Leg nach jeder Änderung daran ein echtes Konto in deiner Live-App an.

Wie deine Registrierung nach einer Änderung in Betrieb bleibt

Eine Registrierung ist der Teil deiner App, den du an dem Tag nicht mehr testest, an dem er läuft. Du hast dich einmal registriert, du hast ein Konto, und danach meldest du dich an. Der nächste Prompt, der deine Tabellen anfasst, kann die Registrierung für alle anderen kaputt machen, während deine eigene Sitzung sich völlig normal weiter verhält.

Reeve Monitor schaut für dich nach:

  • alle neun Checks jede Stunde, für bis zu drei Apps
  • eine E-Mail an dem Tag, an dem ein erneuter Scan deine App schlechter benotet als der vorige
  • ob die App erreichbar ist, jede Minute
  • ein monatlicher Bericht darüber, was gesehen wurde

Eine Regel, die zum Loswerden eines Fehlers abgeschaltet wurde, lässt eine Tabelle voller Menschen lesbar zurück. Reeve Care hält eine Kopie deiner Supabase-Datenbank dort, wo keine Änderung an deinen Regeln sie erreicht.

  • eine verschlüsselte Kopie jeden Tag, außerhalb deines Supabase-Kontos aufbewahrt
  • jede Kopie wird geprüft, bevor sie zählt, durch Zählen der Zeilen in jeder Tabelle
  • eine Wiederherstellung per Klick, die sichert, was jetzt da ist, bevor sie es ersetzt
  • deine hochgeladenen Dateien ebenfalls, sobald du eine Storage-Zugangsberechtigung verbindest
  • alles, was Monitor tut

Was heute zu tun ist

Was zu tun ist

  • Reproduzier eine fehlgeschlagene Registrierung und merk dir die Uhrzeit, bevor du irgendetwas änderst.
  • Lies den Auth-Log-Eintrag zu diesem Zeitpunkt, und danach die Postgres-Log-Zeile zur selben Zeit, falls der Auth-Eintrag auf die Datenbank zeigt. Diese Zeile nennt die Spalte, die Relation oder die Berechtigung, an der es gescheitert ist.
  • Prüf, ob deine Trigger-Funktion security definer trägt und ihr Ziel vollständig nennt, als public.profiles statt als profiles.
  • Lehnt eine Policy auf dieser Tabelle das Insert ab, schreib eine Policy dafür. Row Level Security abzuschalten löst den Fehler und lässt diese Tabelle für jeden lesbar, der danach fragt.
  • Leg nach jeder Änderung an einem Registrierungs-Trigger ein echtes Konto in deiner Live-App an. Deine eigene Sitzung verbirgt eine kaputte Registrierung vollständig vor dir.

Fang bei der Tabelle an, in die dein Trigger schreibt, und lies dann die Policies auf jeder Tabelle, die du in derselben Woche angefasst hast. Die 10-Minuten-Sicherheits-Checkliste deckt ab, was in einer frisch gestarteten App sonst noch offen bleibt, und der Supabase-Sicherheitsguide geht den Rest davon durch, was ein Fremder erreichen kann.

FAQ

Was bedeutet "Database error saving new user"?

Es bedeutet, dass Supabase die neue Person in die eigene Tabelle auth.users geschrieben hat, dass etwas an diesem Schreibvorgang fehlgeschlagen ist und die Datenbank daraufhin den ganzen Vorgang samt der neuen Zeile zurückgenommen hat. Es wurde also kein Konto angelegt, und es liegt nichts Halbfertiges herum. Die Meldung nennt absichtlich keine Tabelle und keinen Grund, denn dein Registrierungsformular steht offen im Internet, und ein genauer Fehler würde jedem, der eine E-Mail-Adresse einträgt, deine Datenbank beschreiben.

Wo finde ich den echten Fehler?

In den Logs deines Projekts, und Supabase gibt in der eigenen Fehlerhilfe die Reihenfolge vor. Versuch eine Registrierung, lass sie scheitern und merk dir die Uhrzeit. Öffne Logs und lies den Auth-Eintrag zu diesem Zeitpunkt, der trägt das, was Auth selbst aufgeschrieben hat. Zeigt dieser Eintrag auf die Datenbank, öffne die Postgres-Logs zur selben Zeit: diese Zeile nennt die Spalte, die Relation oder die Berechtigung, an der es wirklich gescheitert ist.

Bricht Row Level Security auf meiner profiles-Tabelle die Registrierung?

Das kann sein. Schreibt dein Trigger in eine Tabelle mit Row Level Security und erlaubt keine Policy dieses Insert, wird es abgelehnt, und die Registrierung scheitert mit derselben nichtssagenden Meldung. Die Lösung ist eine Policy dafür oder eine Funktion, die mit Rechten läuft, für die die Policies nicht gelten. Die Regeln auf dieser Tabelle abzuschalten lässt den Fehler ebenfalls verschwinden, und danach ist eine Tabelle mit deinen Nutzerinnen und Nutzern für jeden lesbar, der danach fragt.

Was ist security definer, und ist das sicher?

Damit läuft die Funktion mit den Rechten dessen, der sie angelegt hat, statt mit denen dessen, der sie aufruft. Genau das lässt einen Trigger, den die Auth-Rolle ausführt, in eine Tabelle von dir schreiben. Es ist das Muster, das Supabase veröffentlicht, und es ist eine echte Übertragung von Befugnissen, deshalb sollte die Funktion genau eine enge Sache tun. Supabase setzt im eigenen Beispiel außerdem den search_path auf leer, und deshalb nennt das Insert dort public.profiles vollständig und nicht nur profiles.

Ich habe nie einen Trigger angelegt. Was kann es sonst sein?

Drei andere Dinge. Ein Constraint oder ein Fremdschlüssel auf auth.users, den die neue Zeile nicht erfüllt. Eine Trigger-Funktion, die auf eine Tabelle zeigt, die umbenannt oder gelöscht wurde. Und Supabase nennt Prisma ausdrücklich, weil es die Berechtigungen auf auth.users kaputt machen kann. Welches davon es ist, sagt dir die Zeile im Postgres-Log.

Geschrieben von

Vlad Tkachenko

Gründer, Reeve

Ich schaue mir den ganzen Tag Apps an, die mit Lovable, Bolt, v0, Cursor und Replit gebaut wurden, und die kurze Liste von Fehlern, die darin immer wieder auftauchen.

Mehr über den Autor

Weiterlesen

Alle Artikel

Sichere deine Supabase-Datenbank vor der nächsten Änderung

Reeve sichert sie verschlüsselt nach Zeitplan und stellt sie für dich wieder her, wenn etwas schiefgeht.

Supabase-Backups ansehen

Automatisierte externe Prüfung, kein vollständiges Audit. Das Fehlen von Funden ist keine Garantie für Sicherheit.