JWT vs cookies de sesión: en qué se diferencian y cuándo usar cada uno
Una cookie de sesión es la llave de un estado que se guarda en el servidor; un JWT lleva ese estado firmado hasta el servidor. De esa única diferencia salen todas las ventajas e inconvenientes.
La comparación suele plantearse como JWT contra cookies, pero no son el mismo tipo de cosa. Una cookie es una forma de enviar datos con cada petición. Un JWT es un formato de datos. La elección real está entre una sesión en el servidor, en la que el navegador guarda un ID aleatorio y el servidor consulta a quién pertenece, y un token autocontenido, en el que el cliente guarda afirmaciones (claims) firmadas que el servidor puede comprobar sin consultar nada. Con eso claro, las dudas sobre revocación, almacenamiento y CSRF encajan por sí solas.
Cómo funciona una sesión en el servidor
Tras un inicio de sesión correcto, el servidor crea un registro de sesión (ID de usuario, roles, caducidad) en memoria, en una base de datos o en un almacén como Redis, y envía al navegador un ID imposible de adivinar dentro de una cookie. El navegador adjunta la cookie a todas las peticiones posteriores a ese sitio, y el servidor carga el registro en cada una. El ID no significa nada por sí mismo; 32 bytes aleatorios se codifican en 43 caracteres.
Set-Cookie: __Host-session=p-sLYX7J3ziMKxq-dadVMuQJNSVBHM5hrw7MJPihGQ0; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400
El prefijo __Host- hace que los navegadores rechacen la cookie salvo que sea Secure, tenga Path=/ y no fije ningún Domain, de modo que un subdominio no puede sobrescribirla.
Cómo funciona un JWT
Un JSON Web Token (RFC 7519) son tres partes codificadas en Base64URL y unidas por puntos: una cabecera que indica el algoritmo, un payload con claims como sub y exp, y una firma sobre las dos primeras partes. El servidor comprueba la firma y la caducidad con una clave y confía en los claims sin preguntar a una base de datos. Normalmente se envía en una cabecera Authorization: Bearer.
El payload solo está codificado, no cifrado. Cualquiera que tenga el token puede leerlo, algo que puede comprobar pegando uno en el decodificador JWT. No meta nunca en un JWT secretos ni datos personales que no le mostraría al propio usuario. Existen tokens cifrados (JWE, RFC 7516), pero la mayoría de los JWT en uso solo van firmados. Para entender por qué las partes usan - y _ en lugar de + y /, consulte Base64 vs Base64URL.
Este es un token de ejemplo y el código que lee su payload en el navegador. Como se ve, no hace falta ninguna clave:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzg0MTIiLCJpYXQiOjE3NjAwMDAwMDAsImV4cCI6MTc2MDAwMDkwMH0.08BFm85KQIp34VSetcibqwBVLpHKR3IZhI_IPNeTif4
// Reading the payload needs no key at all (browser):
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(part), c => c.charCodeAt(0));
JSON.parse(new TextDecoder().decode(bytes));
// { sub: 'user_8412', iat: 1760000000, exp: 1760000900 }
Ventajas e inconvenientes, uno al lado del otro
| Sesión en el servidor | JWT | |
|---|---|---|
| El cliente guarda | Un ID aleatorio y sin significado | Claims firmados y legibles |
| El servidor necesita | Un almacén de sesiones compartido por todos los servidores | Solo la clave para verificar firmas |
| Cerrar la sesión de un usuario en todas partes | Borrar la sesión: inmediato | Imposible antes de exp sin estado adicional |
| Cambio de rol o de permisos | Se aplica en la siguiente petición | Se aplica cuando se emite un token nuevo |
| Transporte habitual | Cookie, enviada automáticamente | Cabecera Authorization, o una cookie |
| Tamaño por petición | ID de 43 caracteres en nuestro ejemplo | De 152 a 540 caracteres en nuestros ejemplos |
Revocación, caducidad y refresh tokens
No guardar estado (statelessness) es el principal argumento a favor de los JWT y también su principal debilidad. Un token válido sigue siéndolo hasta que caduca, aunque el usuario cierre sesión, cambie la contraseña o sea bloqueado. La respuesta habitual es que los access tokens duren poco, a menudo minutos en lugar de horas, y acompañarlos de un refresh token de mayor duración que el cliente canjea por nuevos access tokens. El refresh token se comprueba contra un estado guardado en el servidor, así que al revocarlo se deja de emitir access tokens nuevos.
La OAuth 2.0 Security Best Current Practice (RFC 9700) describe la rotación de refresh tokens: cada renovación devuelve un refresh token nuevo e invalida el anterior, de modo que se puede detectar la reutilización de un token robado. Si además necesita revocar al instante los access tokens, acabará manteniendo una lista de denegación de IDs de token (jti) o una versión de token por usuario, lo que vuelve a ser estado en el servidor. Llegado ese punto, valore si una sesión normal no sería más sencilla.
Dónde guardar el token: cookies, localStorage y XSS
Todo lo que esté en localStorage o sessionStorage lo puede leer cualquier script que se ejecute en su página. Basta un fallo de cross-site scripting (XSS) o un script de terceros comprometido para copiar el token y usarlo desde cualquier lugar hasta que caduque.
Una cookie marcada como HttpOnly no es accesible desde JavaScript, así que un XSS no puede robarla. Aun así, un XSS puede enviar peticiones desde la página abierta de la víctima, y el navegador adjuntará la cookie, de modo que HttpOnly limita el daño en lugar de evitarlo. Prevenir el XSS sigue siendo imprescindible en cualquier caso; las cookies HttpOnly simplemente hacen que un ataque logrado le sirva de menos al atacante.
CSRF y SameSite
La comodidad de la cookie es también su riesgo: el navegador la adjunta automáticamente, incluso en peticiones provocadas por otro sitio web. Eso es la falsificación de peticiones entre sitios (CSRF). Los tokens enviados en una cabecera Authorization no están expuestos al CSRF clásico, porque el navegador no adjunta un bearer token por su cuenta; tiene que añadirlo su propio código.
SameSite=Strict: la cookie no se envía nunca en peticiones iniciadas desde otro sitio, ni siquiera al seguir un enlace hacia el suyo.SameSite=Lax: se envía cuando el usuario llega a su sitio con un enlace normal (un GET de nivel superior), pero no en envíos de formularios entre sitios, peticiones fetch ni recursos incrustados. Chrome aplica Lax a las cookies que no fijan el atributo; indíquelo de forma explícita en lugar de depender de eso.SameSite=None: se envía en todos los casos y debe combinarse conSecure(si no, Chrome la rechaza). Solo hace falta para usos realmente entre sitios, como widgets incrustados.
«Mismo sitio» significa el mismo dominio registrable, así que app.example.com y forum.example.com cuentan como el mismo sitio. SameSite no le protege de un subdominio comprometido. Para las peticiones que cambian estado, mantenga también un token CSRF o compruebe la cabecera Origin.
Tamaño del token
Un ID de sesión sigue siendo diminuto por mucho que sepa del usuario. Un JWT crece con cada claim y según el algoritmo de firma. Los tokens que generamos para esta guía ocupaban 152 caracteres con HS256 y tres claims, 241 caracteres al añadir nombre, correo electrónico y rol, y 540 caracteres con esos mismos claims firmados con RS256 y una clave de 2048 bits. El RFC 6265 solo obliga a los navegadores a admitir cookies de al menos 4096 bytes, contando nombre y atributos, así que un JWT cargado de permisos puede no caber en una cookie. Y ese tamaño viaja en todas y cada una de las peticiones.
Errores que rompen la seguridad de un JWT
- Confiar en la cabecera
alg. Fije los algoritmos que acepta su servidor. Aceptarnone, o dejar que un token pase de RS256 a HS256 para que la clave pública se use como secreto HMAC, permite a los atacantes falsificar tokens. - Decodificar en lugar de verificar. Leer el payload no demuestra nada; verifique antes la firma con una biblioteca mantenida.
- Saltarse la comprobación de claims. Valide
exp,nbf,issyaud, para que un token emitido para un servicio no lo acepte otro.
El RFC 8725, las JWT Best Current Practices, trata estos errores y algunos más.
Cuándo encaja cada uno
| Situación | Lo que suele encajar mejor |
|---|---|
| Aplicación web renderizada en el servidor, en un solo dominio | Cookie de sesión |
| Aplicación de una sola página servida desde el mismo sitio que su API | Cookie de sesión, o un backend-for-frontend que guarde los tokens en el servidor |
| Aplicación móvil nativa | Access token de corta duración más refresh token en el almacenamiento seguro de la plataforma |
| Microservicios que verifican peticiones sin llamar a un servicio de autenticación | Access tokens JWT firmados con una clave asimétrica, como RS256 o ES256 |
| Acceso de terceros a su API | Access tokens de OAuth 2.0, que a menudo son JWT |
El modelo híbrido: un JWT dentro de una cookie HttpOnly
Muchas aplicaciones guardan el propio JWT en una cookie HttpOnly; Secure; SameSite. Los scripts no pueden leerlo y los servidores pueden seguir verificándolo sin consultar una sesión. Pero los inconvenientes también vienen incluidos: la cookie se envía automáticamente, así que vuelven a hacer falta defensas contra CSRF; se aplica el límite de 4 KB de las cookies; y revocarlo es exactamente igual de difícil que con cualquier otro JWT. Es un diseño sólido para una aplicación de navegador apoyada en APIs sin estado, siempre que lo trate como una cookie en lo que respecta al CSRF y como un JWT en lo que respecta a la revocación.