Saltar al contenido

Fundamentos de seguridad

Supabase "Invalid Refresh Token: Refresh Token Not Found": 3 causas

Supabase "Invalid Refresh Token: Refresh Token Not Found" cierra la sesión de tus usuarios. Tres causas, y cómo las separa el log de Auth.

Vlad Tkachenko14 min de lectura
Un ticket roto en dos, con la mitad izquierda iluminada y la derecha apagada y cayendo, delante de una fila de tickets enteros.

En resumen

  • Supabase "Invalid Refresh Token: Refresh Token Not Found" significa que Supabase Auth no tiene registro del refresh token que envió tu app, así que no puede renovar la sesión y el usuario queda desconectado.
  • Tres cosas borran ese registro: un cierre de sesión (que en JavaScript termina todos los dispositivos por defecto), un token ya usado que llega otra vez y un límite de sesión en tus ajustes de Auth.
  • Parte de esto es una línea inofensiva de una pestaña vieja. Otra parte desconecta a todos tus usuarios cada día. El log de Auth te dice cuál es, y cada causa tiene su propio arreglo.

Tus usuarios te escriben para decirte que se les cerró la sesión. Uno estaba a mitad de un formulario, y otra está segura de que no tocó nada. Cuando pruebas la app tú mismo, funciona. Luego abres tus logs y encuentras la misma línea, una y otra vez:

AuthApiError: Invalid Refresh Token: Refresh Token Not Found

Supabase "Invalid Refresh Token: Refresh Token Not Found" es Supabase Auth, la parte de Supabase que inicia la sesión de la gente, diciendo que no tiene registro del token que tu app envió para mantener una sesión abierta. Sin nada que renovar, tu app cierra la sesión del usuario.

Esta es la parte que es fácil entender al revés: este error es el último paso, y la causa llegó antes. Cuando Supabase ya no encuentra el token, la sesión a la que pertenecía ya terminó. La terminó un cierre de sesión, un token enviado dos veces o un límite que alguien puso. Hacer que el access token dure más, o apagar la comprobación que termina una sesión, vuelve la línea más rara en tus logs y deja la causa exactamente donde estaba.

¿Qué significa "Invalid Refresh Token: Refresh Token Not Found"?

Significa que Supabase Auth recibió un refresh token del que no tiene registro, así que se negó a renovar la sesión.

Cada usuario con sesión iniciada tiene dos tokens. El access token funciona como una pulsera: demuestra quién es durante un rato, una hora si no has cambiado el ajuste, y tu app la muestra en cada petición. El refresh token es un ticket. Antes de que caduque la pulsera, tu app lleva el ticket al mostrador, que es Supabase Auth, y lo cambia por una pulsera nueva y un ticket nuevo. La guía de sesiones de Supabase dice que los refresh tokens "never expire but can only be used once".

Refresh Token Not Found es el mostrador buscando un ticket y no encontrando nada. El servidor responde con HTTP 400 y el código refresh_token_not_found, que la lista de códigos de error de Auth de Supabase describe como "Session containing the refresh token not found." Si la pulsera también ha caducado, el cliente de JavaScript de Supabase quita la sesión del navegador y le avisa a tu app de que el usuario cerró sesión. En ese momento tu usuario se encuentra con la página de inicio de sesión.

Ninguno de los dos tokens es una de las API keys de tu proyecto, que son otra cuestión: cuáles de ellas van en tu app. Si tu mensaje dice Invalid JWT, se rechazó la pulsera y no el ticket, y ese error tiene su propia página. Si después no puede volver a entrar y el formulario dice Invalid login credentials, ese mensaje cubre a tres personas distintas.

¿Por qué se desconectan mis usuarios de Supabase?

Porque la sesión detrás de su ticket terminó, y la pulsera que todavía llevan los mantuvo conectados hasta que caducó. Ese retraso, de hasta una hora con el ajuste por defecto, es lo que hace que las desconexiones parezcan aleatorias: el usuario sale un rato después de que terminara la sesión.

Supabase Auth tiene cuatro maneras de decir que un ticket no vale, y cada una apunta a una causa distinta. Estos son los mensajes exactos del código del propio servidor de Supabase Auth:

El mensaje en tu logEl códigoQué pasó
Invalid Refresh Token: Refresh Token Not Foundrefresh_token_not_foundEl mostrador no tiene registro de este ticket. Un cierre de sesión terminó su sesión, o se cambió hace tanto que su registro ya no está.
Invalid Refresh Token: Already Usedrefresh_token_already_usedEl ticket se cambió hace más de diez segundos y ha vuelto. Supabase termina toda la sesión cuando pasa esto.
Invalid Refresh Token: Session Expired, a veces con (Inactivity) o (Revoked by Newer Login) detrássession_expiredUn límite de sesión en tus ajustes de Auth la terminó.
Invalid Refresh Token: No Valid Session Foundsession_not_foundLa sesión a la que pertenecía el ticket ya no existe.

Detrás de estos mensajes hay tres causas, según la propia documentación de Supabase y los reportes en sus issue trackers, y cada una tiene su arreglo. Si nadie puede iniciar sesión, o toda la app está en blanco, es otro problema, y ¿Supabase está caído, o es tu app? es por donde empezar.

Un refresh token funciona una vez

Cada vez que tu app renueva una sesión, entrega el ticket y el mostrador rompe el viejo. Supabase llama a esto rotación de refresh tokens. El ticket viejo sigue funcionando diez segundos más, lo que la guía de sesiones llama el reuse interval, para que un servidor y un navegador que renuevan casi al mismo tiempo no se echen el uno al otro. Un cliente que cambió su ticket y perdió la respuesta por una mala conexión también está cubierto: recibe de vuelta el ticket actual.

Fuera de esos dos casos, un ticket viejo que vuelve cuenta como señal de alarma, porque un token que aparece dos veces es lo que parece uno robado. La guía dice que entonces toda la sesión "is regarded as terminated and all refresh tokens belonging to it are marked as revoked". La respuesta esa vez es Already Used.

Un paso más convierte Already Used en Not Found. El servidor de Supabase Auth borra los registros de los tickets rotos 24 horas después de su revocación. Así que el ticket caducado que hoy recibe Already Used recibe mañana Refresh Token Not Found, porque el mostrador ya no tiene el registro con el que compararlo.

Un ticket en cuatro momentos. Antes de diez segundos todavía funciona. Después se rechaza como ya usado, y cuando su registro se borra un día más tarde, el mostrador ya no lo encuentra.

Causa 1: alguien cerró sesión, en este dispositivo o en otro

Un cierre de sesión borra la sesión del lado del mostrador, y cada ticket de esa sesión pasa a ser uno que el mostrador no encuentra. En el cliente de JavaScript de Supabase, signOut() sin nada entre los paréntesis termina todas las sesiones de ese usuario, en todos sus dispositivos. La guía para cerrar sesión de Supabase lo dice directamente: con ese valor por defecto, las demás sesiones fallan con AuthApiError: Invalid Refresh Token: Refresh Token Not Found la próxima vez que intentan renovarse, "even though no one signed out on that device."

Así que un usuario que cierra sesión en el móvil también queda fuera en el portátil, hasta una hora después, cuando la pulsera del portátil caduca y su ticket vuelve como desconocido. Desde su lado, parece que tu app lo echó sin motivo.

Si cada dispositivo debe seguir conectado por su cuenta, la llamada de cierre de sesión tiene que terminar solo la sesión desde la que se hizo:

await supabase.auth.signOut({ scope: 'local' })

Pídele a tu builder que use esa forma en todos los sitios donde tu app cierra la sesión de la gente. La otra forma habitual de terminar una sesión es un cambio de contraseña, que la guía de sesiones de Supabase incluye entre las cosas que terminan una.

Sin scope, un cierre de sesión termina la sesión en todos los dispositivos. Con scope local, termina solo la del que tienes en la mano.

Causa 2: el ticket nuevo nunca llegó al navegador

Si tu app genera las páginas en un servidor, como hace una app de Next.js, ese servidor también renueva sesiones. Lee el ticket de una cookie, lo cambia en el mostrador y tiene que devolver el ticket nuevo al navegador en una cabecera Set-Cookie de su respuesta. Cuando esa cookie no llega, el navegador se queda con el ticket viejo, y la siguiente petición presenta un ticket que el servidor ya cambió. Antes de diez segundos, eso pasa. Después es Already Used y la sesión termina, y un día más tarde el mismo ticket es Not Found.

La guía de Supabase para crear un cliente de servidor nombra el error que provoca esto en una app de Next.js. El código que renueva la sesión, que Next.js 16 llama Proxy y las versiones anteriores middleware, tiene que devolver la respuesta en la que se escribieron las cookies nuevas: "An earlier response doesn't carry the refreshed cookies, so the user is signed out on the next request." La misma guía advierte que en Next.js 15 y anteriores un archivo llamado proxy.ts nunca se ejecuta, "so sessions never refresh and users get signed out". Un asistente que trabaja con la documentación más nueva puede escribir justo ese archivo en una app más antigua.

La guía de Supabase también explica por qué este cuesta encontrarlo: "it looks like users being signed out at random rather than an error in your code." Los reportes suenan igual. Un desarrollador escribió en el issue tracker de @supabase/ssr "I'm inadvertently logged out of my web application every day", y el ingeniero de Supabase que respondió señaló cómo el middleware pasaba su respuesta. El reporte de una app de iOS describe la desconexión llegando "2-3 days after logging in", y se cerró después de que unas actualizaciones de la librería de Swift de Supabase redujeran los reportes.

El servidor renovó la sesión y el ticket nuevo nunca volvió. El navegador sigue teniendo el que ya se cambió.

Para comprobarlo, abre las herramientas de desarrollo de tu navegador en una página que haya renovado la sesión y mira la respuesta en la pestaña Network. Debería llevar un Set-Cookie con el token nuevo, y la siguiente petición debería enviar ese token de vuelta.

Causa 3: un límite de sesión en tus ajustes de Auth

Desde el plan Pro, Supabase puede terminar sesiones a propósito, desde la página Sessions de los ajustes de Auth de tu proyecto. Hay tres límites, y la guía de sesiones describe cada uno:

  • Las sesiones con límite de tiempo terminan pasado un tiempo fijo, por activo que esté el usuario.
  • Un tiempo de inactividad termina una sesión que lleva un rato sin renovarse.
  • Una sola sesión por usuario conserva solo el inicio de sesión más reciente, así que entrar desde el móvil termina la sesión en el portátil.

Estos lo dicen en su propio mensaje, Session Expired, con (Inactivity) o (Revoked by Newer Login) detrás en el segundo y el tercero. La guía añade que las sesiones terminadas se borran 24 horas después, así que un ticket de una de ellas que vuelve pasado ese plazo recibe Not Found en su lugar.

Si pusiste uno de estos, las desconexiones son el ajuste haciendo su trabajo. Si no era tu intención, o lo activó una plantilla o un asistente por ti, la página muestra qué límite está activo.

El caso inofensivo

Un ticket de una sesión que ya terminó, que llega desde una pestaña vieja o desde un navegador que nadie abrió en semanas, también recibe Refresh Token Not Found. La persona detrás ya estaba desconectada. Su navegador preguntó una vez, Supabase dijo que no, y ve tu página de inicio de sesión, que es lo que habría visto de todos modos.

En un log se ve así: una línea para un usuario, a menudo poco después de que ese usuario cerrara sesión en algún sitio, y luego nada más de él. Líneas así son el error haciendo su trabajo. Las que merece la pena seguir son las mismas personas una y otra vez, o un montón de personas distintas el mismo día.

Cómo leer tus logs de Auth para esto

Abre Logs en tu panel de Supabase, elige el tipo de log Auth y busca refresh token en los mensajes de evento. Selecciona una fila para ver sus detalles, incluido el ID del usuario, y el filtro User reduce entonces la lista a esa persona. Hasta dónde llegan los logs depende de tu plan, así que léelos mientras las quejas son recientes.

Luego lee lo que se repite:

Lo que ves en el log de AuthA qué apunta
Un Refresh Token Not Found para un usuario, y nada despuésUna pestaña vieja o un navegador que vuelve. Inofensivo.
Refresh Token Not Found para un usuario poco después de cerrar sesión en otro sitioCausa 1, un cierre de sesión que terminó todos los dispositivos
Already Used, y un día después Refresh Token Not Found, para los mismos usuarios, una y otra vezCausa 2, tickets nuevos que no llegan al navegador
Session Expired, con un motivo entre paréntesisCausa 3, un límite de sesión

También puedes preguntarle a la base de datos con qué frecuencia inicia sesión la gente. Cada inicio de sesión añade una fila a auth.sessions, y un cierre de sesión la borra. Pega esto en el SQL Editor:

select user_id, count(*) as sessions, max(created_at) as latest
from auth.sessions
where created_at > now() - interval '14 days'
group by user_id
order by sessions desc
limit 20;

Un usuario con muchas más sesiones que dispositivos ha iniciado sesión una y otra vez sin cerrarla nunca, que es lo que hace alguien a quien tu app echa constantemente.

Qué no cambiar mientras lo arreglas

Tus reglas de Row Level Security, la detección de reutilización y la duración del access token. Las tres parecen un arreglo y dejan la causa donde está, y aflojar la primera puede abrir tus datos a desconocidos.

Deja en paz tus reglas de Row Level Security. Row Level Security es el conjunto de reglas que decide qué filas recibe cada visitante. Mientras a los usuarios se les cierra la sesión, cada pantalla cuya regla lee auth.uid() les sale vacía, porque según la documentación de Supabaseauth.uid() devuelve null cuando la sesión ha caducado. El arreglo tentador es aflojar esa regla hasta que la pantalla vuelva a llenarse, y entonces también se llena para desconocidos. Si alguien ya lo hizo, nuestro escaneo gratuito pregunta a cada tabla que nombra tu app cuántas filas recibiría un visitante sin sesión, lee el número sin descargar una sola fila y tarda unos 20 segundos, sin cuenta: escanea tu app.

Deja activada la detección de reutilización. Supabase te deja apagarla, en los ajustes avanzados de la página Sessions, y lo considera "generally not recommended". Con ella apagada, la causa 2 deja de producir Already Used y el ticket nuevo sigue sin llegar al navegador.

Deja el access token en una hora. Una pulsera más larga retrasa la desconexión y deja su causa en su sitio. La guía de Supabase dice que ponerlo por encima de una hora es "generally discouraged for security reasons".

Mantener las sesiones funcionando después del arreglo

Dos cosas pueden seguir saliendo mal cuando paran las desconexiones: un cambio posterior las trae de vuelta, y una regla aflojada por el camino deja una tabla abierta a desconocidos.

Reeve Monitor sigue comprobando desde fuera:

  • si tu app está en línea, comprobado cada minuto, para que una ola de mensajes de "me sacó de la sesión" no se confunda con una caída
  • la comprobación de seguridad completa cada hora, en hasta tres apps
  • un mensaje cuando un resultado cambia, para que una regla aflojada durante un arreglo no espere a que mires
  • un informe mensual, en lenguaje claro, de lo que vio

Reeve Care guarda una copia de tu base de datos de Supabase:

  • 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

  • Busca refresh token en tus logs de Auth y lee qué mensaje se repite, y con quién.
  • Si un cierre de sesión en un dispositivo termina los demás, cámbialo a signOut({ scope: 'local' }).
  • Si tu app genera páginas en un servidor, asegúrate de que la respuesta que renovó una sesión es la que llega al navegador, y busca su Set-Cookie en la pestaña Network.
  • Mira en la página Sessions de tus ajustes de Auth si hay un límite de tiempo, un tiempo de inactividad o una sola sesión por usuario que nadie quiso activar.
  • Deja Row Level Security, la detección de reutilización y la duración del access token como están mientras lo arreglas.
  • Trata un Not Found por usuario como ruido, y a los mismos usuarios cada día como un fallo.

Empieza por el log de Auth, porque el mensaje que se repite te dice cuál de las tres causas tienes. Después, cuando la gente siga conectada, escanea tu app para comprobar que no se aflojó nada por el camino.

Preguntas frecuentes

¿Por qué mis usuarios de Supabase se desconectan sin motivo aparente?

Porque la sesión detrás de su refresh token terminó, y el access token que todavía tienen los mantiene conectados hasta una hora, así que la desconexión llega después de su causa. Las razones habituales son un cierre de sesión en otro dispositivo, un token renovado que nunca llegó al navegador y un límite de sesión en tus ajustes de Auth. El log de Auth muestra cuál es.

¿Qué es un refresh token?

Es la mitad duradera de una sesión de Supabase. El access token demuestra quién es el usuario durante una hora por defecto, y antes de que caduque tu app cambia el refresh token por un par nuevo. Cada refresh token funciona una vez, y el viejo deja de funcionar diez segundos después del cambio.

¿"Refresh Token Not Found" es señal de un ataque?

Por sí solo, no. Significa que Supabase Auth rechazó un token del que no tiene registro, que es la respuesta correcta. La protección contra un token robado es la detección de reutilización, que termina una sesión cuando vuelve un token viejo, y Supabase recomienda dejarla activada.

¿Por qué solo pasa en producción?

Porque la mayoría de las causas necesitan tiempo o un segundo dispositivo. Un token caducado se rechaza como ya usado el primer día y como no encontrado cuando su registro se borra un día después, y una sesión que abres mientras construyes rara vez dura tanto. Además, los usuarios reales entran desde varios dispositivos, y ahí es donde aparece un cierre de sesión que termina todas las sesiones.

¿Cuánto dura una sesión de Supabase?

Por defecto, hasta que el usuario cierra sesión. El access token dura una hora si no lo cambias, y el refresh token nunca caduca, pero solo funciona una vez. Desde el plan Pro puedes limitar las sesiones por tiempo total, por inactividad o a una por usuario, en la página Sessions de tus ajustes de Auth.

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.