Saltar al contenido

Fundamentos de seguridad

"Invalid login credentials" en Supabase: las tres causas

Supabase dice "Invalid login credentials" ante una contraseña errónea, un email desconocido y una cuenta de Google. Cómo saber cuál es.

Vlad Tkachenko12 min de lectura
Un teclado junto a una puerta con su única luz encendida en rojo y, delante, una tarjeta de acceso para la que el teclado no tiene ranura.

En resumen

  • Supabase responde "Invalid login credentials" a tres personas distintas: alguien que escribió mal la contraseña, alguien cuyo email no tiene cuenta y alguien que se registró con Google y nunca tuvo contraseña.
  • Lo hace a propósito. Un formulario de inicio de sesión que dijera cuál de los tres casos fue le diría a cualquiera con una lista de emails cuáles son de tus usuarios.
  • Puedes distinguir los tres tú mismo con una consulta sobre auth.users. Tu página de inicio de sesión debería mantener el mensaje vago y ofrecer todas las formas en que pudo crearse la cuenta.

Alguien te escribe para decirte que no puede entrar en tu app, y está seguro de que la contraseña es correcta. Pruebas su dirección tú mismo y ves la misma línea roja debajo del formulario:

Invalid login credentials

Supabase responde "Invalid login credentials" ante una contraseña incorrecta. Y responde exactamente con las mismas palabras a otras dos personas: alguien cuyo email no tiene cuenta en tu app y alguien que se registró con Google y nunca ha tenido contraseña. Tu app de Lovable, Bolt, v0, Cursor, Claude Code o Replit funciona tal como se construyó. Da una sola respuesta a tres situaciones distintas.

Esta es la parte que el arreglo obvio hace mal: el mensaje vago es deliberado, y hacerlo más útil abre un agujero. Lo que ayuda es averiguar cuál de los tres casos tienes delante, algo que tu propio panel te puede decir, mientras el formulario sigue diciendo tan poco como ahora.

¿Qué significa "Invalid login credentials" en Supabase?

Significa que Supabase Auth, la parte de Supabase que inicia la sesión de la gente, rechazó un email y una contraseña y se guarda el motivo.

El mensaje está escrito en el propio código del servidor de Auth. En la función que gestiona un inicio de sesión con contraseña, el mismo error se devuelve en tres puntos: cuando ningún usuario tiene ese email, cuando el usuario no tiene contraseña guardada y cuando la contraseña no coincide. Cada vez la respuesta es HTTP 400 con el código invalid_credentials, que la referencia de códigos de error de Supabase describe como "Login credentials or grant type not recognized."

En tu app llega como un AuthApiError de signInWithPassword, con error.code igual a invalid_credentials. La misma referencia indica identificar los errores siempre por error.code y error.name, y eso importa más adelante, cuando decidas qué dice tu formulario. Si tus usuarios tenían la sesión iniciada y luego los echó, es otro error con una página propia.

Piensa en tu formulario como el teclado junto a la puerta de un edificio. Alguien marca un código y una luz responde. La verde abre la puerta. La roja se enciende con un código equivocado, con un código que nunca se le dio a nadie y con un vecino que siempre ha entrado por la puerta lateral con su tarjeta y nunca ha tenido código.

Las tres personas detrás del mensaje

Cada una necesita un arreglo distinto, así que conviene saber cuál tienes delante.

La contraseña incorrecta. La cuenta existe, tiene contraseña, y lo que escribió no coincide. Eso incluye una contraseña cambiada en otro dispositivo y olvidada después, y un gestor de contraseñas que rellena una antigua.

La dirección sin cuenta. Nadie en tu proyecto se registró con ese email. Puede ser una segunda dirección (la del trabajo, cuando se registró con la personal) o un error al escribir durante el registro que ahora está guardado como su email.

La cuenta sin contraseña. Se registró con Google, GitHub u otro botón de inicio de sesión, y Supabase nunca guardó una contraseña para ella. Cuando vuelve y escribe una, no hay nada con qué compararla. El servidor de Auth comprueba que haya una contraseña guardada antes de mirar la escrita, y una cuenta sin ella recibe el mismo 400.

Quien se registró con un magic link, el enlace de inicio de sesión enviado por email, es una variante del primer caso. El código de magic link de Supabase crea la cuenta con una contraseña aleatoria que nunca se le muestra a nadie, así que cualquier contraseña que escriba es incorrecta.

Una contraseña incorrecta, una dirección desconocida y una cuenta sin contraseña llegan a la misma comprobación y reciben la misma respuesta.

¿Cómo sé cuál de los tres fue?

Pregúntale a tu propia base de datos. Cada cuenta de tu proyecto es una fila en una tabla llamada auth.users, y esa fila dice cómo inicia sesión la persona.

La guía de usuarios de Supabase describe el campo que importa: según Supabase, app_metadata.providers es la lista de proveedores con los que el usuario puede iniciar sesión. Un usuario puede tener varias identidades, así que una misma persona puede tener contraseña y también Google. Si hay una contraseña guardada lo dice la columna que el propio servidor comprueba.

Abre el SQL Editor en tu panel de Supabase y ejecuta esto con la dirección que te dio la persona:

select email,
       raw_app_meta_data->'providers' as signs_in_with,
       coalesce(encrypted_password, '') <> '' as has_password,
       email_confirmed_at
from auth.users
where lower(email) = lower('person@example.com');

Lee una fila y nunca muestra una contraseña, solo si existe.

Qué devuelveQué significa
Ninguna filaNinguna cuenta tiene esa dirección en este proyecto. Pregunta qué otra dirección pudo usar
has_password es false, y signs_in_with incluye google, github o algo parecidoSe registró con ese botón y nunca ha tenido contraseña
has_password es trueLa contraseña que escribió es incorrecta, o se registró con un magic link y nunca supo la suya
email_confirmed_at está vacíoAún no ha confirmado su email, lo que se convierte en otro mensaje cuando la contraseña es correcta

Si prefieres hacer clic a escribir, Authentication → Users en el panel muestra las mismas cuentas.

La cuenta que nunca tuvo contraseña

Mándala de vuelta al botón que usó, o deja que cree una contraseña con tu enlace para restablecerla.

En el teclado, este es el vecino que siempre usó la puerta lateral. No hay ningún código guardado para él, así que cualquier código que pruebe se rechaza.

El primer arreglo está en tu página de inicio de sesión. Si el registro ofrece "Continuar con Google", la página de inicio de sesión necesita el mismo botón, para que quien se registró con él lo vea cuando vuelva.

Si además quiere una contraseña, tu enlace "¿Olvidaste tu contraseña?" ya lo hace. El código de restablecimiento de contraseña de Supabase envía el email de recuperación a cualquier cuenta que encuentre, sin comprobar cómo se creó. Un usuario de Google que sigue el enlace y define una contraseña nueva puede entrar de las dos formas después. Eso depende de que tu app tenga la página que define la contraseña nueva, que llama a supabase.auth.updateUser({ password }). Si el enlace lleva a una página que nunca la pide, la persona entra una vez y sigue sin contraseña.

"Email not confirmed" significa que la contraseña era correcta

Es otro mensaje con otro código, y solo le llega a alguien que ya pasó la comprobación de la contraseña.

En la misma función del servidor de Auth, la comprobación del email confirmado va después de la de la contraseña. Así que Email not confirmed, código email_not_confirmed, solo vuelve a alguien que escribió un email existente y su contraseña correcta. La referencia de errores de Supabase lo expresa como "Signing in is not allowed for this user as the email address is not confirmed." En el teclado es otra luz: el código era correcto, y la puerta sigue cerrada hasta que se termine el papeleo de la mudanza.

Lo ves cuando Confirm email está activado en el proveedor Email de tu proyecto. Según la guía de configuración de Supabase, los usuarios tienen que confirmar su dirección de email antes de iniciar sesión por primera vez. El arreglo es el email en el que aún no hizo clic: dile que revise la bandeja de entrada y la carpeta de spam, y ofrece reenviarlo con supabase.auth.resend({ type: 'signup', email }). Si esos emails no llegan en absoluto, el límite de envío de emails es lo primero que hay que descartar. Si llegan y el enlace responde "Email link is invalid or has expired", probablemente algo lo abrió antes que la persona.

Como este mensaje solo aparece tras una contraseña correcta, pedirle a la persona que revise su bandeja no le da a un desconocido nada que no tuviera ya.

Por qué el mensaje sigue siendo vago

Porque la versión útil les diría a desconocidos quiénes son tus usuarios.

Imagina que el teclado respondiera "en el 4B no vive nadie". Cualquiera que pasara podría recorrer la lista de pisos y saber cuáles están ocupados.

Tu formulario funciona igual. Si dijera "No hay ninguna cuenta con ese email", cualquiera con una lista de direcciones podría escribirlas una a una y saber cuáles son de tus usuarios. Una filtración de cualquier otra web deja atrás una lista así, a veces con las contraseñas que la gente usaba allí. Quien sabe qué direcciones son tus usuarios sabe de quién vale la pena probar contraseñas antiguas en tu app, y quién probablemente abrirá un email que parezca tuyo.

Supabase aplica la misma regla en todos los sitios donde se escribe una dirección. Su código de registro, cuando se le pide crear una cuenta para un email ya registrado y confirmado, puede devolver un usuario inventado con un ID aleatorio, mediante una función cuyo comentario dice que existe para evitar que se filtre si un usuario está registrado o no. El restablecimiento de contraseña responde a una dirección desconocida con el mismo éxito vacío que a una conocida.

El lado del registro tiene un interruptor que controlas tú. Ese usuario inventado es lo que vuelve cuando se exige confirmación tanto para los registros por email como por teléfono. Si cualquiera de las dos está desactivada (la del teléfono cuenta aunque tu app nunca use el inicio de sesión por teléfono), una dirección registrada recibe User already registered, código user_already_exists, y tu formulario de registro se convierte en un comprobador de listas por sí mismo.

El mensaje útil, además, tiene que salir de algún sitio. Supabase no le dice a tu app si un email tiene cuenta, así que para mostrar "No hay ninguna cuenta con ese email" tu app tiene que averiguarlo por su cuenta. Eso significa una consulta que funcione para un visitante sin sesión: una lectura de tu tabla profiles que solo funciona si cualquiera puede leer los emails que contiene, o una función de base de datos que busca en auth.users para quien pregunte. En cualquier caso, la lista de tus usuarios pasa a poder leerse desde fuera.

La primera de las dos se ve desde fuera, y así es como la busca nuestro escaneo gratuito. Pregunta a cada tabla que nombra tu app cuántas filas podría obtener un visitante sin sesión, lee solo el recuento y tarda unos 20 segundos, sin cuenta: escanea tu app.

Un formulario que nombra una cuenta inexistente separa cualquier lista de direcciones en tus usuarios y todos los demás. Una sola respuesta para todos deja la lista como estaba.

Qué poner en tu página de inicio de sesión

Mantén el mensaje vago y pon todas las formas de volver a entrar en la misma página.

  • Un mensaje para los tres casos, con palabras sencillas y las otras vías al lado: "El email y la contraseña no coinciden. Si te registraste con Google, usa el botón de abajo." Ayuda al usuario de Google y no le dice a un desconocido nada que tu página de registro no diga ya.
  • Todos los métodos de tu página de registro. El botón de Google, el magic link y el campo de contraseña, todos también en la de inicio de sesión.
  • Un enlace "¿Olvidaste tu contraseña?" a una página que la defina. Supabase responde igual a cualquier dirección ahí, así que es seguro mostrarlo, y es como alguien sin contraseña consigue una.
  • Decisiones según error.code. Muestra tu mensaje vago para invalid_credentials, y "revisa tu bandeja de entrada" con un botón de reenvío para email_not_confirmed. La referencia de errores de Supabase indica identificar los errores por su código en lugar de por el texto del mensaje, y advierte de que los códigos de estado HTTP pueden cambiar sin aviso.

En código, son dos ramas:

const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error?.code === 'email_not_confirmed') showCheckYourInbox(email)
else if (error) showSignInHelp()

Si un builder escribe tu página de inicio de sesión, puedes pegarle estos cuatro puntos como instrucción, incluido el que dice lo que la página debe callarse.

Que tu página de inicio de sesión siga como la dejaste

Dos cosas pueden salir mal después de hoy: un prompt posterior reescribe la página para que sea "más útil", y un cambio en otra parte pierde las cuentas a las que la página da acceso.

Reeve Monitor sigue comprobando desde fuera:

  • las nueve comprobaciones cada hora, en hasta tres apps, incluido cuántas filas puede contar un visitante sin sesión en las tablas que nombra tu app
  • 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, en lenguaje claro, de lo que vio

Reeve Care guarda una copia de tu base de datos de Supabase, con las cuentas incluidas:

  • una copia cifrada según una programación, en el almacenamiento propio de Reeve en la UE, fuera de tu cuenta de Supabase
  • el esquema auth en el que viven las cuentas de tus usuarios, para que vuelvan con todo lo demás
  • cada copia verificada antes de contar, contando las filas de cada tabla
  • también tus archivos subidos, en cuanto conectes una credencial de Storage
  • una restauración en un clic, con una copia de seguridad del estado actual tomada antes
  • todo lo que hace Monitor

Qué hacer ahora

Qué hacer

  • Ejecuta la consulta sobre auth.users con la dirección que te dio la persona y mira cuál de los tres casos es.
  • Ninguna fila: pregunta con qué otra dirección pudo registrarse.
  • Sin contraseña: indícale el botón de Google o GitHub que usó, o tu enlace para restablecer la contraseña.
  • Con contraseña y aun así rechazada: envíale el enlace para restablecerla.
  • Muestra un mensaje vago para invalid_credentials, y "revisa tu bandeja de entrada" solo para email_not_confirmed.
  • Nunca añadas una comprobación que le diga a un visitante si un email tiene cuenta.

Si el problema empieza un paso antes y las cuentas nuevas no llegan a crearse, Database error saving new user cubre ese extremo del formulario. La lista de seguridad de 10 minutos cubre lo que suele quedar abierto en una app recién lanzada.

Preguntas frecuentes

¿Por qué Supabase dice "Invalid login credentials" si la contraseña es correcta?

Porque la contraseña puede ser correcta para una cuenta que no existe, o la cuenta puede no tener contraseña. Comprueba que el email sea el que usó para registrarse, y si se registró con Google u otro botón de inicio de sesión, porque entonces la cuenta no tiene contraseña. Una consulta sobre auth.users en el SQL Editor te dice cuál es.

¿"Invalid login credentials" significa que la cuenta no existe?

A veces. Supabase devuelve el mismo mensaje, con el mismo código, invalid_credentials, cuando ninguna cuenta tiene ese email, cuando la cuenta no tiene contraseña y cuando la contraseña es incorrecta. El mensaje por sí solo no te dice cuál, y está hecho así.

¿Por qué alguien que se registró con Google no puede entrar con contraseña?

Porque su cuenta se creó sin ella. Supabase comprueba que haya una contraseña guardada antes de comparar la que se escribe, y una cuenta sin contraseña recibe "Invalid login credentials". Puede seguir usando el botón de Google o usar el enlace para restablecer la contraseña de tu app y crear una: Supabase envía ese email a cualquier cuenta que encuentre, sin importar cómo se creó.

¿Debería mi formulario decir que el email no está registrado?

No. Ese mensaje le dice a cualquiera que escriba una dirección si pertenece a uno de tus usuarios, y alguien con una lista de direcciones filtradas puede comprobarlas todas. Tu app además necesitaría una consulta que respondiera esa pregunta a desconocidos, y eso es un segundo agujero. Deja el mensaje vago y muestra las otras formas de entrar.

¿Qué diferencia hay entre "Invalid login credentials" y "Email not confirmed"?

Supabase solo devuelve "Email not confirmed" después de comprobar la contraseña y darla por correcta. Así que la persona correcta escribió la contraseña correcta, y la cuenta espera a que haga clic en el enlace de su email de confirmación. Es seguro decirle que revise su bandeja de entrada y ofrecerle reenviar el email.

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.