Fundamentos de seguridad
Supabase "Email link is invalid or has expired" con un enlace nuevo
Con un enlace enviado hace un minuto, "Email link is invalid or has expired" en Supabase suele indicar que un filtro de correo lo usó antes. Cómo arreglarlo.

En resumen
- Supabase muestra "Email link is invalid or has expired" cuando el token de un enlace de inicio de sesión o de confirmación ya se usó, fue sustituido por un email más reciente o tiene más de una hora. El código de error es otp_expired.
- Con un enlace que la persona acaba de recibir, la causa habitual es un filtro de correo que abrió el enlace para revisarlo y lo gastó al hacerlo. La propia página de solución de problemas de Supabase lo llama el motivo más común.
- Alargar la validez no cambia nada. Cambia el email para que abrir la dirección no haga nada: envía un código para escribir, o enlaza a una página donde un botón inicia la sesión.
Un usuario nuevo se registra, abre el email de confirmación un minuto después y pulsa el botón. Tu app se abre en la página de inicio, todavía sin sesión, y la barra de direcciones termina así:
#error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired
Te registras con tu propia dirección para comprobarlo. El enlace funciona a la primera, y en cada intento después.
Supabase dice "Email link is invalid or has expired" cuando el token de un enlace de email ya se usó, fue sustituido o pasó de su hora. En un enlace que llegó hace un minuto, el motivo habitual es que algo lo usó antes que tu usuario: un filtro de correo que abre los enlaces para revisarlos. Tu app de Lovable, Bolt, v0, Cursor, Claude Code o Replit funciona tal como se construyó, y el email que envía se puede cambiar para que un filtro no tenga nada que gastar.
Esta es la parte que el consejo habitual hace mal: "pídele que lo intente otra vez" y "haz que el enlace dure más" fallan los dos ante un filtro. Un segundo enlace pasa por el mismo filtro y acaba igual, por mucho que fuera a durar.
¿Qué significa "Email link is invalid or has expired" en Supabase?
Significa que Supabase Auth, la parte de Supabase que inicia la sesión de la gente, no encontró detrás del enlace un token que estuviera dispuesto a aceptar.
El mensaje está escrito en el propio código del servidor de Auth. En
la función que comprueba un enlace de email,
se devuelve en dos puntos: cuando ningún token pendiente coincide con el del
enlace, y cuando el token que coincide se envió hace más tiempo del que tu
proyecto permite. Las dos respuestas son HTTP 403 con el código otp_expired,
así que el mensaje por sí solo no te dice cuál de las dos ocurrió.
Tu usuario nunca ve el 403. El enlace del email apunta a la dirección propia de
tu proyecto de Supabase, que comprueba el token y luego envía el navegador a tu
app. Cuando la comprobación falla, Supabase pone tres cosas en la parte de la
dirección de tu app que va detrás del #: error=access_denied,
error_code=otp_expired y el propio mensaje. Si tu app se configuró con el
flujo PKCE, también están en la parte que va detrás del ?. Mientras ninguna
página de tu app los lea, la persona ve lo que tu app le muestra a un visitante
sin sesión, sin una palabra sobre el porqué. Una página para restablecer la
contraseña que muestra su formulario de todos modos falla al guardar con "Auth
session missing!", porque el enlace no inició la sesión de nadie, y
ese mensaje tiene su propio artículo.
Piensa en el enlace como una entrada que deja pasar a una persona una sola vez. Quien la entrega primero pasa, y la entrada queda perforada. "Email link is invalid or has expired" es la persona en la puerta con una entrada que ya tiene un agujero, o con una cuya hora ya pasó. Si tus usuarios entran y más tarde la app los echa, es otro error con una página propia.
Por qué un enlace enviado hace un minuto ya está usado
Porque algo lo abrió antes que tu usuario, y abrir un enlace de email de Supabase es lo mismo que usarlo.
La guía de plantillas de email de Supabase lo incluye entre sus limitaciones: algunos proveedores de correo tienen detección de spam u otras funciones de seguridad que abren por adelantado los enlaces de los emails entrantes. El ejemplo que da es Safe Links de Microsoft Defender for Office 365. La página de Supabase para solucionar este error dice que esa apertura por adelantado es el motivo más común de que un token parezca caducado o no válido antes de que el usuario pueda siquiera usarlo.
El enlace por defecto no tiene ningún paso entre abrirlo y usarlo. Su dirección es el endpoint de verificación de tu proyecto, que comprueba el token y lo gasta en la primera visita, la haga quien la haga. Así que el filtro entrega la entrada mientras llega el mensaje, y pasa. Tu usuario hace clic un minuto después y lo rechazan en la puerta.
De ahí salen dos cosas.
- Tú no lo vas a ver. Le pasa a la gente cuyo correo pasa por un filtro de ese tipo, y puede que tu propia bandeja nunca sea una de ellas. Tus registros de prueba siguen funcionando mientras tus usuarios te escriben.
- La cuenta suele estar ya confirmada. En una confirmación de registro, la visita del filtro cuenta: Supabase confirma la dirección de quien abra el enlace. Si lo abrió un filtro, la cuenta está confirmada cuando tu usuario ve el error, y puede iniciar sesión con la contraseña que eligió.
Las otras tres causas
Cada una produce el mismo mensaje, y cada una te pide algo distinto.
El enlace ya se usó. Tu usuario lo pulsó una vez, quizá en el móvil, y ahora pulsa el mismo email en el portátil. Cuando un enlace funciona, Supabase borra todos los enlaces que seguían pendientes para esa cuenta. Su código de cuentas hace la misma limpieza después de una confirmación y después de un enlace de inicio de sesión, así que un segundo clic no encuentra nada. La entrada quedó perforada en la primera visita.
Un email más reciente lo sustituyó. Pulsar "reenviar" crea un enlace nuevo, y Supabase borra el anterior del mismo tipo antes de guardarlo. Ocurre en el código que crea el token. El primer email sigue en la bandeja con buen aspecto, y su enlace falla. Solo funciona el email más reciente. Supabase además solo deja que una dirección pida este tipo de email una vez cada 60 segundos, algo que el límite de emails explica a fondo.
De verdad caducó. La guía de inicio de sesión sin contraseña de Supabase dice que los magic links caducan por defecto al cabo de 1 hora. La misma página dice que el ajuste Email OTP Expiration rige también la validez de los magic links y de los demás enlaces de email, incluidos los de confirmación, recuperación de contraseña, cambio de email e invitación. Quien se registra por la noche y abre el email a la mañana siguiente se encuentra este mensaje, haga lo que haga su servicio de correo.
¿Qué pasa cuando el enlace se abre en otro dispositivo?
Si tu app usa el flujo PKCE, el otro dispositivo falla primero con un mensaje distinto, y este viene después.
PKCE parte la entrada en dos. La guía de PKCE de Supabase explica que el code verifier se crea y se guarda en local cuando empieza el flujo de Auth, y que por eso el intercambio del código tiene que iniciarse en el mismo navegador y el mismo dispositivo donde empezó el flujo. El email lleva una mitad. El navegador donde tu usuario pidió el enlace se queda con el talón.
Abierto en un móvil, el enlace pasa igualmente por la comprobación de Supabase, que lo gasta. Luego tu app busca el talón en el móvil y no lo encuentra. El cliente de JavaScript de Supabase lo informa con sus propias palabras:
PKCE code verifier not found in storage. This can happen if the auth flow was initiated in a different browser or device, or if the storage was cleared.
Así que la persona vuelve al portátil y pulsa allí el mismo email, y Supabase
responde "Email link is invalid or has expired", porque el móvil ya hizo
perforar la entrada. Si la dirección en la que se abre tu app tras un enlace que
funciona contiene ?code=, tu app usa PKCE y este caso te afecta.
¿Cómo sé cuál de ellas 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 registra cuándo se envió cada tipo
de email y cuándo se confirmó la dirección.
Abre el SQL Editor en tu panel de Supabase y ejecuta esto con la dirección que te dio la persona:
select email,
confirmation_sent_at,
email_confirmed_at,
recovery_sent_at
from auth.users
where lower(email) = lower('person@example.com');
Lee una fila y no cambia nada. confirmation_sent_at es cuándo salió la última
confirmación de registro. recovery_sent_at es cuándo salió el último magic
link o restablecimiento de contraseña hacia una cuenta que ya existía. Son los
momentos desde los que Supabase mide la hora, en la misma función que devuelve
el error. Compáralos con la hora del email que pulsó la persona.
| Lo que ves | Lo que significa |
|---|---|
Un enlace de confirmación falló en el primer clic, y email_confirmed_at tiene valor | Algo abrió el enlace antes que la persona. La cuenta está confirmada, así que puede entrar con su contraseña |
Un enlace de confirmación falló, email_confirmed_at está vacío y lo pulsó más de una hora después del envío | Caducó. Envía una confirmación nueva |
confirmation_sent_at o recovery_sent_at es posterior al email que pulsó | Un email más reciente lo sustituyó. Solo funciona el enlace del más nuevo |
| Un magic link del email más reciente falló en el primer clic, dentro de la hora | Algo lo abrió primero: un filtro de correo, o la persona en otro dispositivo |
Si la fila muestra la dirección confirmada y la persona sigue sin poder entrar con contraseña, Invalid login credentials es el siguiente mensaje sobre el que leer.
Qué no cambiar mientras lo arreglas
La validez, el interruptor Confirm email y quién puede confirmar a un usuario. Cada uno de los atajos obvios hace que el mensaje pare y abre algo.
Deja la validez en una hora. Un filtro abre el enlace mientras llega el mensaje, así que un enlace que dura una semana también lo usa él primero. La guía de inicio de sesión sin contraseña de Supabase desaconseja expresamente más de 86.400 segundos, es decir, un día. Un enlace de inicio de sesión abre la sesión de quien lo abra. Cuanto más tiempo funciona, más tiempo son un email reenviado, una bandeja compartida o un mensaje viejo en el archivo de alguien una forma de entrar en esa cuenta.
Deja Confirm email activado. Si lo desactivas, los registros dejan de enviar ningún enlace y este error se acaba. También significa que cualquiera puede crear una cuenta con una dirección que nunca ha controlado.
No confirmes usuarios desde el navegador. Pídele a un asistente que "los
marque como confirmados y ya" y puede echar mano de las funciones de
administración de Supabase, que pueden poner email_confirm en una cuenta. La
referencia de administración
de Supabase dice que cualquier método bajo supabase.auth.admin requiere una
clave secret y que esa clave nunca debe exponerse en el navegador. Una clave
secreta en tu frontend lee y escribe todas las tablas de tu base de datos,
digan lo que digan tus reglas, para cualquiera que abra el código de tu sitio.
Es la clave que
este artículo sobre claves de API
te dice que rotes. Si no estás seguro de que no se coló ninguna, nuestro
escaneo gratuito lee el código que tu app en vivo envía a cada visitante y
marca las claves secretas que encuentra ahí. Tarda unos 20 segundos y no pide
cuenta: escanea tu app.
Comprueba cualquier dirección que pase tu página de confirmación. La segunda solución que propone Supabase es una página tuya con un botón, a la que se llega con un enlace de esta forma:
<a href="{{ .SiteURL }}/confirm-signup?confirmation_url={{ .ConfirmationURL }}">
El botón lleva entonces adonde diga confirmation_url. Hecha sin comprobación,
esa página envía a cualquiera que siga un enlace manipulado a cualquier
dirección, con el nombre de tu app. O compruebas que la dirección empieza por la
de tu propio proyecto, https://<project-ref>.supabase.co/auth/v1/verify, o
usas la versión con el token de la siguiente sección, que no hace pasar ninguna
dirección por la página.
Cómo enviar un enlace que un filtro de correo no pueda gastar
Haz que abrir la dirección no haga nada, de modo que solo inicie sesión una persona que escribe un código o pulsa un botón. Supabase documenta las dos opciones, y se cambian en las plantillas de email, dentro de Authentication en tu panel. Confirm signup y Magic Link son las dos que hay que editar.
Envía un código. Pon {{ .Token }} en la plantilla. La guía de Supabase lo
describe como una contraseña de un solo uso de 6 dígitos que puede usarse en
lugar del enlace. Tu página pide la dirección de email y el código, y luego
llama a:
const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })
Un filtro puede abrir un email. No puede escribir un código en tu página.
Enlaza a una página con un botón. Haz que el enlace apunte a una página tuya y lleve el hash del token, para que no tenga que pasar ninguna dirección:
<a href="{{ .SiteURL }}/confirm?token_hash={{ .TokenHash }}&type=email">Confirm your email</a>
{{ .SiteURL }} es la Site URL de la página URL Configuration de tu proyecto.
Si todavía dice http://localhost:3000,
corrige eso primero.
La página muestra un botón, y solo el botón hace el trabajo:
const params = new URLSearchParams(window.location.search)
async function onConfirmClick() {
const { error } = await supabase.auth.verifyOtp({
token_hash: params.get('token_hash'),
type: 'email',
})
if (error?.code === 'otp_expired') showSendNewLink()
}
El tipo email sirve para las dos plantillas: la comprobación de Supabase busca
el hash tanto entre las confirmaciones de registro como entre los enlaces de
inicio de sesión.
La página no debe llamar a verifyOtp al cargarse. La
guía de contraseñas de
Supabase construye su enlace de confirmación como
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}
y lo verifica en el momento en que se abre esa dirección. Eso es otra vez el
enlace por defecto en una dirección nueva, y un filtro lo abre con la misma
facilidad.
Cambies lo que cambies, habrá gente que siga encontrándose un enlace viejo, así que dale una salida. La página a la que llegan tus enlaces puede leer el error y ofrecer un email nuevo:
const hash = new URLSearchParams(window.location.hash.slice(1))
if (hash.get('error_code') === 'otp_expired') showSendNewLink()
En un registro, añade una línea que diga que quizá ya está confirmada y puede
probar a iniciar sesión. Si un builder escribe tus páginas de inicio de sesión,
pídele la versión con código o con botón de las dos plantillas, una página de
llegada que lea otp_expired y ofrezca un enlace nuevo, y ninguna clave de
administración en ninguna parte de la app.
Enterarte de los problemas antes de que tus usuarios te escriban
Este error les llega a tus usuarios y nunca a ti, y otros fallos también les llegan a ellos primero: la app que se cae de noche, o un prompt posterior que mete una clave en el código de tu app.
Reeve Monitor sigue comprobando tu app en vivo desde fuera:
- si está disponible, cada 60 segundos, para distinguir "el enlace no funciona" de "el sitio está caído"
- las nueve comprobaciones cada hora, en hasta tres apps, incluidas las claves secretas en el código que tu app envía a los visitantes
- un mensaje cuando un resultado cambia, para que una clave que sale con un despliegue posterior te llegue sin que tengas que ir a mirar
- 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
- Lee la fila de la persona en
auth.users. Unemail_confirmed_atcon valor en una confirmación fallida significa que un filtro abrió el enlace y que puede iniciar sesión. - Di a la gente que use el email más reciente y, si tu app usa PKCE, que lo abra en el dispositivo donde lo pidió.
- Cambia las plantillas Confirm signup y Magic Link para que envíen un código, o para que abran una página donde un botón llame a
verifyOtp. - Haz que la página a la que llegan tus enlaces lea
error_code=otp_expiredy ofrezca un enlace nuevo. - Deja la validez en una hora, deja Confirm email activado y mantén la clave secreta fuera del navegador.
Si los emails ni siquiera llegan, el límite de emails es lo primero que hay que descartar. La lista de seguridad de 10 minutos cubre lo que suele quedar abierto en una app recién lanzada.
Preguntas frecuentes
¿Por qué un magic link de Supabase dice que ha caducado justo después de enviarlo?
Normalmente porque algo lo abrió antes que la persona. Algunos servicios de correo abren los enlaces de un mensaje entrante para revisarlos, y Supabase pone como ejemplo Safe Links de Microsoft Defender for Office 365. Abrir un enlace de email de Supabase es usarlo, así que cuando la persona hace clic un minuto después el token ya no está y Supabase responde "Email link is invalid or has expired".
¿Está confirmada la cuenta aunque el enlace dijera que había caducado?
Si un filtro de correo abrió primero un enlace de confirmación de registro, sí. Supabase confirma la dirección para quien abra el enlace, así que la visita del filtro la confirmó. Mira email_confirmed_at en auth.users: si tiene valor, la persona puede iniciar sesión con la contraseña que eligió.
¿Cuánto tiempo es válido un enlace de email de Supabase?
Una hora por defecto. Lo controla el ajuste Email OTP Expiration, y ese mismo ajuste cubre los magic links, las confirmaciones de registro, los restablecimientos de contraseña, los cambios de email y las invitaciones. Supabase desaconseja expresamente cualquier valor por encima de 86.400 segundos, es decir, un día.
¿Debería hacer que los enlaces duren más?
No. Un filtro de correo abre el enlace en cuanto llega el mensaje, así que una validez más larga no impide que lo use primero. Lo que sí hace es mantener funcionando más tiempo cada enlace sin usar en cada bandeja de entrada, y un enlace inicia la sesión de quien lo abra, incluida una persona a la que se le reenvió el email.
¿Por qué el enlace funciona en mi ordenador y falla en mi móvil?
Si tu app usa el flujo PKCE, el navegador donde la persona pidió el enlace guarda la mitad de lo que necesita el inicio de sesión, así que abrir el email en otro dispositivo falla con "PKCE code verifier not found in storage". Ese intento además gasta el enlace, y por eso volver a hacer clic en el primer dispositivo dice después "Email link is invalid or has expired".
¿Qué significa error_code=otp_expired en la dirección de mi app?
Es la forma en que Supabase Auth informa de un enlace de email fallido. Envía a la persona a tu app con error=access_denied, error_code=otp_expired y el mensaje en la parte de la dirección que va detrás del signo #. Tu app puede leer error_code ahí y mostrar una página que ofrezca enviar un enlace nuevo.