Zum Inhalt springen

Sicherheitsgrundlagen

Supabase-Fehler "violates foreign key constraint" beim Einfügen

Supabase "insert or update on table violates foreign key constraint": Deine Zeile zeigt auf eine, die es nicht gibt. Welche, warum, und die Lösung.

Vlad Tkachenko13 Min. Lesezeit
Eine Straße mit nummerierten Häusern, ein Grundstück leer und bernsteinfarben umrandet, davor ein Brief an die fehlende Nummer.

Kurz gesagt

  • Supabase "violates foreign key constraint" beim Einfügen (Postgres-Code 23503, HTTP 409 in deiner App) heißt: Die Zeile, die du gespeichert hast, nennt eine Zeile in einer anderen Tabelle, die es nicht gibt. Gespeichert wurde nichts. Die Meldung nennt deine Tabelle und die Regel, die Detailzeile die Tabelle, in der Postgres gesucht hat.
  • Die fehlende Zeile wurde nie gespeichert, erst als zweite gespeichert, zwischendurch gelöscht, oder sie liegt in einem anderen Projekt. Steht in der Detailzeile "users", ist fast immer auth.users gemeint, wo Supabase Auth alle registrierten Personen führt.
  • Die Prüfung liest jede Zeile der anderen Tabelle, egal was deine Row-Level-Security-Regeln sagen. Sie nimmt also auch eine Zeile an, die auf den Datensatz einer anderen Person zeigt, und deine Insert-Regel muss prüfen, wem dieser Datensatz gehört.

Du drückst in deiner App bei einem neuen Profil auf Speichern, und nichts ändert sich. Die Seite zeigt keine Meldung. Die Browserkonsole hat eine:

{
  "code": "23503",
  "details": "Key is not present in table \"users\".",
  "hint": null,
  "message": "insert or update on table \"profiles\" violates foreign key constraint \"profiles_id_fkey\""
}

Supabase "violates foreign key constraint" heißt, dass die Zeile, die du speichern wolltest, eine Zeile in einer anderen Tabelle nennt, und diese Zeile gibt es nicht. Hier trug das Profil eine id, die zu jemandem gehören sollte, der sich registriert hat, und Postgres hat niemanden mit dieser id gefunden. Gespeichert wurde nichts.

Am schnellsten verschwindet die Meldung, wenn du die Regel löschst. Danach gehen die Speichervorgänge durch, und die Zeilen, die sie anlegen, zeigen ins Leere: eine Bestellposition ohne Bestellung, ein Profil ohne Person dahinter. Finde zuerst heraus, welche Zeile fehlt und warum. Sie ist auf einem von wenigen Wegen verloren gegangen.

Was bedeutet "violates foreign key constraint" in Supabase?

Eine Spalte deiner Zeile muss zu einer Zeile in einer anderen Tabelle passen, der Wert, den du geschickt hast, passt zu keiner, und Postgres hat die ganze Zeile abgelehnt. Die Dokumentation von Postgres zu Constraints beschreibt die Regel in einem Satz: Ein Fremdschlüssel "specifies that the values in a column (or a group of columns) must match the values appearing in some row of another table."

Stell dir die andere Tabelle als Straße vor und jede ihrer Zeilen als Haus mit einer Nummer. Deine neue Zeile ist ein Brief, und die Spalte mit dem Fremdschlüssel ist die Hausnummer darauf. Postgres ist das Briefzentrum. Bevor es den Brief annimmt, schlägt es die Nummer in der Straße nach, und ein Brief an ein Haus, das es nicht gibt, kommt sofort zurück. Die Meldung sagt, welcher Brief zurückkam. Die Detailzeile sagt, in welcher Straße gesucht wurde.

FeldBeispielWas es dir sagt
code23503Der Code von Postgres für diesen Fehler, foreign_key_violation in seiner Liste der Codes
messageinsert or update on table "profiles" violates foreign key constraint "profiles_id_fkey"Die Tabelle, in die du geschrieben hast, dann der Name der Regel
detailsKey (id)=(…) is not present in table "users". oder dasselbe ohne den WertDie Spalte, der Wert, den du geschickt hast, und die Tabelle, in der Postgres gesucht hat. Der nächste Abschnitt zeigt, wann der Wert fehlt
hintnullNichts hinzuzufügen bei diesem Fehler
HTTP-Status409 ConflictPostgREST, der Teil von Supabase, der Aufrufe supabase.from(...) in SQL übersetzt, ordnet 23503 in seinem Code dem Status 409 zu

Der Wortlaut stammt aus der Datei in Postgres, die diese Prüfungen ausführt. Auch ein Update löst ihn aus, wenn es die Spalte auf eine Nummer ohne Haus setzt, und deshalb beginnt die Meldung mit "insert or update".

Eine Meldung, die mit update or delete on table beginnt, ist dieselbe Regel von der Straße aus gesehen: Du wolltest ein Haus abreißen, auf das noch Briefe zeigen. Sie kommt vom Löschen, und der Beitrag über das Löschen von Nutzern behandelt sie.

Nach welcher Zeile sucht Postgres?

Nach der aus der Detailzeile, in der Tabelle, die diese Zeile nennt. Zwei Dinge machen die Zeile schwerer zu lesen, als sie aussieht.

Manchmal fehlt der Wert. Postgres gibt Key (id)=(…) nur aus, wenn der Aufrufer diese Spalte deiner Tabelle lesen darf. Ist Row Level Security auf der Tabelle eingeschaltet, in die du geschrieben hast, lässt sein Code den Wert weg, mit dem Kommentar "we don't return any specifics to avoid leaking data", und du bekommst Key is not present in table "users". Das sieht eine Browseranfrage an eine geschützte Tabelle. Der Wert ist der, den deine App geschickt hat, also öffne die Entwicklertools deines Browsers, such im Netzwerk-Tab die Anfrage an /rest/v1/profiles und lies ihn aus dem Payload.

Zeigt deine Konsole den Wert bei einer Anfrage, die deine App mit dem öffentlichen Schlüssel gestellt hat, ist Row Level Security auf der Tabelle aus, in die du geschrieben hast. Eine Tabelle mit ausgeschalteten Regeln gibt einem Fremden ihre Zeilen genauso bereitwillig wie dir, und die Regeln für jede Tabelle einschalten zeigt den Schalter und die erste Regel. Unser kostenloser Scan liest deine Live-App mit dem öffentlichen Schlüssel, den jeder Besucher bekommt, und listet die Tabellen auf, die ihm antworten. Er braucht kein Konto.

Der Tabellenname hat kein Schema. Postgres gibt users aus, und in einem Supabase-Projekt zeigt eine Spalte, die auf Personen zeigt, fast immer auf auth.users, die Tabelle, in der Supabase Auth alle registrierten Personen führt. Sie steht nicht unter deinen öffentlichen Tabellen im Table Editor, und deshalb liest sich die Meldung, als nenne sie eine Tabelle, die du nie angelegt hast.

Um genau zu sehen, wohin die Regel zeigt, führ das im SQL Editor aus, mit dem Namen der Regel aus der Meldung:

select conrelid::regclass as on_table,
       pg_get_constraintdef(oid) as rule
from pg_constraint
where conname = 'profiles_id_fkey';

Die Antwort nennt die Tabelle, auf der die Regel sitzt, und was die Regel sagt, zum Beispiel FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE: deine Spalte, dann die Tabelle und Spalte, zu der sie passen muss. Dann schlag den Wert nach:

select id, email, created_at from auth.users where id = '…';

Kommt keine Zeile zurück, gibt es die Person, die dein Profil nennt, in diesem Projekt nicht.

Woher kommt eine Zeile, die ins Leere zeigt?

Von einer von wenigen Stellen, und die Tabelle in der Detailzeile sagt dir, wo du zuerst suchst.

Die Zeile, auf die sie zeigt, wurde nie gespeichert, oder erst als zweite. Eine Bestellung und ihre Positionen sind zwei Speichervorgänge. Speichert deine App zuerst die Positionen, oder speichert sie die Bestellung, bekommt einen Fehler, den sie nie liest, und macht mit den Positionen weiter, dann nennt jede Position eine Bestellung, die es nicht gibt. Speichere zuerst die Zeile, auf die gezeigt wird, nimm ihre id aus der Antwort, und lies den Fehler jedes Schritts vor dem nächsten:

const { data: order, error } = await supabase
  .from('orders')
  .insert({ user_id: user.id })
  .select('id')
  .single()

if (error) throw error

const { error: linesError } = await supabase
  .from('order_items')
  .insert(cart.map((item) => ({ ...item, order_id: order.id })))

.select() nach einem Insert liest die neue Zeile zurück, also braucht die Person eine Regel, die ihr erlaubt, ihre eigenen Bestellungen zu lesen. Die Dokumentation von Postgres zu Policies sagt, dass eine neue Zeile, die an den Leseregeln scheitert, das Speichern mit einem Fehler abbricht, und in Supabase heißt dieser Fehler new row violates row-level security policy. Die beiden Speichervorgänge sind getrennte Anfragen, also ist die Bestellung schon gespeichert, wenn die Positionen scheitern: Versuch die Positionen noch einmal, oder lösch die Bestellung.

Positionen, die vor ihrer Bestellung gespeichert werden, zeigen auf ein Haus, das noch nicht steht. Speichere die Bestellung, nimm ihre id aus der Antwort, dann speichere die Positionen.

Die id kam von woanders. Jedes Supabase-Projekt hat sein eigenes auth.users. Eine id aus deinem Testprojekt, eine, die ein KI-Builder aus einer Vorschau im Code stehen gelassen hat, oder Zeilen, die von einem Projekt in ein anderes kopiert wurden, nennen alle Personen, die das Live-Projekt nie gesehen hat. Wenn du Daten zwischen Projekten kopierst, kopier die Zeilen, auf die gezeigt wird, vor den Zeilen, die auf sie zeigen. Die Personen hinter einer Spalte, die auf auth.users zeigt, lassen sich so nicht kopieren: Sie registrieren sich im neuen Projekt und bekommen dabei neue ids. Ein Profil, dessen id die id der Person sein muss, geht auch kaputt, wenn die App sie mit einer frischen Zufallszahl füllt, etwa crypto.randomUUID(). Diese Nummer gehört zu keinem Haus. Nimm user.id aus der angemeldeten Sitzung.

Die Zeile wurde zwischendurch gelöscht. Ein Produkt, das aus deinem Shop entfernt wurde, während es in jemandes Warenkorb lag, oder ein Konto, das in einem anderen Tab gelöscht wurde. Der Speichervorgang danach zeigt auf ein Haus, das stand, als die Seite geladen wurde. Die Reparatur liegt in deiner App: Prüf auf error.code === '23503' und sag der Person, was sich geändert hat, etwa "Dieses Produkt ist nicht mehr verfügbar."

Ein Trigger, der zu früh läuft. Ein Trigger auf auth.users, der jede neue Person in profiles kopiert, muss laufen, nachdem die Person gespeichert ist. Die Dokumentation von Postgres zu Triggern sagt, die Änderung, die einen zeilenweisen BEFORE-Trigger auslöst, "is naturally not visible to SQL commands executed in a row-level BEFORE trigger, because it hasn't happened yet". Ein Profil, das aus einem Trigger before insert geschrieben wird, zeigt also auf eine Person, die es noch nicht gibt. Jede Registrierung scheitert, und Supabase meldet sie als "Database error saving new user". Das Muster von Supabase selbst, das dieser Beitrag vollständig zeigt, nutzt after insert.

Warum kommt ein leerer Wert durch?

Weil ein Fremdschlüssel nur Werte prüft, die da sind. Die Dokumentation von Postgres zu Constraints sagt es so: "Normally, a referencing row need not satisfy the foreign key constraint if any of its referencing columns are null." Null ist das Wort von Postgres für gar keinen Wert. Ein Brief ohne Hausnummer wird nie nachgeschlagen. Er landet im Sack und gehört zu keinem Haus.

Eine Bestellposition, die mit leerem order_id gespeichert wird, wird also angenommen und gehört zu keiner Bestellung. Deine App trifft später auf sie, als Position, die in niemandes Warenkorb auftaucht. Dieselbe Seite nennt die Reparatur: "declare the referencing column(s) as NOT NULL".

alter table public.order_items alter column order_id set not null;

Danach wird auch ein leerer Wert abgelehnt, mit einer Meldung, die die Spalte direkt nennt, null value in column violates not-null constraint. Der Befehl scheitert, solange es schon Zeilen mit leerem order_id gibt, also liste sie zuerst auf mit select * from public.order_items where order_id is null.

Kann eine Regel die gesuchte Zeile verstecken?

Nein. Postgres prüft einen Fremdschlüssel gegen jede Zeile der anderen Tabelle, egal was deine Row-Level-Security-Regeln sagen. Seine Dokumentation zu Row Security sagt das direkt: "Referential integrity checks, such as unique or primary key constraints and foreign key references, always bypass row security to ensure that data integrity is maintained." In dem Code, der die Prüfung ausführt, wechselt die Suche zum Eigentümer der anderen Tabelle, mit einem Flag, das selbst eine erzwungene Regel nicht greifen lässt.

Das Briefzentrum arbeitet also mit dem vollständigen Straßenplan. Deine App sieht die Straße durch ihre Regeln, die ihr vielleicht nur die Häuser zeigen, die sie besuchen darf, und die Prüfung nutzt diese Sicht nie. Ein 23503 heißt, dass das Haus für niemanden da ist. Gibt es die Zeile und deine App kann sie nicht sehen, geht das Speichern durch.

Deine App liest die Zeilen, die ihre Regeln erlauben. Die Fremdschlüssel-Prüfung liest jede Zeile, also wird eine Zeile angenommen, die auf ein Haus zeigt, das deine App nicht sehen kann.

Kann jemand eine Zeile an einen fremden Datensatz hängen?

Ja, außer deine Insert-Regel prüft, wem dieser Datensatz gehört. Die Prüfung, ob das Haus existiert, übergeht deine Regeln, also sagt sie Ja zu einem Haus, das jemand anderem gehört.

Nimm order_items mit dieser Insert-Regel, die sicher aussieht:

create policy "Add your own order lines"
on public.order_items for insert
to authenticated
with check (auth.uid() = user_id);

Sie prüft, dass die Position dir gehört, und schaut nie, zu wessen Bestellung sie kommt. Eine angemeldete Person, die ihre eigene user_id mit der order_id von jemand anderem schickt, besteht die Regel und den Fremdschlüssel, und eine Position, die sie geschrieben hat, steht jetzt auf der Bestellung einer fremden Person. Dieselbe Form setzt einen Kommentar unter den privaten Beitrag einer anderen Person oder ein Mitglied in ein Team, in das es niemand eingeladen hat.

Ersetze sie durch eine Regel, die sich die Bestellung ansieht:

create policy "Add lines to your own orders"
on public.order_items for insert
to authenticated
with check (
  exists (
    select 1 from public.orders
    where orders.id = order_items.order_id
      and orders.user_id = auth.uid()
  )
);

Lösch die erste Regel, wenn du diese hinzufügst. Postgres verknüpft Regeln derselben Art mit "OR", so steht es in seiner Dokumentation zu Policies, also würde die alte die Position allein weiter durchlassen.

Die Prüfung beantwortet außerdem eine Frage für jeden, der einfügen darf. Der Satz nach dem oben zitierten, in der Dokumentation zu Row Security, warnt davor: "Care must be taken when developing schemas and row level policies to avoid 'covert channel' leaks of information through such referential integrity checks." Ein Speichern, das mit 23503 scheitert, sagt, dass ein Haus nicht da ist. Eines, das gelingt, sagt, dass es da ist, in einer Straße, die die Person nicht lesen kann. Die Nutzer-ids von Supabase sind lange Zufallswerte, die niemand errät. Zeigt eine Spalte auf etwas Erratbares, etwa eine E-Mail-Adresse oder eine id, die ab 1 hochzählt, kann eine Person Werte einzeln ausprobieren und erfahren, welche es gibt.

Solltest du den Fremdschlüssel entfernen?

Nein. Ohne ihn geht das Speichern durch, die Zeile zeigt ins Leere, und ab dann sagt dir nichts mehr, wann das passiert. Ein Warenkorb zeigt eine Position für ein Produkt, das du letzten Monat entfernt hast, und ein Profil bleibt für eine Person zurück, die nicht mehr in auth.users steht.

Die Regel entscheidet auch, was mit diesen Zeilen passiert, wenn die Zeile gelöscht wird, auf die sie zeigen. Mit on delete cascade auf der Regel von profiles zu auth.users löscht das Löschen einer Person auch ihr Profil. Ohne die Regel bleibt das Profil, mit allen persönlichen Angaben, die es enthält. Der Beitrag über das Löschen von Nutzern zeigt, wie du festlegst, was jede Tabelle behält.

Zeigen alte Zeilen schon ins Leere, liste sie auf, bevor du eine Regel wieder hinzufügst oder verschärfst, denn Postgres weigert sich, einen Fremdschlüssel anzulegen, solange eine Zeile ihn verletzt:

select i.*
from public.order_items i
left join public.orders o on o.id = i.order_id
where i.order_id is not null
  and o.id is null;

Entscheide für jede einzelne, ob sie ihre Bestellung zurückbekommt oder geht. Das Löschen lässt sich ohne eine Kopie der Datenbank von vorher nicht rückgängig machen.

Bevor du die Zeilen reparierst

Behalte eine Kopie der Datenbank von vor dem Moment, in dem du Zeilen löschst, die ins Leere zeigen, Daten zwischen Projekten kopierst oder eine Regel änderst, und erfahre es, wenn eine Tabelle anfängt, Fremden zu antworten.

Reeve Care bewahrt eine Kopie deiner Supabase-Datenbank auf:

  • eine verschlüsselte Kopie nach Zeitplan, auf Reeves eigenem Speicher in der EU, außerhalb deines Supabase-Kontos
  • jede Zeile, wie sie war, bevor eine Aufräumaktion die falschen gelöscht hat
  • jede Kopie wird geprüft, bevor sie zählt, indem die Zeilen in jeder Tabelle gezählt werden
  • auch deine hochgeladenen Dateien, sobald du eine Storage-Zugangsberechtigung verbindest
  • Wiederherstellung mit einem Klick, vorher wird eine Sicherheitskopie des aktuellen Stands angelegt
  • alles, was Monitor macht

Reeve Monitor prüft weiter von außen:

  • ob deine App erreichbar ist, jede Minute geprüft
  • der vollständige Sicherheitscheck 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, sodass eine Tabelle, deren Regeln abgeschaltet wurden, dich erreicht, ohne dass du nachsehen musst
  • ein monatlicher Bericht in klarer Sprache über das, was er gesehen hat

Was du jetzt tun solltest

Was zu tun ist

  • Lies die Tabelle in der Detailzeile. In Supabase heißt users dort fast immer auth.users.
  • Finde den Wert: in der Detailzeile oder in der Anfrage, die deine App geschickt hat.
  • Speichere zuerst die Zeile, auf die gezeigt wird, nimm ihre id aus der Antwort, und lies jeden Fehler vor dem nächsten Speichern.
  • Gib einer Spalte, die auf etwas zeigen muss, not null, damit auch ein leerer Wert abgelehnt wird.
  • Lass jede Insert-Regel prüfen, wem die Zeile gehört, auf die die neue zeigt.
  • Behalte den Fremdschlüssel, und liste die Zeilen auf, die ins Leere zeigen, bevor du eine davon löschst.

Fang bei der Tabelle in der Detailzeile und der id an, die deine App geschickt hat. Löscht die Reparatur Zeilen oder kopiert sie zwischen Projekten, behalte eine Kopie der Datenbank von vor dem Ausführen.

FAQ

Was bedeutet insert or update on table violates foreign key constraint?

Die Zeile, die du speichern wolltest, zeigt auf eine Zeile in einer anderen Tabelle, die es nicht gibt, und Postgres hat sie abgelehnt. Geschrieben wurde nichts. Die Meldung nennt die Tabelle, in die du geschrieben hast, und die Regel, die Detailzeile die Tabelle, in der Postgres gesucht hat. Der Code ist 23503, und aus einer Supabase-App kommt er als HTTP 409.

Warum nennt der Fehler die Tabelle "users", obwohl ich keine habe?

Weil fast sicher auth.users gemeint ist, die Tabelle, in der Supabase Auth alle registrierten Personen führt. Postgres gibt den Namen einer Tabelle ohne ihr Schema aus. Deine Spalte zeigt auf eine Person, und die id darin gehört niemandem in diesem Projekt.

Kann Row Level Security einen Fremdschlüssel-Fehler auslösen?

Nein. Postgres prüft einen Fremdschlüssel gegen jede Zeile der anderen Tabelle, auch gegen Zeilen, die deine Regeln verbergen. Der Fehler heißt also, dass die Zeile wirklich fehlt. Dieselbe Tatsache wirkt auch andersherum: Eine Zeile, die auf einen Datensatz zeigt, den die Person nicht sehen kann, wird angenommen, außer deine Insert-Regel prüft, wem dieser Datensatz gehört.

Warum sagt die Detailzeile "Key is not present" ohne den Wert?

Postgres lässt den Wert weg, wenn Row Level Security auf der Tabelle eingeschaltet ist, in die du geschrieben hast, oder wenn der Aufrufer diese Spalte nicht lesen darf. Der Wert ist der, den deine App geschickt hat, also lies ihn aus der Anfrage im Netzwerk-Tab der Entwicklertools deines Browsers.

Soll ich den Fremdschlüssel entfernen, damit der Fehler aufhört?

Nein. Die Speichervorgänge gehen durch, und die Zeilen zeigen ins Leere: eine Bestellposition ohne Bestellung, ein Profil ohne Person dahinter. Die Regel entscheidet auch, was mit diesen Zeilen passiert, wenn ein Nutzer gelöscht wird. Finde heraus, welche Zeile fehlt, und speichere sie zuerst, oder korrigiere die id.

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.