Basi della sicurezza
"Database error saving new user" su Supabase: dove guardare
"Database error saving new user" vuol dire che qualcosa attaccato alla registrazione Supabase è fallito. L'errore vero sta nei tuoi log.

In breve
- "Database error saving new user" vuol dire che Supabase ha accettato la registrazione, qualcosa attaccato a quella scrittura è fallito e tutto quanto è stato annullato. Nessun account è stato creato.
- Il messaggio è vago di proposito, e quello vero è già scritto da qualche parte. La pagina di risoluzione di Supabase ti manda prima nei log di Auth e poi in quelli di Postgres, alla riga che sta sotto.
- Quasi sempre è un trigger su auth.users che non arriva alla tabella in cui scrive. La risposta è una funzione security definer, e la cosa da azzeccare è quanto le si permette di fare.
Qualcuno prova a creare un account sulla tua app e riceve una risposta che non parla di lui:
Database error saving new user
Nessuno riesce a registrarsi. Il modulo funzionava quando l'hai costruito, il tuo progetto è su, e l'unica cosa che hai è una frase che non nomina nessuna tabella, nessuna colonna e nessun motivo.
Ecco la parte che guida dopo guida sbaglia: questo messaggio è stato fatto per non dirti niente, e il tuo errore vero è già scritto da un'altra parte. Cerca la stringa e ti passano un blocco di SQL da incollare, di solito la versione di qualcun altro di una funzione che scrive in una tabella con i suoi nomi di colonna. Incollarlo o funziona, o ti aggiunge un secondo problema al primo. La riga che dice che cosa è fallito sta nei log del tuo progetto, e nomina la tabella.
Che cosa vuol dire "Database error saving new user"
Supabase ha accettato l'account nuovo e poi l'ha disfatto, perché qualcosa attaccato alla registrazione è fallito.
Una registrazione scrive una riga, in una tabella chiamata auth.users che
Supabase gestisce per te. Quasi tutti i progetti attaccano qualcosa a quella
scrittura: un trigger che copia la persona nuova in una tabella profiles tua,
un vincolo, una chiave esterna. Quando quella cosa attaccata solleva un errore,
il database annulla l'intera operazione, e questo comprende la riga in
auth.users. Non resta nessun account fatto a metà. E non c'è niente nella
risposta che dica quale delle cose attaccate fosse.
Pensa a un bancone con un retrobottega dietro. Qualcuno firma il registro al bancone, e un impiegato porta il nome nuovo sul retro per ricopiarlo su un secondo registro. Se l'impiegato non riesce a entrare nella stanza dove quel registro è tenuto, l'iscrizione non vale: la riga al bancone viene barrata e alla persona si dice che c'è stato un problema. Quello che nessuno le dice è che il problema era una porta chiusa dodici metri più in là.
La vaghezza è voluta, ed è la scelta giusta. Il tuo modulo di registrazione è aperto su internet. Un messaggio che nominasse le tue tabelle descriverebbe il tuo database a chiunque sia disposto a digitarci dentro un indirizzo email.
Se la risposta dice invece email rate limit exceeded, il database non c'entra:
Supabase si è rifiutato di inviare l'email di conferma, e
quell'errore ha una pagina sua.
Dov'è l'errore vero?
Nei log del tuo progetto, in due passi, e la pagina di risoluzione di Supabase dà l'ordine.
Riproducilo una volta prima di metterti a cercare, così hai un'ora con cui fare combaciare le cose. Prova una registrazione sulla tua app in produzione, lasciala fallire e segnati il momento.
Poi, nel pannello di Supabase:
- Apri Logs e leggi i log di Auth. Trova la voce a quell'ora. Porta l'errore che Auth ha annotato per conto suo, che è più preciso della frase ricevuta dalla tua app.
- Se quella voce punta al database, torna in Logs e apri quelli di Postgres. La riga allo stesso momento è il tuo errore vero, scritta nel vocabolario ordinario di Postgres: una colonna, una relazione, un tipo, un permesso.
La nota di risoluzione di Supabase per questo messaggio elenca tre cause. Hai un
trigger o una funzione di trigger sulla tabella auth.users che contiene un
errore. Hai aggiunto su auth.users un vincolo che non viene soddisfatto. Oppure
stai usando Prisma e ha rotto i permessi su auth.users. Da quella stessa nota
viene anche il percorso in due passi qui sopra.
Per un'app fatta con Lovable, Bolt, Replit o v0 la prima causa ne copre la
maggior parte, perché copiare una registrazione nuova in una tabella profiles è
lo schema a cui ricorre ogni builder, e un trigger è il modo in cui si fa.
Causa 1: il trigger non arriva alla tua tabella
Il ruolo di database che esegue il tuo trigger non può uscire dallo schema
auth, e il tuo trigger scrive fuori.
Supabase lo spiega sulla propria bacheca di discussione, e la formulazione vale
averla esatta. Il ruolo di database che gestisce l'autenticazione,
supabase_auth_admin, ha solo i permessi che gli servono per i propri compiti,
quindi quando un trigger operato da supabase_auth_admin interagisce fuori dallo
schema auth, questo provoca un errore di permessi.
Quello è il pass dell'impiegato. Apre l'ufficio davanti, perché è lì che sta il suo lavoro. Il secondo registro sta in una stanza per cui quel pass non è mai stato fatto, e una porta chiusa non dice niente sul perché lo sia.
Nel log di Postgres questo arriva come un errore di permessi che nomina la tabella in cui il tuo trigger stava scrivendo.
La risposta, e che cosa cede
security definer sulla funzione, che la fa girare con i privilegi di chi l'ha
creata invece che di chi l'ha chiamata.
Supabase lo dice in una riga: una funzione security definer conserva i privilegi
dell'utente di database che l'ha creata, e finché quello è il ruolo postgres, i
tuoi trigger di auth dovrebbero riuscire a interagire con tabelle esterne.
L'impiegato smette di portare il proprio pass e porta quello del responsabile.
È un vero passaggio di autorità, quindi conviene sapere che cosa hai scritto
prima di incollarlo. Ogni istruzione dentro quella funzione ora gira come
proprietario. L'esempio di Supabase mette il search_path a vuoto, con
security definer set search_path = '', ed è per questo che l'insert che sta lì
nomina public.profiles per intero invece di profiles. Con il search_path
vuoto un nome di tabella nudo non risolve più proprio niente. A una funzione che
porta i privilegi del proprietario nessuno può allora scambiare di nascosto il
bersaglio con un'altra tabella, a seconda di quello che dice il search_path di
chi chiama.
Quello che questo non autorizza è ricorrere a security definer la prossima
volta che qualcosa viene negato. Lascia la funzione a fare una cosa e tornare,
che è quello che fa la versione di Supabase.
Causa 2: una regola sulla tabella in cui scrivi
Row Level Security sulla tua tabella profiles può rifiutare l'insert, e la
registrazione fallisce allora esattamente allo stesso modo.
La guida di Supabase per questo schema ti dice di accendere Row Level Security su quella tabella, e quell'istruzione è giusta. Per il tuo trigger vuol dire che l'insert deve essere permesso, o da una policy che lo copre, o da una funzione che gira come qualcuno a cui le policy non si applicano.
Qui smette di essere un bug e diventa una decisione che qualcuno prende alle
undici di sera. Nessuno riesce a registrarsi, l'errore non dice niente, e nel
pannello c'è un interruttore, scritto in parole chiare, che fa sparire l'errore:
spegnere Row Level Security su profiles. Il tentativo dopo funziona. Quello che
succede anche è che una tabella con i tuoi utenti, con tutto quello che ci tieni
accanto ai loro nomi, comincia a rispondere a chiunque la chieda.
Quella parte si vede da fuori, ed è così che possiamo contarla. Il nostro scanner legge le app in produzione come farebbe uno sconosciuto. Abbiamo trovato un progetto Supabase dietro 8.435 delle app che ha valutato. Su 3.680 di quelle la domanda "qualcuno senza account può leggere questa tabella" ha ottenuto una risposta utilizzabile, e 2.096 hanno risposto di sì. In 394 di esse la tabella aperta era chiamata come una tabella di persone.
Una parte è voluta, perché un menù pubblicato e una pagina di annunci stanno in tabelle che uno sconosciuto deve leggere, e il nostro scanner non riesce a distinguerle da una tabella di clienti aperta per togliersi di mezzo un errore. Riferisce che cosa ha risposto. Se preferisci vedere la tua risposta invece di ragionarci sopra, la nostra scansione gratuita legge la tua app in produzione e ti dice quali tabelle hanno risposto. Ci vogliono circa 20 secondi e non serve nessun account: scansiona la tua app.
La policy di cui ha bisogno un trigger è parecchio più stretta che spegnere le regole. Accendere Row Level Security su ogni tabella spiega come scriverne una, e "new row violates row-level security policy" è il messaggio che ricevi quando una policy rifiuta un insert che vedi con i tuoi occhi.
Le altre cause, e come si legge ognuna nel log
Altre quattro, e la riga del log le distingue in poche parole.
Una tabella che non c'è. La nota di Supabase lo copre: una funzione di
trigger che punta a una tabella che non esiste o è stata cancellata. Una tabella
profiles cancellata e ricostruita con un altro nome si porta via ogni
registrazione, e il log nomina la relazione mancante.
Un vincolo su auth.users. Un check o una chiave esterna aggiunta su quella
tabella che la riga nuova non soddisfa. Vale la pena cercarlo anche se sei sicuro
di non aver aggiunto nessun trigger, perché una chiave esterna fra una tabella
tua e l'utente di auth è il modo più comune in cui questa causa arriva. Lasciata
al comportamento predefinito, la stessa chiave esterna è quella che rifiuta la
cancellazione quando più avanti quella persona viene eliminata, e
quel messaggio ha un articolo tutto suo.
Un valore che non è del tipo giusto. Questa compare parola per parola. Un
trigger che copia campi della registrazione da raw_user_meta_data li legge con
->>, e ->> restituisce testo comunque fosse fatto il valore in entrata.
Scrivere quel testo in una colonna date ha prodotto, in un caso riportato,
column "birth_date" is of type date but expression is of type text. La
riparazione è un cast, con l'estrazione prima fra parentesi:
(new.raw_user_meta_data->>'birthDate')::date,
(new.raw_user_meta_data->>'height')::integer,
Righe inserite a mano. La nota di Supabase copre anche gli account creati
direttamente in SQL invece che tramite l'API di Auth, dove colonne obbligatorie
dello schema auth restano a NULL invece che a stringhe vuote. Se qualcuno ha
seminato utenti con un'istruzione insert, quella è una riparazione diversa da
tutte quelle sopra.
Un'altra riga che puoi incontrare lì:
duplicate key value violates unique constraint. Vuol dire che la riga scritta
dal tuo trigger c'era già, per esempio perché la tua app inserisce lo stesso
profilo dopo la registrazione, e
quell'errore ha un articolo tutto suo.
E un'altra ancora: null value in column … violates not-null constraint. Il tuo
trigger ha scritto un valore vuoto in una colonna che ne richiede uno, quasi
sempre un campo di registrazione che il modulo non ha mai mandato, e
anche questo ha un articolo tutto suo.
L'ultima che puoi incontrare è
insert or update on table "profiles" violates foreign key constraint. Di solito
significa che il trigger è stato creato con before insert, quindi la persona
per cui scrive un profilo non è ancora salvata. Lo schema di Supabase più sotto
usa after insert, e
quel messaggio ha un articolo tutto suo.
La versione che funziona, per intero
Parti dallo schema che Supabase pubblica e non da un risultato di ricerca, perché quattro cose lì dentro reggono il peso e una parafrasi di solito ne perde una.
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();
Le quattro sono security definer, il search_path vuoto, il public.profiles
scritto per intero e il return new che lascia proseguire la registrazione. I
tuoi nomi di colonna vanno nell'insert, e ogni colonna che nomini lì deve
esistere e deve accettare quello che ->> le dà.
La guida di Supabase aggiunge la frase che vale la pena ripetere: se il trigger fallisce, può bloccare le registrazioni, quindi prova il tuo codice a fondo. Crea un account vero sulla tua app in produzione dopo ogni modifica che gli fai.
Tenere in piedi la registrazione dopo averci messo mano
Una registrazione è la parte della tua app che smetti di provare il giorno in cui funziona. Ti sei registrato una volta, hai un account, e da lì in poi fai login. Il prompt successivo che tocca le tue tabelle può rompere la registrazione per tutti gli altri mentre la tua sessione continua a comportarsi normalmente.
Reeve Monitor guarda al posto tuo:
- tutti e nove i controlli ogni ora, su un massimo di tre app
- una email il giorno in cui una nuova scansione valuta la tua app peggio della precedente
- se l'app risponde, ogni 60 secondi
- un rapporto mensile di quello che ha visto
Una regola spenta per togliersi di mezzo un errore lascia leggibile una tabella di persone. Reeve Care tiene una copia del tuo database Supabase dove nessuna modifica alle tue regole può arrivare.
- una copia cifrata ogni giorno, conservata fuori dal tuo account Supabase
- ogni copia verificata prima di contare, contando le righe di ogni tabella
- un ripristino in un clic che salva quello che c'è adesso prima di sostituirlo
- anche i tuoi file caricati, appena colleghi una credenziale di Storage
- tutto quello che fa Monitor
Che cosa fare oggi
Cosa fare
- Riproduci una registrazione fallita e segnati l'ora prima di cambiare qualunque cosa.
- Leggi la voce del log di Auth a quel momento, e poi la riga del log di Postgres alla stessa ora se la voce di Auth punta al database. Quella riga nomina la colonna, la relazione o il permesso che è fallito.
- Controlla che la tua funzione di trigger porti
security definere nomini il bersaglio per intero, comepublic.profilesinvece cheprofiles. - Se una policy su quella tabella sta rifiutando l'insert, scrivi una policy per lui. Spegnere Row Level Security toglie l'errore e lascia quella tabella leggibile da chiunque la chieda.
- Crea un account vero sulla tua app in produzione dopo ogni modifica a un trigger di registrazione. La tua sessione ti nasconde completamente una registrazione rotta.
Comincia dalla tabella in cui scrive il tuo trigger, poi leggi le policy di ogni tabella che hai toccato nella stessa settimana. La checklist di sicurezza in 10 minuti copre che altro tende a restare aperto in un'app appena lanciata, e la guida alla sicurezza di Supabase passa in rassegna il resto di quello che uno sconosciuto può raggiungere.
FAQ
Che cosa vuol dire "Database error saving new user"?
Vuol dire che Supabase ha scritto la persona nuova nella propria tabella auth.users, che qualcosa attaccato a quella scrittura è fallito e che il database ha annullato l'intera operazione, riga nuova compresa. Quindi non è stato creato nessun account e non resta niente a metà da ripulire. Il messaggio non nomina né tabelle né motivi di proposito, perché il tuo modulo di registrazione è aperto su internet e un errore preciso descriverebbe il tuo database a chiunque ci scriva dentro un indirizzo email.
Dove trovo l'errore vero?
Nei log del tuo progetto, e la pagina di risoluzione di Supabase dà l'ordine. Prova una registrazione, lasciala fallire e segnati l'ora. Apri Logs e leggi la voce di Auth di quel momento, che porta quello che Auth ha annotato per conto suo. Se quella voce punta al database, apri i log di Postgres alla stessa ora: quella riga nomina la colonna, la relazione o il permesso che è davvero fallito.
Row Level Security sulla mia tabella profiles rompe la registrazione?
Può farlo. Se il tuo trigger inserisce in una tabella con Row Level Security e nessuna policy permette quell'insert, l'insert viene rifiutato e la registrazione fallisce con lo stesso messaggio vago. La risposta è una policy per lui, oppure una funzione che gira con privilegi a cui le policy non si applicano. Spegnere le regole su quella tabella toglie anche l'errore, e lascia una tabella con i tuoi utenti leggibile da chiunque la chieda.
Che cos'è security definer ed è prudente?
Fa girare la funzione con i privilegi di chi l'ha creata invece che di chi la chiama, ed è proprio questo che permette a un trigger eseguito dal ruolo di auth di scrivere in una tabella tua. È lo schema che Supabase pubblica, ed è un vero passaggio di autorità, quindi la funzione dovrebbe fare una cosa sola e ben delimitata. Supabase inoltre mette il search_path a vuoto nel suo esempio, ed è per questo che lì l'insert nomina public.profiles per intero e non solo profiles.
Non ho mai creato nessun trigger. Che altro può essere?
Altre tre cose. Un vincolo o una chiave esterna aggiunta su auth.users che la riga nuova non soddisfa. Una funzione di trigger che punta a una tabella rinominata o cancellata. E la nota di Supabase cita Prisma per nome, perché può rompere i permessi su auth.users. Quale delle tre hai te lo dice la riga del log di Postgres.