Bases de la sécurité
"Database error saving new user" sur Supabase : où regarder
"Database error saving new user" veut dire que quelque chose rattaché à votre inscription Supabase a échoué. La vraie erreur est dans vos logs.

En bref
- "Database error saving new user" veut dire que Supabase a accepté l'inscription, que quelque chose qui y était rattaché a échoué et que le tout a été défait. Aucun compte n'a été créé.
- Le message est vague exprès, et le vrai est déjà consigné. La page de dépannage de Supabase vous envoie d'abord dans les logs Auth, puis dans les logs Postgres pour la ligne du dessous.
- Presque toujours, c'est un trigger sur auth.users qui n'atteint pas la table où il écrit. La réponse est une fonction security definer, et ce qu'il faut régler, c'est ce qu'on lui autorise.
Quelqu'un essaie de créer un compte sur votre app et reçoit une réponse qui ne parle pas de lui :
Database error saving new user
Personne ne peut s'inscrire. Le formulaire marchait quand vous l'avez construit, votre projet est debout, et tout ce que vous avez est une phrase qui ne nomme aucune table, aucune colonne et aucune raison.
Voici ce que guide après guide se trompe : ce message a été fait pour ne rien vous dire, et votre vraie erreur est déjà écrite ailleurs. Cherchez la chaîne et on vous tend un bloc de SQL à coller, en général la version de quelqu'un d'autre d'une fonction qui écrit dans une table portant ses noms de colonnes à lui. Le coller, soit ça marche, soit ça ajoute un second problème au premier. La ligne qui dit ce qui a échoué est dans les logs de votre propre projet, et elle nomme la table.
Ce que veut dire "Database error saving new user"
Supabase a accepté le nouveau compte puis l'a défait, parce que quelque chose rattaché à l'inscription a échoué.
Une inscription écrit une ligne, dans une table nommée auth.users que Supabase
gère pour vous. La plupart des projets rattachent quelque chose à cette écriture :
un trigger qui recopie la nouvelle personne dans une table profiles à vous, une
contrainte, une clé étrangère. Quand cette chose rattachée lève une erreur, la
base défait l'opération entière, et cela inclut la ligne dans auth.users. Il ne
reste aucun compte à moitié fait. Et il n'y a rien dans la réponse qui dise
laquelle de ces choses c'était.
Imaginez un comptoir avec une arrière-salle. Quelqu'un signe le registre au comptoir, et un employé emporte le nouveau nom à l'arrière pour le recopier dans un second registre. Si l'employé ne peut pas entrer dans la pièce où ce registre est rangé, l'inscription ne tient pas : la ligne du comptoir est barrée et on dit à la personne qu'il y a eu un problème. Ce que personne ne lui dit, c'est que le problème était une porte fermée à douze mètres de là.
Le flou est délibéré, et c'est le bon choix. Votre formulaire d'inscription est ouvert sur internet. Un message qui nommerait vos tables décrirait votre base à quiconque accepte d'y taper une adresse e-mail.
Si la réponse dit plutôt email rate limit exceeded, votre base n'y est pour
rien : Supabase a refusé d'envoyer l'e-mail de confirmation, et
cette erreur a sa propre page.
Où est la vraie erreur ?
Dans les logs de votre projet, en deux étapes, et la page de dépannage de Supabase donne l'ordre.
Reproduisez-la une fois avant de chercher, pour avoir une heure à faire correspondre. Tentez une inscription sur votre app en production, laissez-la échouer et notez le moment.
Puis, dans le tableau de bord Supabase :
- Ouvrez Logs et lisez les logs Auth. Trouvez l'entrée à cette heure. Elle porte l'erreur qu'Auth a consignée de son côté, plus précise que la phrase reçue par votre app.
- Si cette entrée pointe vers la base, revenez dans Logs et ouvrez les logs Postgres. La ligne au même moment est votre vraie erreur, écrite dans le vocabulaire ordinaire de Postgres : une colonne, une relation, un type, une permission.
La note de dépannage de Supabase pour ce message liste trois causes. Vous avez un
trigger ou une fonction de trigger sur la table auth.users qui contient une
erreur. Vous avez ajouté sur auth.users une contrainte qui n'est pas satisfaite.
Ou vous utilisez Prisma et il a cassé les permissions sur auth.users. C'est de
cette même note que vient le parcours en deux étapes ci-dessus.
Pour une app faite avec Lovable, Bolt, Replit ou v0, la première cause en couvre
la plupart, parce que recopier une nouvelle inscription dans une table profiles
est le motif auquel tous les builders ont recours, et un trigger est la façon
dont ça se fait.
Cause 1 : le trigger n'atteint pas votre table
Le rôle de base de données qui exécute votre trigger n'a pas le droit de sortir
du schéma auth, et votre trigger écrit en dehors.
Supabase l'explique sur son propre forum de discussion, et la formulation vaut
d'être reprise exactement. Le rôle de base de données qui gère
l'authentification, supabase_auth_admin, n'a que les permissions nécessaires à
ses propres tâches, de sorte que lorsqu'un trigger opéré par
supabase_auth_admin interagit en dehors du schéma auth, cela provoque une
erreur de permission.
C'est le badge de l'employé. Il ouvre le bureau de devant, parce que c'est là qu'est son travail. Le second registre est dans une pièce pour laquelle ce badge n'a jamais été fait, et une porte fermée ne dit rien de la raison pour laquelle elle l'est.
Dans le log Postgres, cela arrive comme une erreur de permission qui nomme la table où votre trigger écrivait.
La réponse, et ce qu'elle cède
security definer sur la fonction, qui la fait tourner avec les privilèges de
celui qui l'a créée plutôt que de celui qui l'a appelée.
Supabase le dit en une ligne : une fonction security definer conserve les
privilèges de l'utilisateur de base de données qui l'a créée, et tant que c'est
le rôle postgres, vos triggers d'auth devraient pouvoir interagir avec des
tables extérieures. L'employé ne porte plus son propre badge mais celui du
responsable.
C'est un vrai transfert d'autorité, donc il vaut mieux savoir ce que vous avez
écrit avant de le coller. Chaque instruction à l'intérieur de cette fonction
tourne désormais en tant que propriétaire. L'exemple de Supabase fixe le
search_path à vide, avec security definer set search_path = '', et c'est pour
cela que l'insert qui s'y trouve nomme public.profiles en entier plutôt que
profiles. Avec un search_path vide, un nom de table nu ne résout plus rien du
tout. On ne peut alors pas échanger discrètement la cible d'une fonction qui
porte les privilèges du propriétaire contre une autre table, selon ce que dit le
search_path de l'appelant.
Ce que cela n'autorise pas, c'est de reprendre security definer la prochaine
fois que quelque chose est refusé. Laissez la fonction faire une chose et
retourner, comme le fait la version de Supabase.
Cause 2 : une règle sur la table où vous écrivez
Row Level Security sur votre table profiles peut refuser l'insert, et
l'inscription échoue alors exactement de la même façon.
Le guide de Supabase sur ce motif vous dit d'activer Row Level Security pour cette table, et c'est la bonne consigne. Pour votre trigger, cela veut dire que l'insert doit être permis, soit par une policy qui le couvre, soit par une fonction qui tourne en tant que quelqu'un à qui les policies ne s'appliquent pas.
C'est ici que ça cesse d'être un bug pour devenir une décision prise par
quelqu'un à onze heures du soir. Personne ne peut s'inscrire, l'erreur ne dit
rien, et il y a dans le tableau de bord un interrupteur, libellé en langage
clair, qui fait disparaître l'erreur : couper Row Level Security sur profiles.
La tentative suivante marche. Ce qui arrive aussi, c'est qu'une table contenant
vos utilisateurs, avec tout ce que vous y gardez à côté de leurs noms, se met à
répondre à quiconque la demande.
Cette partie-là se voit de l'extérieur, et c'est ainsi que nous pouvons la compter. Notre scanner lit les apps en production comme le ferait un inconnu. Nous avons trouvé un projet Supabase derrière 8 435 des apps qu'il a notées. Sur 3 680 d'entre elles, la question « quelqu'un sans compte peut-il lire cette table » a obtenu une réponse exploitable, et 2 096 ont répondu oui. Dans 394 d'entre elles, la table ouverte portait un nom de table de personnes.
Une partie est délibérée, parce qu'un menu publié et une page d'annonces vivent dans des tables qu'un inconnu est censé lire, et notre scanner ne peut pas les distinguer d'une table de clients ouverte pour se débarrasser d'une erreur. Il rapporte ce qui a répondu. Si vous préférez voir votre propre réponse plutôt que raisonner dessus, notre scan gratuit lit votre app en production et vous dit quelles tables ont répondu. Cela prend environ 20 secondes et ne demande aucun compte : scannez votre app.
La policy dont a besoin un trigger est bien plus étroite que de couper les règles. Activer Row Level Security sur chaque table explique comment en écrire une, et « new row violates row-level security policy » est le message que vous recevez quand une policy refuse un insert que vous voyez de vos propres yeux.
Les autres causes, et comment chacune se lit dans le log
Quatre de plus, et la ligne du log les distingue en quelques mots.
Une table qui n'est pas là. La note de Supabase le couvre : une fonction de
trigger qui pointe vers une table qui n'existe pas ou a été supprimée. Une table
profiles supprimée puis reconstruite sous un autre nom emporte toutes les
inscriptions avec elle, et le log nomme la relation manquante.
Une contrainte sur auth.users. Un check ou une clé étrangère ajoutée sur
cette table que la nouvelle ligne ne satisfait pas. Cela vaut la peine de la
chercher même si vous êtes sûr de n'avoir ajouté aucun trigger, car une clé
étrangère entre une table à vous et l'utilisateur d'auth est la façon la plus
courante dont cette cause arrive. Laissée à son comportement par défaut, cette
même clé étrangère est celle qui refuse la suppression quand cette personne est
retirée plus tard, et
ce message a son propre article.
Une valeur qui n'est pas du bon type. Celle-ci apparaît mot pour mot. Un
trigger qui recopie des champs d'inscription depuis raw_user_meta_data les lit
avec ->>, et ->> renvoie du texte quelle que soit l'allure de la valeur en
entrant. Écrire ce texte dans une colonne date a produit, dans un cas rapporté,
column "birth_date" is of type date but expression is of type text. La
réparation est un cast, avec l'extraction entre parenthèses d'abord :
(new.raw_user_meta_data->>'birthDate')::date,
(new.raw_user_meta_data->>'height')::integer,
Des lignes insérées à la main. La note de Supabase couvre aussi les comptes
créés directement en SQL plutôt que par l'API Auth, où des colonnes obligatoires
du schéma auth restent à NULL au lieu de chaînes vides. Si quelqu'un a semé des
utilisateurs avec une instruction insert, c'est une réparation différente de
toutes celles ci-dessus.
Une ligne de plus que vous pouvez y croiser :
duplicate key value violates unique constraint. Elle signifie que la ligne
écrite par votre trigger existait déjà, par exemple parce que votre application
insère le même profil après l'inscription, et
cette erreur a son propre article.
Et une autre encore : null value in column … violates not-null constraint.
Votre trigger a écrit une valeur vide dans une colonne qui en exige une, le plus
souvent un champ d'inscription que le formulaire n'a jamais envoyé, et
elle a aussi son propre article.
La dernière que vous pouvez rencontrer est
insert or update on table "profiles" violates foreign key constraint. Elle veut
le plus souvent dire que le trigger a été créé en before insert, si bien que la
personne pour qui il écrit un profil n'est pas encore enregistrée. Le modèle de
Supabase plus bas utilise after insert, et
ce message a son propre article.
La version qui marche, en entier
Partez du motif publié par Supabase plutôt que d'un résultat de recherche, parce que quatre choses dedans portent le poids et qu'une paraphrase en perd généralement une.
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();
Les quatre sont security definer, le search_path vide, le public.profiles
écrit en entier, et le return new qui laisse l'inscription continuer. Vos
propres noms de colonnes vont dans l'insert, et chaque colonne que vous y nommez
doit exister et doit accepter ce que ->> lui donne.
Le guide de Supabase ajoute la phrase qui mérite d'être répétée : si le trigger échoue, il peut bloquer les inscriptions, donc testez votre code soigneusement. Créez un vrai compte sur votre app en production après chaque modification.
Garder votre inscription en marche après y avoir touché
Une inscription, c'est la partie de votre app que vous arrêtez de tester le jour où elle marche. Vous vous êtes inscrit une fois, vous avez un compte, et ensuite vous vous connectez. Le prompt suivant qui touche vos tables peut casser l'inscription pour tout le monde pendant que votre propre session continue de se comporter normalement.
Reeve Monitor regarde pour vous :
- les neuf vérifications toutes les heures, sur trois apps au plus
- un e-mail le jour où un nouveau scan note votre app moins bien que le précédent
- si l'app répond, toutes les 60 secondes
- un rapport mensuel de ce qui a été vu
Une règle coupée pour faire disparaître une erreur laisse une table de personnes lisible. Reeve Care garde une copie de votre base Supabase là où aucun changement de vos règles ne peut l'atteindre.
- une copie chiffrée chaque jour, conservée en dehors de votre compte Supabase
- chaque copie vérifiée avant de compter, en comptant les lignes de chaque table
- une restauration en un clic qui sauvegarde ce qui est là avant de le remplacer
- vos fichiers téléversés aussi, dès que vous connectez un accès Storage
- tout ce que fait Monitor
Quoi faire aujourd'hui
Que faire
- Reproduisez une inscription échouée et notez l'heure avant de changer quoi que ce soit.
- Lisez l'entrée du log Auth à ce moment, puis la ligne du log Postgres à la même heure si l'entrée Auth pointe vers la base. Cette ligne nomme la colonne, la relation ou la permission qui a échoué.
- Vérifiez que votre fonction de trigger porte
security defineret nomme sa cible en entier, enpublic.profilesplutôt qu'enprofiles. - Si une policy sur cette table refuse l'insert, écrivez une policy pour lui. Couper Row Level Security fait disparaître l'erreur et laisse cette table lisible par quiconque la demande.
- Créez un vrai compte sur votre app en production après chaque modification d'un trigger d'inscription. Votre propre session vous cache complètement une inscription cassée.
Commencez par la table où écrit votre trigger, puis lisez les policies de chaque table touchée la même semaine. La checklist de sécurité en 10 minutes couvre ce qui reste ouvert par ailleurs dans une app fraîchement lancée, et le guide de sécurité Supabase passe en revue le reste de ce qu'un inconnu peut atteindre.
FAQ
Que veut dire "Database error saving new user" ?
Cela veut dire que Supabase a écrit la nouvelle personne dans sa propre table auth.users, que quelque chose rattaché à cette écriture a échoué et que la base a défait l'opération entière, y compris la nouvelle ligne. Aucun compte n'a donc été créé et il ne reste rien à moitié fait à nettoyer. Le message ne nomme ni table ni raison, et c'est volontaire : votre formulaire d'inscription est ouvert sur internet, et une erreur précise décrirait votre base à quiconque y tape une adresse e-mail.
Où se trouve la vraie erreur ?
Dans les logs de votre projet, et la page de dépannage de Supabase donne l'ordre. Tentez une inscription, laissez-la échouer et notez l'heure. Ouvrez Logs et lisez l'entrée Auth de ce moment, elle porte ce qu'Auth a consigné de son côté. Si cette entrée pointe vers la base, ouvrez les logs Postgres à la même heure : cette ligne nomme la colonne, la relation ou la permission qui a réellement échoué.
Row Level Security sur ma table profiles casse-t-il l'inscription ?
C'est possible. Si votre trigger insère dans une table protégée par Row Level Security et qu'aucune policy n'autorise cet insert, l'insert est refusé et l'inscription échoue avec le même message vague. La réponse est une policy pour lui, ou une fonction qui tourne avec des privilèges auxquels les policies ne s'appliquent pas. Couper les règles sur cette table fait aussi disparaître l'erreur, et laisse une table contenant vos utilisateurs lisible par quiconque la demande.
Qu'est-ce que security definer, et est-ce prudent ?
Cela fait tourner la fonction avec les privilèges de celui qui l'a créée plutôt que de celui qui l'appelle, ce qui permet à un trigger exécuté par le rôle d'auth d'écrire dans une table à vous. C'est le motif que publie Supabase, et c'est un vrai transfert d'autorité, donc la fonction devrait faire une seule chose bien délimitée. Supabase fixe en plus le search_path à vide dans son exemple, et c'est pour cela que l'insert y nomme public.profiles en entier et pas seulement profiles.
Je n'ai jamais créé de trigger. Qu'est-ce que ça peut être d'autre ?
Trois autres choses. Une contrainte ou une clé étrangère ajoutée sur auth.users que la nouvelle ligne ne satisfait pas. Une fonction de trigger qui pointe vers une table renommée ou supprimée. Et la note de Supabase cite Prisma nommément, parce qu'il peut casser les permissions sur auth.users. La ligne du log Postgres vous dit laquelle vous avez.