Aller au contenu

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.

Vlad Tkachenko12 min de lecture
Un registre posé sur un comptoir, sa ligne la plus récente déjà barrée, et derrière lui un second registre dans une pièce dont la porte est barrée.

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.

La ligne dans auth.users est écrite puis retirée, si bien qu'il ne survit rien de l'échec hormis une ligne dans un log que la réponse ne mentionne jamais.

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 :

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

Quel badge la fonction porte, c'est toute la différence. security definer est la voie du bas, et c'est le badge du propriétaire.

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 definer et nomme sa cible en entier, en public.profiles plutôt qu'en profiles.
  • 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.

Écrit par

Vlad Tkachenko

Fondateur de Reeve

Je passe mes journées à examiner des applications créées avec Lovable, Bolt, v0, Cursor et Replit, et la courte liste d'erreurs qui y reviennent sans cesse.

En savoir plus sur l'auteur

À lire ensuite

Tous les articles

Sauvegardez votre base Supabase avant la prochaine modification

Reeve en garde une copie chiffrée à intervalles réguliers et la restaure pour vous en cas de problème.

Voir les sauvegardes Supabase

Vérification externe automatisée, pas un audit complet. L'absence de problème n'est pas une garantie de sécurité.