Vai al contenuto

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.

Vlad Tkachenko13 min di lettura
Una via di case numerate, con un lotto vuoto bordato di ambra, e davanti una lettera indirizzata al numero che manca.

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.

CampoEsempioChe cosa ti dice
code23503Il codice di Postgres per questo errore, chiamato foreign_key_violation nel suo elenco di codici
messageinsert or update on table "profiles" violates foreign key constraint "profiles_id_fkey"La tabella in cui hai scritto, poi il nome della regola
detailsKey (id)=(…) is not present in table "users"., oppure lo stesso senza il valoreLa colonna, il valore che hai mandato e la tabella in cui Postgres ha cercato. La sezione successiva spiega quando il valore viene omesso
hintnullNiente da aggiungere per questo errore
Stato HTTP409 ConflictPostgREST, 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.

Le righe salvate prima del loro ordine puntano a una casa che non c'è ancora. Salva l'ordine, prendi il suo id dalla risposta, poi salva le righe.

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.

La tua app legge le righe che le sue regole permettono. Il controllo della chiave esterna legge tutte le righe, quindi una riga che punta a una casa che la tua app non vede viene accettata.

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, users lì significa quasi sempre auth.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 null a 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.

Scritto da

Vlad Tkachenko

Fondatore di Reeve

Passo le giornate a guardare app costruite con Lovable, Bolt, v0, Cursor e Replit, e la breve lista di errori che vi ricompaiono di continuo.

Altro sull'autore

Da leggere dopo

Tutti gli articoli

Fai il backup del database Supabase prima della prossima modifica

Reeve ne tiene una copia cifrata a intervalli programmati e la ripristina per te se qualcosa va storto.

Vedi i backup Supabase

Controllo esterno automatizzato, non un audit completo. L'assenza di risultati non è una garanzia di sicurezza.