Saltar al contenido

Fundamentos de seguridad

"Database error saving new user" en Supabase: dónde mirar

"Database error saving new user" significa que algo unido a tu registro de Supabase falló y se deshizo entero. El error real está en tus logs.

Vlad Tkachenko12 min de lectura
Un libro de registro en un mostrador con la línea más reciente ya tachada, y detrás un segundo libro en una sala con la puerta atrancada.

En resumen

  • "Database error saving new user" significa que Supabase aceptó el registro, algo unido a él falló y todo se deshizo. No se creó ninguna cuenta.
  • El mensaje es vago a propósito, y el de verdad ya está anotado. La propia guía de resolución de Supabase te manda primero a los logs de Auth y después a los de Postgres, a la línea que hay debajo.
  • Casi siempre es un trigger sobre auth.users que no alcanza la tabla en la que escribe. La solución es una función security definer, y lo que hay que acertar es cuánto se le permite hacer.

Alguien intenta crear una cuenta en tu app y recibe una respuesta que no va sobre él:

Database error saving new user

Nadie puede registrarse. El formulario funcionaba cuando lo construiste, tu proyecto está arriba, y lo único que tienes es una frase que no nombra ninguna tabla, ninguna columna ni ninguna razón.

Aquí está la parte que guía tras guía se equivoca: este mensaje está hecho para no decirte nada, y tu error real ya está anotado en otro sitio. Busca la cadena y te darán un bloque de SQL para pegar, normalmente la versión de alguien de una función que escribe en una tabla con los nombres de columna de esa persona. Pegarlo o funciona, o te suma un segundo problema al primero. La línea que dice qué falló está en los logs de tu propio proyecto, y nombra la tabla.

Qué significa "Database error saving new user"

Supabase aceptó la cuenta nueva y después la deshizo, porque algo unido al registro falló.

Un registro escribe una fila, en una tabla llamada auth.users que Supabase gestiona por ti. La mayoría de los proyectos une algo a esa escritura: un trigger que copia a la persona nueva a una tabla profiles tuya, una constraint, una clave foránea. Cuando eso que está unido lanza un error, la base de datos deshace la operación entera, y eso incluye la fila en auth.users. No queda ninguna cuenta a medio hacer. Tampoco hay nada en la respuesta que diga cuál de esas cosas unidas fue.

Piensa en un mostrador con una trastienda detrás. Alguien firma el libro en el mostrador, y un empleado se lleva el nombre nuevo atrás para copiarlo en un segundo libro. Si el empleado no puede entrar en la sala donde se guarda ese libro, la inscripción no vale: la línea del mostrador se tacha y a la persona se le dice que ha habido un problema. Lo que nadie le dice es que el problema era una puerta cerrada a doce metros.

La vaguedad es deliberada, y es la decisión correcta. Tu formulario de registro está abierto a internet. Un mensaje que nombrase tus tablas le estaría describiendo tu base de datos a cualquiera dispuesto a teclear un correo en él.

Si la respuesta dice email rate limit exceeded, el problema no es tu base de datos: Supabase se negó a enviar el correo de confirmación, y ese error tiene su propia página.

La fila de auth.users se escribe y se vuelve a sacar, así que del fallo no sobrevive nada salvo una línea en un log que la respuesta nunca menciona.

¿Dónde está el error real?

En los logs de tu proyecto, en dos pasos, y la propia guía de resolución de Supabase da el orden.

Reprodúcelo una vez antes de ponerte a buscar, para tener una hora con la que cuadrar. Intenta un registro en tu app en producción, deja que falle y apunta el momento.

Después, en el panel de Supabase:

  1. Abre Logs y lee los logs de Auth. Busca la entrada de esa hora. Lleva el error que Auth anotó por su cuenta, que es más concreto que la frase que recibió tu app.
  2. Si esa entrada apunta a la base de datos, vuelve a Logs y abre los de Postgres. La línea del mismo momento es tu error real, escrito en el vocabulario corriente de Postgres: una columna, una relación, un tipo, un permiso.

La nota de resolución de Supabase para este mensaje enumera tres causas. Tienes un trigger o una función de trigger sobre la tabla auth.users que tiene un error. Has añadido sobre auth.users una constraint que no se cumple. O estás usando Prisma y ha roto los permisos sobre auth.users. De esa misma nota sale el recorrido de dos pasos por los logs de arriba.

En una app hecha con Lovable, Bolt, Replit o v0, la primera causa cubre la mayoría, porque copiar un registro nuevo a una tabla profiles es el patrón al que recurre cualquier builder, y un trigger es como se hace.

Causa 1: el trigger no alcanza tu tabla

El rol de base de datos que ejecuta tu trigger no puede salir del esquema auth, y tu trigger escribe fuera de él.

Supabase lo explica en su propio foro de discusión, y la formulación conviene tenerla exacta. El rol de base de datos que gestiona la autenticación, supabase_auth_admin, solo tiene los permisos que necesita para sus propias tareas, así que cuando un trigger operado por supabase_auth_admin interactúa fuera del esquema auth, eso provoca un error de permisos.

Esa es la credencial del empleado. Abre la oficina de delante, porque ahí está su trabajo. El segundo libro está en una sala para la que esa credencial nunca se emitió, y una puerta cerrada no informa de por qué lo está.

En el log de Postgres esto llega como un error de permisos que nombra la tabla en la que tu trigger estaba escribiendo.

La solución, y lo que cede

security definer en la función, que la hace correr con los privilegios de quien la creó en lugar de los de quien la llamó.

Supabase lo dice en una línea: una función security definer conserva los privilegios del usuario de base de datos que la creó, y mientras ese sea el rol postgres, tus triggers de auth deberían poder interactuar con tablas de fuera. El empleado deja de llevar su propia credencial y lleva la del encargado.

Eso es una cesión real de autoridad, así que conviene saber qué has escrito antes de pegarlo. Cada sentencia dentro de esa función corre ahora como el propietario. El propio ejemplo de Supabase fija el search_path a vacío, con security definer set search_path = '', y por eso el insert que hay en él nombra public.profiles entero en lugar de profiles. Con el search_path vacío un nombre de tabla a secas no resuelve a nada. A una función que lleva los privilegios del propietario no se le puede cambiar entonces el destino en silencio por otra tabla, según lo que diga el search_path de quien la llama.

Qué credencial lleva la función es toda la diferencia. security definer es el carril de abajo, y es la credencial del propietario.

Lo que esto no autoriza es recurrir a security definer la próxima vez que algo sea denegado. Deja la función haciendo una cosa y devolviendo, que es lo que hace la versión de Supabase.

Causa 2: una regla en la tabla en la que escribes

Row Level Security en tu tabla profiles puede rechazar el insert, y entonces el registro falla exactamente igual.

La guía de Supabase para este patrón te dice que actives Row Level Security en esa tabla, y esa indicación es la correcta. Para tu trigger significa que el insert tiene que estar permitido, o por una policy que lo cubra, o por una función que corra como alguien a quien las policies no se le aplican.

Aquí es donde esto deja de ser un bug y pasa a ser una decisión que alguien toma a las once de la noche. Nadie puede registrarse, el error no dice nada, y en el panel hay un interruptor, rotulado en lenguaje claro, que hace desaparecer el error: apagar Row Level Security en profiles. El siguiente intento funciona. Lo que también pasa es que una tabla con tus usuarios, con todo lo que guardes en ella junto a sus nombres, empieza a responderle a cualquiera que la pida.

Esa parte se ve desde fuera, que es como podemos contarla. Nuestro escáner lee apps en producción como lo haría un desconocido. Encontramos un proyecto de Supabase detrás de 8.435 de las apps que ha calificado. En 3.680 de ellas la pregunta "¿puede alguien sin cuenta leer esta tabla?" obtuvo una respuesta utilizable, y 2.096 respondieron que sí. En 394 de ellas la tabla abierta estaba nombrada como una tabla de personas.

Parte de eso es deliberado, porque una carta publicada y una página de anuncios viven en tablas que un desconocido debe leer, y nuestro escáner no puede distinguirlas de una tabla de clientes que alguien abrió para quitarse un error de encima. Informa de lo que respondió. Si prefieres ver tu propia respuesta antes que razonar sobre ella, nuestro escaneo gratuito lee tu app en producción y te dice qué tablas contestaron. Tarda unos 20 segundos y no necesita cuenta: escanea tu app.

La policy que necesita un trigger es bastante más estrecha que apagar las reglas. Activar Row Level Security en todas las tablas explica cómo escribir una, y "new row violates row-level security policy" es el mensaje que recibes cuando una policy rechaza un insert que estás viendo con tus propios ojos.

Las otras causas, y cómo se lee cada una en el log

Cuatro más, y la línea del log las distingue en unas pocas palabras.

Una tabla que no está. La nota de Supabase lo cubre: una función de trigger que apunta a una tabla que no existe o fue borrada. Una tabla profiles borrada y reconstruida con otro nombre se lleva por delante cada registro, y el log nombra la relación que falta.

Una constraint sobre auth.users. Un check o una clave foránea añadida a esa tabla que la fila nueva no cumple. Vale la pena buscarla incluso si estás seguro de no haber creado ningún trigger, porque una clave foránea entre una tabla tuya y el usuario de auth es la vía más común por la que llega esta causa. Con su comportamiento por defecto, esa misma clave foránea es la que rechaza el borrado cuando más tarde se elimina a esa persona, y ese mensaje tiene su propio artículo.

Un valor que no es del tipo correcto. Esta aparece palabra por palabra. Un trigger que copia campos del registro desde raw_user_meta_data los lee con ->>, y ->> devuelve texto independientemente del aspecto que tuviera el valor al entrar. Escribir ese texto en una columna date produjo, en un caso reportado, column "birth_date" is of type date but expression is of type text. El arreglo es un cast, con la extracción entre paréntesis primero:

(new.raw_user_meta_data->>'birthDate')::date,
(new.raw_user_meta_data->>'height')::integer,

Filas insertadas a mano. La nota de Supabase cubre también las cuentas creadas directamente con SQL en lugar de por la API de Auth, donde columnas obligatorias del esquema auth se quedan en NULL en lugar de en cadenas vacías. Si alguien sembró usuarios con una sentencia insert, ese es un arreglo distinto de todos los de arriba.

Una línea más que puedes encontrarte ahí: duplicate key value violates unique constraint. Significa que la fila que escribe tu trigger ya estaba, por ejemplo porque tu app inserta el mismo perfil después del registro, y ese error tiene su propio artículo.

Y una más: null value in column … violates not-null constraint. Tu trigger escribió un valor vacío en una columna que exige uno, casi siempre un campo de registro que el formulario nunca envió, y ese también tiene su propio artículo.

La última que puedes encontrarte es insert or update on table "profiles" violates foreign key constraint. Casi siempre significa que el trigger se creó con before insert, así que la persona para la que escribe un perfil todavía no está guardada. El patrón de Supabase de más abajo usa after insert, y ese mensaje tiene su propio artículo.

La versión que funciona, entera

Parte del patrón que publica Supabase y no de un resultado de búsqueda, porque cuatro cosas de ahí sostienen el peso y una paráfrasis suele dejarse 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();

Las cuatro son security definer, el search_path vacío, el public.profiles escrito entero y el return new que deja continuar el registro. Tus propios nombres de columna van en el insert, y cada columna que nombres ahí tiene que existir y tiene que aceptar lo que ->> le da.

La guía de Supabase añade la frase que conviene repetir: si el trigger falla, puede bloquear los registros, así que prueba tu código a fondo. Crea una cuenta real en tu app en producción después de cada cambio que le hagas.

Cómo mantener tu registro funcionando después de tocarlo

Un registro es la parte de tu app que dejas de probar el día en que funciona. Te registraste una vez, tienes cuenta, y a partir de ahí inicias sesión. El siguiente prompt que toque tus tablas puede romper el registro para todo el mundo mientras tu propia sesión sigue comportándose con normalidad.

Reeve Monitor mira por ti:

  • las nueve comprobaciones cada hora, en hasta tres apps
  • un correo el día en que un nuevo escaneo califica tu app peor que el anterior
  • si la app está disponible, cada 60 segundos
  • un informe mensual de lo que vio

Una regla apagada para quitarse un error de encima deja legible una tabla de personas. Reeve Care guarda una copia de tu base de datos de Supabase donde ningún cambio en tus reglas puede alcanzarla.

  • una copia cifrada cada día, guardada fuera de tu cuenta de Supabase
  • cada copia verificada antes de contar, contando las filas de cada tabla
  • una restauración en un clic que guarda lo que hay ahora antes de reemplazarlo
  • también tus archivos subidos, en cuanto conectes una credencial de Storage
  • todo lo que hace Monitor

Qué hacer hoy

Qué hacer

  • Reproduce un registro fallido y apunta la hora antes de cambiar nada.
  • Lee la entrada del log de Auth de ese momento, y después la línea del log de Postgres de la misma hora si la entrada de Auth apunta a la base de datos. Esa línea nombra la columna, la relación o el permiso que falló.
  • Comprueba que tu función de trigger lleva security definer y nombra su destino entero, como public.profiles en lugar de profiles.
  • Si una policy de esa tabla está rechazando el insert, escribe una policy para él. Apagar Row Level Security quita el error y deja esa tabla legible para cualquiera que la pida.
  • Crea una cuenta real en tu app en producción después de cada cambio en un trigger de registro. Tu propia sesión te oculta por completo un registro roto.

Empieza por la tabla en la que escribe tu trigger, y lee después las policies de cada tabla que tocaste esa misma semana. La lista de seguridad de 10 minutos cubre qué más suele quedarse abierto en una app recién lanzada, y la guía de seguridad de Supabase repasa el resto de lo que un desconocido puede alcanzar.

Preguntas frecuentes

¿Qué significa "Database error saving new user"?

Significa que Supabase escribió a la persona nueva en su propia tabla auth.users, que algo unido a esa escritura falló y que la base de datos deshizo la operación entera, incluida la fila nueva. Así que no se creó ninguna cuenta y no queda nada a medias que limpiar. El mensaje no nombra ninguna tabla ni ninguna razón a propósito, porque tu formulario de registro está abierto a internet y un error concreto le describiría tu base de datos a cualquiera que escribiese un correo en él.

¿Dónde encuentro el error real?

En los logs de tu proyecto, y la propia guía de resolución de Supabase da el orden. Prueba un registro, deja que falle y apunta la hora. Abre Logs y lee la entrada de Auth de ese momento, que lleva lo que Auth anotó por su cuenta. Si esa entrada apunta a la base de datos, abre los logs de Postgres a la misma hora: esa línea nombra la columna, la relación o el permiso que falló de verdad.

¿Row Level Security en mi tabla profiles rompe el registro?

Puede. Si tu trigger inserta en una tabla con Row Level Security y ninguna policy permite ese insert, el insert se rechaza y el registro falla con el mismo mensaje vago. La solución es una policy para él, o una función que corra con privilegios a los que las policies no se aplican. Apagar las reglas en esa tabla también quita el error, y deja una tabla con tus usuarios legible para cualquiera que la pida.

¿Qué es security definer y es seguro?

Hace que la función corra con los privilegios de quien la creó en lugar de los de quien la llama, que es lo que permite a un trigger ejecutado por el rol de auth escribir en una tabla tuya. Es el patrón que publica Supabase, y es una cesión real de autoridad, así que la función debería hacer una sola cosa concreta. Supabase además fija el search_path a vacío en su ejemplo, y por eso el insert nombra public.profiles entero y no solo profiles.

Nunca creé ningún trigger. ¿Qué más puede ser?

Otras tres cosas. Una constraint o una clave foránea añadida a auth.users que la fila nueva no cumple. Una función de trigger que apunta a una tabla renombrada o borrada. Y la nota de Supabase menciona Prisma expresamente, porque puede romper los permisos sobre auth.users. La línea del log de Postgres te dice cuál de ellas tienes.

Escrito por

Vlad Tkachenko

Fundador de Reeve

Me paso el día mirando apps creadas con Lovable, Bolt, v0, Cursor y Replit, y la corta lista de errores que aparecen en ellas una y otra vez.

Más sobre el autor

Sigue leyendo

Todos los artículos

Haz una copia de tu base de datos de Supabase antes del próximo cambio

Reeve la guarda cifrada con regularidad y la restaura por ti si algo falla.

Ver copias de Supabase

Comprobación externa automatizada, no una auditoría completa. La ausencia de hallazgos no es garantía de seguridad.