Errores comunes con JWT y sesiones, y cómo evitarlos
Los errores con JWT y sesiones que llevan al robo de cuentas: confusión de algoritmos, secretos débiles, claims sin validar, almacenamiento y logout real.
· 7 min de lectura · Lina Source LLC
Los JSON Web Token son un formato razonable con una larga lista de aristas. El token en sí rara vez es el problema. Los bugs viven en cómo se verifica, dónde se guarda, cuánto dura y qué ocurre cuando un usuario cierra sesión. Todos esos errores tienen el mismo resultado: alguien tiene un token que no debería tener, y tu servidor lo acepta.
Las dos debilidades que más aparecen son la CWE-347, verificación incorrecta de una firma criptográfica, y la CWE-613, expiración de sesión insuficiente. Los ejemplos de abajo usan jose, una librería de JavaScript muy utilizada para JWT que funciona en Node.js, en runtimes edge y en navegadores.
Decodificar no es verificar
La forma más directa de la CWE-347 es leer los claims de un token sin comprobar su firma. Toda librería de JWT tiene una función de decodificación para depurar, y aparece en los middleware de autenticación más a menudo de lo que debería. Un token decodificado no es más que base64 que cualquiera puede escribir. En las bases de código JavaScript el patrón suele esconderse en un pequeño helper que parte el token por los puntos y hace JSON.parse de la parte central. Funciona en todos los tests, porque los tokens de prueba son válidos, y acepta todos los tokens falsificados en producción.
import { decodeJwt, jwtVerify } from 'jose';
// Vulnerable: cualquiera puede emitir un token con sub apuntando a cualquier usuario
const claims = decodeJwt(token);
const userId = claims.sub;
// Correcto: primero se comprueban la firma, el algoritmo y los claims
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none y confusión de algoritmos
La cabecera del JWT dice qué algoritmo firmó el token, y el atacante controla la cabecera. De confiar en ella se derivan dos ataques clásicos. El primero es alg puesto a none: un token sin firmar que algunas librerías antiguas aceptaban como válido. El segundo es la confusión de algoritmos: a un servidor que espera RS256 pero deja que la cabecera elija el algoritmo se le puede entregar un token HS256 firmado usando la clave pública del servidor como secreto HMAC. La clave pública es pública, así que el atacante puede firmar lo que quiera.
Las librerías modernas se defienden de ambos, y jose rechaza los tokens sin proteger en jwtVerify y comprueba que el tipo de clave coincide con el algoritmo. No te fíes solo de los valores por defecto. Fija la lista de algoritmos de forma explícita en cada llamada de verificación, para que una refactorización futura o un cambio de librería no puedan ampliarla en silencio. Si aceptas más de un algoritmo, por ejemplo durante una migración de claves, lístalos explícitamente y usa una clave distinta para cada uno. Cuando rotes claves, pon un kid en la cabecera y selecciona la clave por kid desde tu propia lista, nunca desde una URL indicada en la cabecera del token.
Secretos de firma débiles
Un token HS256 se firma con un secreto compartido. Si el secreto es corto o adivinable, como «secret», el nombre de la aplicación o un valor copiado de un tutorial, un atacante que tenga un solo token válido puede romperlo por fuerza bruta offline con herramientas corrientes y después firmar tokens para cualquier usuario. No hay rate limit para las conjeturas offline.
- Usa al menos 32 bytes aleatorios para HS256, generados con algo como openssl rand -base64 32.
- Carga el secreto desde el entorno o desde un gestor de secretos, y falla al arrancar si falta o es corto.
- No lo commitees nunca, y rótalo si alguna vez se commiteó.
- Si varios servicios necesitan verificar tokens pero solo uno debería emitirlos, usa un algoritmo asimétrico como RS256 o EdDSA para que los verificadores solo tengan la clave pública.
Valida exp, aud e iss
Una firma válida solo demuestra quién emitió el token. Los claims deciden si está destinado a ti y si sigue vigente. Un token sin expiración es válido para siempre. Un token emitido para tu API móvil no debería ser aceptado por tu servicio de administración solo porque ambos confían en el mismo proveedor de identidad. Para eso están aud e iss.
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}jose comprueba exp siempre que está presente, pero un token sin exp pasaría igualmente. La opción requiredClaims cierra ese hueco. Para los tokens que vienen de un proveedor de identidad externo, verifícalos contra su conjunto de claves publicado con createRemoteJWKSet y fija aun así algoritmos, emisor y audiencia.
localStorage o cookies httpOnly
Guardar un token en localStorage lo deja legible para cualquier script de la página. Un solo bug de XSS, o un solo script de terceros comprometido, y el token se puede enviar a un atacante y usarse desde cualquier sitio hasta que expire.
Una cookie httpOnly no se puede leer desde JavaScript. El XSS sigue siendo grave, porque el script inyectado puede hacer peticiones como el usuario mientras la página está abierta, pero no puede robar una credencial de larga duración y reutilizarla más tarde. Para las aplicaciones de navegador que hablan con su propio backend, las cookies son el mejor valor por defecto. Para una single-page app que llama a una API separada, mantener el access token solo en memoria y el refresh token en una cookie httpOnly es un término medio razonable.
// Express: cookie de sesión con atributos seguros
res.cookie('__Host-session', accessToken, {
httpOnly: true, // no se puede leer desde JavaScript
secure: true, // solo HTTPS; lo exige el prefijo __Host-
sameSite: 'lax', // no se envía en POST cross-site
path: '/', // lo exige el prefijo __Host-
maxAge: 10 * 60 * 1000, // milisegundos, coincide con la vida del token
});El prefijo __Host- le dice al navegador que rechace la cookie a menos que sea Secure, tenga path puesto a / y no tenga atributo Domain, lo que impide que un subdominio comprometido la sobrescriba.
SameSite y lo que no cubre
SameSite=Lax bloquea la cookie en las peticiones POST cross-site y en las cargas de subrecursos, lo que elimina la mayor parte del CSRF clásico. Sigue enviando la cookie en las navegaciones GET de nivel superior, así que cualquier endpoint GET que cambie el estado queda expuesto. Strict bloquea también esas, pero además cierra la sesión de los usuarios cuando siguen un enlace hacia tu aplicación desde un correo u otro sitio. None desactiva la protección y exige Secure.
Lax más una regla de que las peticiones GET nunca cambian el estado es una base sólida. Para las mutaciones sensibles, añade una comprobación de la cabecera Origin o un token CSRF. Recuerda que SameSite trata como del mismo sitio a todos los subdominios de tu dominio registrable, así que un subdominio vulnerable todavía puede falsificar peticiones.
Rotación, revocación y logout de verdad
Un JWT sin estado no se puede revocar antes de que expire; ese es el precio de no consultar la base de datos en cada petición. La CWE-613 describe lo que pasa cuando se ignora ese precio: logouts, cambios de contraseña y suspensiones de cuenta que en realidad no terminan el acceso. Borrar una cuenta, cambiar un rol y sacar a alguien de un equipo también son eventos de revocación, y cada uno debería tener efecto en la siguiente petición, no en la siguiente expiración de token.
- Mantén los access token de vida corta, en el orden de minutos, no de días.
- Guarda los refresh token en el servidor, hasheados, y rótalos en cada uso.
- Si se vuelve a usar un refresh token que ya se había rotado, trátalo como un robo y revoca toda la familia de tokens.
- Añade una versión de token a la fila del usuario y al propio token; increméntala al cerrar sesión en todas partes, al cambiar la contraseña o al suspender la cuenta.
- Regenera el identificador de sesión en el login para prevenir la fijación de sesión.
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// Una versión incrementada o una cuenta desactivada cortan el acceso al instante
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}Esa consulta devuelve una lectura de base de datos por petición, que es el coste honesto de poder revocar. Muchas aplicaciones son más simples y más seguras con un ID de sesión opaco en una cookie y una tabla de sesiones. Cerrar sesión pasa entonces a ser borrar una fila. Usa JWT donde sus propiedades ayuden de verdad, como tokens de vida corta entre servicios, no porque sean lo que viene por defecto en un tutorial.
Elijas lo que elijas, el logout tiene que ocurrir en el servidor. Borrar la cookie o eliminar el token en el navegador solo quita la copia del usuario; una copia robada sigue funcionando hasta que el servidor la rechaza. Al cerrar sesión, borra la sesión del servidor o el refresh token, limpia la cookie usando el mismo nombre, path y atributos con los que se puso, e incrementa la versión de token si el usuario eligió cerrar sesión en todas partes. Un restablecimiento de contraseña debería terminar también todas las demás sesiones.
Una checklist breve de revisión
- Busca llamadas de decodificación en las rutas de autenticación.
- Confirma que cada llamada de verificación fija algoritmos, emisor y audiencia, y exige exp.
- Comprueba cómo se genera, se carga y se valida al arrancar el secreto de firma.
- Localiza dónde se guardan los tokens en el navegador.
- Comprueba que el logout, el cambio de contraseña y la suspensión de cuenta terminan las sesiones existentes.
Estas comprobaciones consisten sobre todo en leer las rutas de código de principio a fin, que es también como CodeAuditAgent aborda una auditoría: los hallazgos vienen con la línea citada, el CWE, un escenario de explotación y un parche, así que una comprobación de audiencia ausente se puede confirmar y corregir en minutos.