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.

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.
| Feld | Beispiel | Was es dir sagt |
|---|---|---|
code | 23503 | Der Code von Postgres für diesen Fehler, foreign_key_violation in seiner Liste der Codes |
message | insert or update on table "profiles" violates foreign key constraint "profiles_id_fkey" | Die Tabelle, in die du geschrieben hast, dann der Name der Regel |
details | Key (id)=(…) is not present in table "users". oder dasselbe ohne den Wert | Die Spalte, der Wert, den du geschickt hast, und die Tabelle, in der Postgres gesucht hat. Der nächste Abschnitt zeigt, wann der Wert fehlt |
hint | null | Nichts hinzuzufügen bei diesem Fehler |
| HTTP-Status | 409 Conflict | PostgREST, 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.
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.
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
usersdort fast immerauth.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.