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.

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 log | El código | Qué pasó |
|---|---|---|
Invalid Refresh Token: Refresh Token Not Found | refresh_token_not_found | El 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 Used | refresh_token_already_used | El 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ás | session_expired | Un límite de sesión en tus ajustes de Auth la terminó. |
Invalid Refresh Token: No Valid Session Found | session_not_found | La 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.
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.
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.
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 Auth | A qué apunta |
|---|---|
Un Refresh Token Not Found para un usuario, y nada después | Una 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 sitio | Causa 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 vez | Causa 2, tickets nuevos que no llegan al navegador |
Session Expired, con un motivo entre paréntesis | Causa 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 tokenen 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-Cookieen 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 Foundpor 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.