Guía de seguridad de JSON Web Tokens (JWTs)

Breve definición

JSON Web Token (abreviado JWT) es un estándar abierto basado en JSON propuesto por IETF (RFC 7519) para la creación de tokens de acceso que permiten la propagación de identidad y privilegios.

El estándar de JWT se basa en otros estándares basados en JSON JSON Web Signature (RFC 7515) y JSON Web Encryption (RFC 7516)

El uso de JWTs como mecanismo para gestionar la autenticación se está convirtiendo en la práctica popular.  

En la autenticación un JWT es un token emitido por el servidor, el cual contiene una serie de datos JSON  con información específica del usuario. Los clientes pueden utilizar este token para comunicarse con APIs (enviándolo en la cabecera HTTP) a fin de poder identificar al usuario representado por el token y poder tomar acciones específicas.

Un JWT contiene una firma que es creada por el servidor que emitió el token y cualquier otro servidor que reciba ese token  puede verificar de forma independiente la firma para asegurarse de que los datos del JSON  no han sido alterados y la información ha sido emitida por una fuente legítima.

Por otro lado, si un JWT es robado se puede seguir utilizando, ya que una API que acepta JWTs realiza una verificación independiente sin depender del origen del JWT, por lo que el servidor del API no tiene forma de saber si se trata de un token robado. Por este motivo, los JWTs tienen un valor de caducidad que por norma general suele ser de 15 minutos para que en el caso de que sea robado deje de tener validez lo antes posible. De todos modos, se debe de evitar de que haya alguna posibilidad de robar un JWT.

Debido a lo anterior es muy importante no almacenar el JWT en el lado cliente mediante cookies o el localstorage. Si se almacena mediante cookies o localstorage la aplicación es vulnerable a ataques de CSRF y XSS a través de formularios o scripts maliciosos.

Para obtener una descripción más detallada de los JWT, consulte Introduction to JSON Web Tokens.

Estructura de un JWT

Cuando se serializa un JWT, en base64, tiene este aspecto:

JWT en base64 Expand source

Si se decodifica el base64, se obtiene un JSON con tres partes: cabecera, datos y firma.

 

La forma serializada sigue el siguiente formato:

Formato forma serializada Expand source

El JWT no está encriptado, está codificado y firmado en base64 por lo que cualquiera puede decodificar el token y usar sus datos. La firma de un JWT se usa para verificar que en realidad proviene de una fuente legítima.

Se puede decir que a los desarrolladores back-end les gustan los JWTs  por los microservicios y no necesitar una base de datos de tokens centralizada. En una configuración de microservicios, cada microservicio puede verificar de forma independiente si el token recibido de un cliente es válido. El microservicio puede decodificar el token y extraer información relevante sin necesidad de tener acceso a una base de datos de tokens centralizada.

Por tanto, el flujo de los JWTs podría ilustrarse cómo:

 

Ejemplo de implementación del proceso de autenticación

A continuación se detalla cómo se puede realizar el proceso de autenticación y renovación de token mediante JWTs.

Inicio de sesión

El proceso de inicio de sesión con JWTs apenas cambia con respecto a un inicio de sesión estándar, puede ser a través de un proveedor externo o mediante OAuth u OAuth2. El tipo de inicio de sesión no importa siempre que el cliente obtenga un token JWT en la respuesta de un inicio de sesión válido.

El primero paso es enviar las credenciales al servidor, el servidor emitirá un token JWT que, de momento, se guardará en memoria (más adelante se explica la forma correcta de hacerlo).

Por tanto, se necesita almacenar el token JWT en alguna parte para poder reenviarlo a la API en la cabecera y una primera solución puede ser guardarlo en localstorage. Nunca se debe de hacer de esta manera ya que es vulnerable a ataques XXS. Otra opción puede ser el uso de cookies pero también son vulnerables a ataques XSS, si se pueden leer en el lado cliente mediante JavaScript fuera de la aplicación pueden robarse. El uso del atributo HttpOnly  podría ayudar pero las cookies seguirían siendo vulnerables a ataques de Cross-site request forgery (CSRF) por lo que aunque se utilice el atributo HttpOnly y una implementación de políticas contra ataques CSRF no son suficientes para evitar el problema, es necesario la implementación de una estrategia de mitigación de ataques CSRF apropiada para dicho escenario.

 

También se puede usar la nueva especificación de cookies  llamada SameSite que hace que los ataques CSRF no sean posibles, pero el soporte en todos los navegadores no es completo. Se puede consultar el estado de cobertura en el siguiente enlace: Soporte del atributo SameSite en navegadores. Esta solución puede no ser viable si los servidores de la API están alojados en diferentes dominios.

Una vez obtenido el token,  podemos usarlo para:

  • Pasarlo en la cabecera de cualquier llamada a la API.

  • Comprobar si un usuario ha iniciado sesión comprobando si la variable JWT está presente.

  • Opcionalmente, se puede decodificar el JWT en el cliente para acceder a los datos.

Configuración del cliente

Al configurar el cliente, la idea es obtener el token de la variable que lo almacena en memoria  y si está presente se envía al cliente.

Suponiendo que la API de destino acepta un token de autenticación JWT como cabecera de autorización lo único que se necesita hacer es configurar el cliente para que establezca una cabecera HTTP utilizando el token JWT con la variable donde está almacenado. En el caso de que no exista el token, el flujo a seguir depende de la aplicación pero se podría redirigir a la página de autenticación.

 

Otro aspecto a tener en cuenta es la caducidad del token. Un valor de caducidad común es de 15 minutos de duración, pasado ese tiempo se recibirá un error de la API al rechazar la solicitud ya que cada servicio que utilice JWT puede verificar y comprobar de forma independiente si el token ha expirado o no. Al recibir un token expirado de la API  se cerrará la sesión o se redirigirá al usuario a la pantalla de inicio de sesión. El problema de estas aproximaciones es que para el usuario final resulta una experiencia no deseable al tener que inciar sesión cada vez que el token expira, por lo que para evitar esto las aplicaciones implementan un mecanismo de actualización silencioso para actualizar el token JWT en segundo plano.

Cierre de sesión

Al usar JWTs el cierre de sesión se basa en eliminar el token en el lado del cliente para que no se pueda usar en futuras llamadas a la API.

En este caso, la API no necesita ningún punto de entrada de logout porque cualquier microservicio que acepte los JWTs los seguirá aceptando. Si el servidor de autenticación elimina el JWT dará igual porque los otros servicios lo seguirán aceptando (ya que el objetivo de los JWT es no requerir una coordinación centralizada).

 

Por lo tanto, el token sigue siendo válido y se puede usar de modo que si se quiere asegurar de que no es posible usarlo más se deben de usar unos valores de caducidad reducidos además de asegurarse de que los JWT no son robados. De este modo el token es válido (incluso después de eliminarlo en el cliente) pero solo por un período corto de tiempo para reducir la probabilidad de que sea utilizado maliciosamente.

Otra posibilidad es añadir una lista negra de JWTs, en este caso la API tendrá un punto de entrada logout y el servidor colocará en una lista de "no válidos" todos los JWT que se envíen a dicho punto de entrada. Sin embargo, todos los servicios de la API que usan JWT ahora necesitan agregar un paso adicional a su verificación JWT para verificar si está en la "lista negra" centralizada. Esto introduce de nuevo el estado centralizado y va en contra de los motivos elegidos para usar JWT.

Pero aún quedaría un caso por resolver, el de haber iniciado sesión en pestañas diferentes. Una forma de resolver esto sería mediante la introducción de un detector de eventos global en localstorage. Cada vez que actualizamos la clave de cierre de sesión en localstorage en una pestaña, el detector activará las otras pestañas realizando también un "cierre de sesión" y redirigirá a los usuarios a la pantalla de inicio de sesión.  Al cerrar sesión se deben de realizar estas dos cosas:

 

  • Poner el valor del token a null.

  • Añadir el valor logout en el localstorage.

De esta forma, cuando se cierre la sesión en una pestaña, el detector de eventos activará todas las demás pestañas y las redireccionará a la pantalla de inicio de sesión.

Actualización silenciosa

Actualmente hay 2 problemas principales a los que se tendrían que enfrentar los usuarios:

  • Debido a los cortos periodos de caducidad en los JWTs la sesión se cerrará cada 15 minutos,

  • Si un usuario cierra su aplicación y la abre de nuevo, deberá iniciar sesión de nuevo. Su sesión no es persistente porque no se está guardando el token JWT en el cliente.

La forma mas utilizada para resolver estos problemas es proporcionan un token de actualización. Un token de actualización tiene 2 propiedades:

  • Se puede usar para hacer una llamada a la API (por ejemplo, refresh_token) para obtener un nuevo token JWT antes de que caduque el  token anterior.

  • Se puede persistir de forma segura entre sesiones en el lado cliente.

El token de actualización se emite como parte del proceso de autenticación junto con el JWT. El servidor de autenticación debe guardar este token de actualización y asociarlo a un usuario específico en su propia base de datos, de modo que pueda manejar la lógica de renovación de JWT. En el cliente, antes de que caduque el token JWT actual la aplicación realiza la llamada a la API (por ejemplo, refresh_token) para obtener un nuevo token JWT.

El servidor de autenticación envía el token de actualización al cliente en una cookie con la cabecera de HttpOnly y el navegador lo envía automáticamente en una llamada a la API (por ejemplo, refresh_token). Debido a que el Javascript del lado del cliente no puede leer o robar una cookie con la cabecera HttpOnly, se mitigan los ataques XSS frente a persistirla como una cookie normal o en localstorage. También está a salvo de los ataques CSRF debido a que aunque un atacante consiga realizar una llamada a la API (por ejemplo, refresh_token) el atacante no puede obtener el nuevo valor de token JWT que se devuelve.

Si bien este método no es resistente a los ataques XSS más sofisticados, junto con las técnicas habituales de mitigación de XSS una cookie con la cabecera HttpOnly es una forma recomendada de mantener la información relacionada con la sesión y al persistir nuestra sesión indirectamente a través de un token de actualización, evitamos de forma directa una vulnerabilidad CSRF que sí estaría presente con un token JWT.

Por lo tanto, el nuevo flujo del proceso de inicio de sesión quedaría de la siguiente manera:

 

  1. El usuario inicia sesión mediante una llamada a la API.

  2. El servidor genera un JWT y un token de actualización.

  3. El servidor genera una cookie, con una cabecera HttpOnly, con el token de actualización. El JWT y el tiempo de caducidad del token son devueltos al cliente como datos del JSON.

  4. El JWT se almacena en memoria.

  5. Se inicia la cuenta atrás para realizar la actualización silenciosa basándose en el tiempo de caducidad.

En cuanto al proceso de actualización silenciosa, el flujo queda de la siguiente forma:

 

  1. Se realiza una llamada a la API (por ejemplo, refresh_token).

  2. El servidor consulta la cookie con la cabecera HttpOnly en busca de un token de actualización válido.

  3. Si el servidor encuentra un token de actualización válido, envía un nuevo JWT junto con su tiempo de caducidad al mismo tiempo que establece un nuevo token de actualización en la cabecera Set-Cookie.

Sesiones persistentes

Ahora los usuarios no tendrán su sesión cerrada cada vez que caduque el token y se renovará automáticamente pero sigue existiendo un problema, si el usuario cierra la aplicación y la vuelve a abrir (al cerrar la pestaña del navegador y volver a abrirla) se le pedirá de nuevo iniciar sesión. Esto ocurre porque el JWT solo se almacena en la memoria y no se persiste.

Las aplicaciones normalmente preguntan si el usuario desea "seguir conectado" entre sesiones o, por defecto, mantener a sus usuarios con la sesión iniciada. Por lo tanto, el siguiente paso será permitir dicha funcionalidad.

Para persistir las sesiones de forma segura utilizaremos la actualización de tokens. De la misma forma que se utiliza la actualización silenciosa de token para renovar la sesión actual y que el usuario no sea desconectado, se puede usar para obtener un nuevo JWT para la nueva sesión. Cuando el usuario cierra la sesión actual cerrando la pestaña del navegador y vuelve a visitar la aplicación el flujo quedaría de la siguiente manera:

 

  1. Si se detecta que no hay un JWT en memoria entonces se realiza el flujo de actualización silenciosa.

  2. Si se detecta un JWT válido (o no ha sido invalidado) se obtiene un nuevo JWT.

  3. En caso de que el token de actualización haya caducado (si el usuario vuelve a abrir la aplicación tras un largo periodo de tiempo) o haya sido invalidado (si se ha hecho un cierre de sesión forzoso) el usuario obtendrá un error 401. Esto también ocurriría si no hay ningún token de actualización en el momento de recuperar la sesión por lo que se obtendrá un error al llamar a la API (por ejemplo, refresh_token) y se redirigirá al usuario a la pantalla de inicio de sesión.

Cierre de sesión forzado (Cerrar todas las sesiones / Cerrar sesión en todos los dispositivos)

Ahora que los usuarios tienen sesiones que no caducan y pueden permanecer conectados durante mucho tiempo, queda un problema del que ocuparse: el de forzar el cierre de sesión o cerrar sesión en todas las sesiones y dispositivos.

Para ello, sería suficiente implementar en el lado servidor una funcionalidad que forzase el cierre de todas las sesiones invalidando todos los tokens asociados actualmente al usuario. Desde el lado cliente bastaría con realizar una llamada a la API para tal función (por ejemplo, force_logout).

Generación de la página en el lado servidor (Server Side Rendering)

Cuando se utiliza SSR existen ciertas complejidades al usarlo con JWT. Cuando se utiliza JWT se busca:

  1. Que el navegador realice una solicitud a la URL de la aplicación.

  2. El servidor SSR genera la página en función del usuario que la solicita.

  3. El usuario obtiene la página generada y continúa usando la aplicación como si fuese una aplicación de página única (Single Page Application).

Para que esto sea posible el servidor necesita saber si el usuario ha iniciado sesión por lo que el navegador debe de enviar cierta información sobre la identidad del usuario actual al servidor SSR y la única forma de hacerlo es mediante una cookie.

Como ya está implementado un flujo para la actualización de tokens mediante cookies, cuando se realiza una solicitud al servidor SSR se debe de asegurar que también se envía el token de actualización.

En el caso de usar páginas auntenticadas con servidores SSR es importante que el dominio de la API de autenticación (y, por lo tanto, el dominio de la cabecera refresh_token de la cookie) sea el mismo que el dominio del servidor SSR. De no ser así, las cookies no se enviarán al servidor SSR.

 

Los pasos que el servidor SRR realiza son:

  1. Al recibir una solicitud para generar una página en particular, el servidor SSR captura la cookie refresh_token.

  2. El servidor SSR usa la cookie refresh_token para obtener un nuevo JWT para el usuario.

  3. El servidor SSR utiliza el nuevo JWT y realiza todas las solicitudes autenticadas para obtener los datos correctos.

El inconveniente es que el usuario no puede continuar haciendo solicitudes autenticadas a la API una vez que se ha cargado la página devuelta por el servidor SSR. Una vez que el servidor SSR devuelve el HTML generado, la única identificación que queda en el navegador sobre la identidad del usuario es la antigua cookie con el token de actualización que ya ha sido utilizado por el servidor SSR. Si la aplicación intenta usar esa cookie para obtener un nuevo JWT, la solicitud fallará y la sesión del usuario será invalidada.

Para resolver esto, después de generar la página el servidor SSR debe enviar la cookie con el último token de actualización para que el navegador pueda usarlo.

Anexo

The Parts of JWT Security Nobody Talks About | Philippe De Ryck, Google Developer Expert

Referencias

The Ultimate Guide to handling JWTs on frontend clients (GraphQL)

JWT.IO