Basi della sicurezza
Errore Supabase "violates foreign key constraint" all'inserimento
Supabase "insert or update on table violates foreign key constraint": la tua riga punta a una riga che non c'è. Quale, perché, e la soluzione.

In breve
- Supabase "violates foreign key constraint" all'inserimento (codice Postgres 23503, HTTP 409 nella tua app) significa che la riga che hai salvato nomina una riga di un'altra tabella che non esiste. Non è stato salvato niente. Il messaggio nomina la tua tabella e la regola, la riga di dettaglio nomina la tabella in cui Postgres ha cercato.
- La riga mancante non è mai stata salvata, è stata salvata per seconda, è stata cancellata nel frattempo o sta in un altro progetto. Quando la riga di dettaglio dice "users", quasi sempre intende auth.users, dove Supabase Auth tiene tutte le persone registrate.
- Il controllo legge ogni riga dell'altra tabella, qualunque cosa dicano le tue regole di Row Level Security. Quindi accetta anche una riga che punta al record di qualcun altro, e la tua regola di inserimento deve controllare di chi è quel record.
Premi Salva su un nuovo profilo nella tua app e non cambia niente. La pagina non mostra nessun messaggio. La console del browser ne ha uno:
{
"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" significa che la riga che hai provato a
salvare nomina una riga di un'altra tabella, e quella riga non esiste. Qui il
profilo portava un id che doveva appartenere a qualcuno che si è registrato, e
Postgres non ha trovato nessuno con quell'id. Non è stato salvato niente.
Il modo più rapido per far sparire il messaggio è cancellare la regola. Dopo i salvataggi passano, e le righe che creano puntano al vuoto: una riga d'ordine senza ordine, un profilo senza nessuna persona dietro. Scopri prima quale riga manca, e perché. È andata persa in uno di pochi modi.
Che cosa significa "violates foreign key constraint" in Supabase?
Significa che una colonna della tua riga deve corrispondere a una riga di un'altra tabella, il valore che hai mandato non corrisponde a nessuna, e Postgres ha rifiutato l'intera riga. La documentazione di Postgres sui vincoli definisce la regola in una frase: una chiave esterna "specifies that the values in a column (or a group of columns) must match the values appearing in some row of another table."
Pensa all'altra tabella come a una via, e a ciascuna delle sue righe come a una casa con un numero civico. La tua nuova riga è una lettera, e la colonna con la chiave esterna è il numero civico scritto sopra. Postgres è il centro di smistamento. Prima di accettare la lettera cerca il numero nella via, e una lettera per una casa che non esiste torna indietro subito. Il messaggio dice quale lettera è tornata. La riga di dettaglio dice in quale via si è cercato.
| Campo | Esempio | Che cosa ti dice |
|---|---|---|
code | 23503 | Il codice di Postgres per questo errore, chiamato foreign_key_violation nel suo elenco di codici |
message | insert or update on table "profiles" violates foreign key constraint "profiles_id_fkey" | La tabella in cui hai scritto, poi il nome della regola |
details | Key (id)=(…) is not present in table "users"., oppure lo stesso senza il valore | La colonna, il valore che hai mandato e la tabella in cui Postgres ha cercato. La sezione successiva spiega quando il valore viene omesso |
hint | null | Niente da aggiungere per questo errore |
| Stato HTTP | 409 Conflict | PostgREST, la parte di Supabase che trasforma le chiamate supabase.from(...) in SQL, associa 23503 allo stato 409 nel suo codice |
Il testo viene dal file di Postgres che esegue questi controlli. Anche un update lo provoca, quando cambia la colonna in un numero senza casa, ed è per questo che il messaggio comincia con "insert or update".
Un messaggio che comincia con update or delete on table è la stessa regola vista
dalla parte della via: hai provato ad abbattere una casa a cui puntano ancora
delle lettere. Quello nasce da una cancellazione, e
l'articolo sulla cancellazione degli utenti
lo spiega.
Quale riga sta cercando Postgres?
Quella della riga di dettaglio, nella tabella che quella riga nomina. Due cose la rendono più difficile da leggere di quanto sembri.
A volte il valore manca. Postgres scrive Key (id)=(…) solo quando chi chiama
può leggere quella colonna della tua tabella. Quando la Row Level Security è
attiva sulla tabella in cui hai scritto,
il suo codice
omette il valore, con il commento "we don't return any specifics to avoid leaking
data", e ricevi Key is not present in table "users". È quello che vede una
richiesta del browser a una tabella protetta. Il valore è quello che ha mandato la
tua app, quindi apri gli strumenti per sviluppatori del browser, trova la
richiesta a /rest/v1/profiles nella scheda Rete e leggilo nel payload.
Se la tua console mostra il valore per una richiesta che la tua app ha fatto con la chiave pubblica, la Row Level Security è spenta sulla tabella in cui hai scritto. Una tabella con le regole spente dà le sue righe a uno sconosciuto con la stessa facilità con cui le dà a te, e accendere le regole su ogni tabella mostra l'interruttore e la prima regola. La nostra scansione gratuita legge la tua app online con la chiave pubblica che riceve ogni visitatore ed elenca le tabelle che le rispondono. Non serve un account.
Il nome della tabella non ha lo schema. Postgres scrive users, e in un
progetto Supabase una colonna che punta a delle persone punta quasi sempre a
auth.users, la tabella dove Supabase Auth tiene tutte le persone registrate.
Non compare tra le tue tabelle pubbliche nel Table Editor, ed è per questo che il
messaggio sembra nominare una tabella che non hai mai creato.
Per vedere esattamente dove punta la regola, esegui questo nel SQL Editor con il nome della regola preso dal messaggio:
select conrelid::regclass as on_table,
pg_get_constraintdef(oid) as rule
from pg_constraint
where conname = 'profiles_id_fkey';
La risposta dà la tabella su cui sta la regola e che cosa dice la regola, per
esempio FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE: la tua
colonna, poi la tabella e la colonna a cui deve corrispondere. Poi cerca il
valore:
select id, email, created_at from auth.users where id = '…';
Se non torna nessuna riga, la persona che il tuo profilo nomina non esiste in questo progetto.
Da dove viene una riga che punta al vuoto?
Da pochi posti, e la tabella nella riga di dettaglio ti dice dove guardare per primo.
La riga a cui punta non è mai stata salvata, o è stata salvata per seconda. Un ordine e le sue righe sono due salvataggi. Se la tua app salva prima le righe, oppure salva l'ordine, riceve un errore che non legge mai e va avanti con le righe, ogni riga nomina un ordine che non esiste. Salva prima la riga a cui si punta, prendi il suo id dalla risposta e leggi l'errore di ogni passo prima del successivo:
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() dopo un insert rilegge la nuova riga, quindi la persona ha bisogno di
una regola che le permetta di leggere i propri ordini. La
documentazione di Postgres sulle policy
dice che una nuova riga che non passa le regole di lettura fa fallire il
salvataggio con un errore, e in Supabase quell'errore è
new row violates row-level security policy.
I due salvataggi sono richieste separate, quindi se le righe falliscono l'ordine è
già salvato: rimanda le righe oppure cancella l'ordine.
L'id veniva da un'altra parte. Ogni progetto Supabase ha il suo auth.users.
Un id copiato dal tuo progetto di prova, uno che un builder con IA ha lasciato nel
codice da un'anteprima, o righe copiate da un progetto a un altro nominano tutti
persone che il progetto online non ha mai visto. Quando copi dati tra progetti,
copia le righe a cui si punta prima delle righe che puntano a loro. Le persone
dietro una colonna che punta a auth.users non si copiano così: si registrano nel
nuovo progetto e ricevono nuovi id quando lo fanno. Un profilo il cui id deve
essere l'id della persona si rompe anche quando l'app lo riempie con un valore
casuale nuovo, come crypto.randomUUID(). Quel numero non è di nessuna casa. Usa
user.id della sessione con cui la persona ha fatto l'accesso.
La riga è stata cancellata nel frattempo. Un prodotto tolto dal tuo negozio
mentre era nel carrello di qualcuno, o un account cancellato in un'altra scheda.
Il salvataggio che segue punta a una casa che c'era quando la pagina è stata
caricata. La correzione sta nella tua app: controlla error.code === '23503' e dì
alla persona che cosa è cambiato, per esempio "Questo prodotto non è più
disponibile."
Un trigger che parte troppo presto. Un trigger su auth.users che copia ogni
nuova persona in profiles deve partire dopo che la persona è stata salvata. La
documentazione di Postgres sui trigger
dice che la modifica che fa scattare un trigger BEFORE per riga "is naturally
not visible to SQL commands executed in a row-level BEFORE trigger, because it
hasn't happened yet". Un profilo scritto da un trigger before insert punta quindi
a una persona che non esiste ancora. Ogni registrazione fallisce, e Supabase la
segnala come "Database error saving new user".
Lo schema di Supabase stesso, che quell'articolo riporta per intero, usa
after insert.
Perché un valore vuoto passa?
Perché una chiave esterna controlla solo i valori che ci sono. La documentazione di Postgres sui vincoli lo dice così: "Normally, a referencing row need not satisfy the foreign key constraint if any of its referencing columns are null." Null è la parola di Postgres per nessun valore. Una lettera senza numero civico non viene mai cercata. Finisce nel sacco e non appartiene a nessuna casa.
Quindi una riga d'ordine salvata con un order_id vuoto viene accettata, e non
appartiene a nessun ordine. La tua app la ritrova più tardi, come una riga che non
compare nel carrello di nessuno. La stessa pagina indica la correzione: "declare
the referencing column(s) as NOT NULL".
alter table public.order_items alter column order_id set not null;
Dopo, anche un valore vuoto viene rifiutato, con un messaggio che nomina
direttamente la colonna,
null value in column violates not-null constraint.
L'istruzione fallisce finché esistono già righe con un order_id vuoto, quindi
elencale prima con select * from public.order_items where order_id is null.
Una regola può nascondere la riga che cerca?
No. Postgres controlla una chiave esterna contro ogni riga dell'altra tabella, qualunque cosa dicano le tue regole di Row Level Security. La sua documentazione sulla sicurezza per riga lo dice chiaramente: "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." Nel codice che esegue il controllo, la ricerca passa al proprietario dell'altra tabella, con un flag che impedisce perfino a una regola forzata di applicarsi.
Il centro di smistamento lavora quindi con la mappa completa della via. La tua app
vede la via attraverso le sue regole, che magari le mostrano solo le case che può
visitare, e il controllo non usa mai quella vista. Un 23503 significa che la
casa non c'è per nessuno. Se la riga esiste e la tua app non può vederla, il
salvataggio passa.
Qualcuno può attaccare una riga a un record che non è suo?
Sì, a meno che la tua regola di inserimento non controlli di chi è quel record. Il controllo che la casa esista ignora le tue regole, quindi dice sì a una casa che appartiene a qualcun altro.
Prendi order_items con questa regola di inserimento, che sembra sicura:
create policy "Add your own order lines"
on public.order_items for insert
to authenticated
with check (auth.uid() = user_id);
Controlla che la riga sia tua, e non guarda mai a quale ordine si aggiunge. Una
persona con l'accesso fatto che manda il proprio user_id con l'order_id di
qualcun altro passa la regola e passa la chiave esterna, e una riga che ha scritto
lei ora sta nell'ordine di uno sconosciuto. La stessa forma mette un commento
sotto il post privato di un'altra persona, o un membro in un team in cui nessuno
lo ha invitato.
Sostituiscila con una regola che guardi l'ordine:
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()
)
);
Cancella la prima regola quando aggiungi questa. Postgres combina le regole dello stesso tipo con "OR", nelle parole della sua documentazione sulle policy, quindi la vecchia continuerebbe a far passare la riga da sola.
Il controllo risponde anche a una domanda per chiunque possa inserire. La frase
dopo quella citata sopra, nella documentazione sulla sicurezza per riga, lo
avverte: "Care must be taken when developing schemas and row level policies to
avoid 'covert channel' leaks of information through such referential integrity
checks." Un salvataggio che fallisce con 23503 dice che una casa non c'è. Uno
che riesce dice che c'è, in una via che la persona non può leggere. Gli id utente
di Supabase sono valori casuali lunghi che nessuno indovina. Quando una colonna
punta a qualcosa di indovinabile, come un indirizzo email o un id che conta da 1
in su, una persona può provare i valori uno alla volta e scoprire quali esistono.
Dovresti togliere la chiave esterna?
No. Senza, il salvataggio passa, la riga punta al vuoto, e da lì in poi niente ti
dice quando succede. Un carrello mostra una riga per un prodotto che hai tolto il
mese scorso, e un profilo resta lì per una persona che non è più in auth.users.
La regola decide anche che cosa succede a quelle righe quando viene cancellata la
riga a cui puntano. Con on delete cascade sulla regola che va da profiles a
auth.users, cancellare una persona cancella il suo profilo. Senza la regola, il
profilo resta, con tutti i dati personali che contiene.
L'articolo sulla cancellazione degli utenti
spiega come scegliere che cosa tiene ogni tabella.
Se delle righe vecchie puntano già al vuoto, elencale prima di rimettere una regola o di renderne una più severa, perché Postgres si rifiuta di aggiungere una chiave esterna finché una riga la viola:
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;
Decidi per ognuna se riavrà il suo ordine o se ne va. Cancellarle non si può annullare senza una copia del database di prima.
Prima di riparare le righe
Tieni una copia del database da prima di cancellare righe che puntano al vuoto, copiare dati tra progetti o cambiare una regola, e vieni a saperlo quando una tabella comincia a rispondere agli sconosciuti.
Reeve Care conserva una copia del tuo database Supabase:
- una copia cifrata secondo un calendario, sullo spazio di archiviazione di Reeve nell'UE, fuori dal tuo account Supabase
- ogni riga com'era prima che una pulizia cancellasse quelle sbagliate
- ogni copia verificata prima di contare, contando le righe di ogni tabella
- anche i tuoi file caricati, appena colleghi una credenziale di Storage
- un ripristino con un clic, con una copia di sicurezza dello stato attuale presa prima
- tutto quello che fa Monitor
Reeve Monitor continua a controllare da fuori:
- se la tua app è raggiungibile, controllato ogni minuto
- il controllo di sicurezza completo ogni ora, su un massimo di tre app
- una email il giorno in cui una nuova scansione valuta la tua app peggio della precedente, così una tabella le cui regole sono state disattivate ti raggiunge senza che tu debba andare a guardare
- un report mensile, in linguaggio semplice, di quello che ha visto
Che cosa fare adesso
Cosa fare
- Leggi la tabella nella riga di dettaglio. In Supabase,
userslì significa quasi sempreauth.users. - Trova il valore: nella riga di dettaglio o nella richiesta che ha mandato la tua app.
- Salva prima la riga a cui si punta, prendi il suo id dalla risposta e leggi ogni errore prima del salvataggio successivo.
- Dai
not nulla una colonna che deve puntare da qualche parte, così anche un valore vuoto viene rifiutato. - Fai controllare a ogni regola di inserimento di chi è la riga a cui punta quella nuova.
- Tieni la chiave esterna, ed elenca le righe che puntano al vuoto prima di cancellarne una.
Comincia dalla tabella nella riga di dettaglio e dall'id che ha mandato la tua app. Se la correzione cancella righe o le copia tra progetti, tieni una copia del database da prima di eseguirla.
FAQ
Che cosa significa insert or update on table violates foreign key constraint?
La riga che hai provato a salvare punta a una riga di un'altra tabella che non esiste, e Postgres l'ha rifiutata. Non è stato scritto niente. Il messaggio nomina la tabella in cui hai scritto e la regola, la riga di dettaglio nomina la tabella in cui Postgres ha cercato. Il codice è 23503, e da un'app Supabase arriva come HTTP 409.
Perché l'errore parla della tabella "users" se io non ne ho una?
Perché la tabella in questione è quasi certamente auth.users, dove Supabase Auth tiene tutte le persone registrate. Postgres scrive il nome di una tabella senza il suo schema. La tua colonna punta a una persona, e l'id che contiene non è di nessuno in questo progetto.
La Row Level Security può causare un errore di chiave esterna?
No. Postgres controlla una chiave esterna contro ogni riga dell'altra tabella, comprese le righe che le tue regole nascondono, quindi l'errore significa che la riga davvero non c'è. Lo stesso fatto vale al contrario: una riga che punta a un record che la persona non può vedere viene accettata, a meno che la tua regola di inserimento non controlli di chi è quel record.
Perché la riga di dettaglio dice "Key is not present" senza il valore?
Postgres omette il valore quando la Row Level Security è attiva sulla tabella in cui hai scritto, o quando chi chiama non può leggere quella colonna. Il valore è quello che ha mandato la tua app, quindi leggilo nella richiesta, nella scheda Rete degli strumenti per sviluppatori del browser.
Devo togliere la chiave esterna per far sparire l'errore?
No. I salvataggi passano e le righe puntano al vuoto: una riga d'ordine senza ordine, un profilo senza nessuno dietro. La regola decide anche che cosa succede a quelle righe quando un utente viene cancellato. Scopri quale riga manca e salvala prima, oppure correggi l'id.