Vai al contenuto

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.

Vlad Tkachenko12 min di lettura
Un registro appoggiato su un bancone con la riga più recente già barrata, e dietro un secondo registro in una stanza con la porta sbarrata.

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.

La riga in auth.users viene scritta e poi tolta, quindi del fallimento non sopravvive niente tranne una riga in un log che la risposta non nomina mai.

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:

  1. 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.
  2. 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.

Quale pass porta la funzione è tutta la differenza. security definer è la corsia in basso, ed è il pass del proprietario.

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 definer e nomini il bersaglio per intero, come public.profiles invece che profiles.
  • 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.

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.