Errores con JWT que se convierten en brechas
Última actualización:
Un JSON Web Token es una declaración firmada, no un sobre sellado. Son tres segmentos codificados en Base64url unidos por puntos: una cabecera que nombra el algoritmo de firma, un payload de claims sobre un usuario o una sesión, y una firma sobre los dos primeros. La firma prueba que el token lo emitió quien posee la clave y que no ha sido alterado desde entonces. No prueba nada sobre el secreto: cualquiera que tenga el token puede decodificar el payload y leer cada claim que contiene, y por eso decodificar uno requiere pegar, no una contraseña. Cuando los JWT fallan en producción, casi nunca es la criptografía lo que se rompió. Lo que se rompió es una comprobación que alguien se saltó: el algoritmo aceptado por confianza desde el propio token, la caducidad que nadie validó, el secreto compartido que un humano podría teclear de memoria. Pega cualquier token en el decodificador JWT y su cabecera y sus claims quedan a la vista. Este artículo recorre los errores de ingeniería que convierten esas comprobaciones omitidas en brechas, y la regla defensiva que cierra cada uno.
Firmado no es lo mismo que secreto
El payload es Base64url —una codificación, de la misma familia que el Base64 común—, no cifrado. Todo lo que lleva es legible por cualquiera por quien pase el token: direcciones de correo, roles, identificadores de tenant, números internos de cuenta y, de vez en cuando, cosas mucho peores, como marcas de restablecimiento de contraseña o fragmentos de datos personales que un desarrollador añadió para depurar y nunca retiró. Los tokens también viajan más lejos de lo que los desarrolladores imaginan: duermen en el almacenamiento del navegador, atraviesan registros de proxies y de aplicaciones, se copian en informes de fallos y se pegan en tickets de error y en hilos de chat. La regla práctica se sigue directamente: nunca pongas en un token un claim que no te sentirías cómodo viendo impreso en un archivo de registro, porque tarde o temprano aparecerá en uno.
El malentendido es comprensible: un token compacto parece texto cifrado, todo caracteres impronunciables y puntos. Pero la firma solo hace al token resistente a la manipulación: cambia un claim y la verificación falla. La confidencialidad es una construcción distinta de la misma familia de estándares (tokens cifrados, JWE), y rara vez es lo que es un token de acceso común. Diseña el payload como texto público con una firma encima: solo identificadores y datos de autorización, los datos sensibles recuperados en el servidor por ID, y vidas útiles cortas para que lo que se filtre caduque pronto.
Dejar que el token elija su propio algoritmo
La cabecera declara un alg, y el fallo más antiguo del libro de JWT es un verificador que se lo cree. Uno de los valores declarados es none: un token sin firmar, sin firma alguna. Una biblioteca o una comprobación artesanal que obedece el algoritmo que el propio token declara puede recibir un token con la firma vacía y aceptarlo como genuino. El RFC de buenas prácticas es tajante en esto: los tokens sin firmar solo son aceptables donde el propio canal ya garantiza la integridad, y un token bearer que llega por la internet pública no es ese caso. El mismo defecto de fiarse de la cabecera produce la confusión de algoritmo. Un sistema que emite tokens RS256 guarda una clave privada y publica la clave pública correspondiente para verificar. Si se puede persuadir al verificador de que procese el token como HS256, el material de verificación pasa a ser la clave pública —pública por diseño, conocida por todos— usada como secreto simétrico, y las firmas que cualquiera puede producir empiezan a verificar. En ninguno de los dos fallos se roba una clave; el verificador simplemente siguió instrucciones escritas por el remitente.
La cabecera lleva un segundo riesgo, más silencioso: los punteros a claves. Parámetros como kid (un identificador de clave), jku y x5u (URL donde vive una clave o un certificado) le dicen al verificador dónde encontrar la clave de verificación, y llegan dentro del token, que está influido por el atacante. Si un valor de kid fluye sin control hacia una ruta de archivo, una consulta a la base de datos o una obtención de claves, la propia búsqueda se convierte en la superficie de ataque. Las reglas defensivas tienen todas la misma forma: fija el algoritmo esperado en la configuración del servidor y rechaza todo lo demás; resuelve las claves desde tu propio registro, con los identificadores contrastados contra una lista de permitidos; nunca obtengas material de claves de una URL que el token suministre; y trata cada campo de la cabecera como entrada no confiable, porque lo es.
La firma pasó; los claims nunca se comprobaron
Verificar una firma responde a una sola pregunta: ¿emitió este token el poseedor de la clave y quedó intacto? No dice nada sobre si el token debe aceptarse aquí y ahora. Ese es el trabajo de los claims, y la validación de claims es donde los sistemas en producción se saltan pasos en silencio. Primero exp: un verificador que nunca comprueba la caducidad convierte cada token en una credencial permanente, así que un token filtrado en un registro hace tres años sigue autenticando hoy. Después iss: acepta tokens solo de los emisores que configuraste, no de cualquiera que comparta un algoritmo. Y aud, la omisión más cara: las organizaciones suelen operar varios servicios contra una misma clave de firma, y el token de un servicio de poco valor —una API de nivel gratuito, una herramienta cercana a staging— se convierte entonces en un token correctamente firmado que un servicio de alto valor acepta, porque la firma es genuina y nadie preguntó para qué audiencia se emitió el token.
Nada de esto es exótico. Es una lista de verificación, y el modo de fallo es que las bibliotecas verifican la firma con entusiasmo mientras dejan las comprobaciones de claims como opcionales, desactivadas o problema del llamante. La postura defensiva es exigir exp, iss y aud en cada verificación, fallar cerrado ante cualquier ausencia o discrepancia, y tolerar desviaciones de reloj en minutos, no en horas: la tolerancia existe para relojes imperfectos, y estirada lo suficiente revive en silencio los tokens caducados. Después prueba los casos negativos como probarías un formulario de acceso: un token caducado, uno con la audiencia equivocada y uno de un emisor desconocido deben ser rechazados, y esos rechazos merecen registrarse como la señal de seguridad que son.
Los secretos que un humano recuerda son secretos que un atacante puede probar
HS256 es simétrico: un único secreto compartido firma y verifica. En bases de código reales ese secreto es demasiado a menudo una palabra o una frase corta: el nombre de la empresa, un valor por defecto de framework copiado de un tutorial, algo que un desarrollador podría teclear de memoria. He aquí el principio que lo hace fatal, y es una propiedad del diseño más que de cualquier herramienta: un token capturado lleva todo lo necesario para probar un secreto adivinado sin conexión. El mensaje se conoce, la firma se conoce, así que las conjeturas pueden comprobarse en local, a toda velocidad, sin que el servidor vea jamás un intento ni se aplique límite de velocidad alguno. La seguridad de todo el esquema se reduce, por tanto, a la entropía de esa única cadena, y las cadenas elegidas por humanos tienen muy poca.
Las correcciones no tienen glamour. Genera los secretos de firma con una máquina, con una longitud adecuada —al menos 256 bits de entropía para HMAC— y guárdalos como las credenciales de producción que son. Que sean únicos por entorno, para que el secreto de una configuración de staging filtrada no compre nada en producción, y nunca dejes que un valor por defecto de desarrollo sobreviva a un despliegue real. Donde muchos servicios verifican tokens, prefiere algoritmos asimétricos como RS256 o ES256: los verificadores conservan entonces solo claves públicas, de modo que un verificador comprometido no filtra nada con lo que acuñar tokens, y la clave de firma permanece en un solo lugar. Y ten una historia de rotación antes de necesitarla, porque con un secreto simétrico no suena ninguna alarma cuando se filtra: la primera señal suele ser un token que no emitiste siendo aceptado como tuyo.
Dónde guarda el navegador el token decide quién puede leerlo
En el cliente, la elección de almacenamiento es una decisión de seguridad tanto si alguien la tomó deliberadamente como si no. Un token en localStorage es legible por cada script que corre en la página: tu propio código, cada etiqueta de terceros y fragmento de analítica que has admitido, y cualquier carga de cross-site scripting que llegue a ejecutarse, proceda del campo de comentarios sin escapar que proceda en cualquier rincón de la aplicación. Un token bearer es una credencial copiable: quien lo lee puede presentarlo desde cualquier sitio y ser ese usuario hasta que caduque. Los tokens longevos en almacenamiento legible por scripts valen, por tanto, exactamente lo que vale tu peor fallo de XSS, y la mayoría de las aplicaciones acaba teniendo uno.
La alternativa habitual, una cookie HttpOnly, elimina por completo el acceso de los scripts —una carga XSS ya no puede leer y exfiltrar el token—, pero el navegador adjunta la cookie automáticamente, lo que convierte la falsificación de petición en sitios cruzados en la amenaza a combatir, con atributos SameSite y tokens CSRF. Guardar los tokens solo en memoria esquiva ambos problemas y pierde la sesión en cada recarga. No existe una respuesta universalmente correcta, y fingir lo contrario es como los equipos acaban con lo peor de cada opción. Lo que convierte el almacenamiento en brecha es la combinación que nadie eligió a propósito: almacenamiento legible por scripts, vidas largas, scopes amplios y ninguna rotación de tokens de refresco que reduzca el valor de una copia. Elige el compromiso conscientemente, mantén cortas las vidas de los tokens de acceso en cualquier caso, y escribe el razonamiento donde el siguiente desarrollador lo encuentre.