Inicio / Artículos

Cuando la autenticación JWT se vuelve con estado: un argumento a favor de las sesiones del lado del servidor en Node

Las listas de bloqueo y los almacenes de actualización hacen que la autenticación con JWT sea, de todos modos, basada en estado. Las sesiones de Express respaldadas por Postgres en Node.js son más simples, y son adecuadas cuando los JWT siguen siendo una buena opción.

Editor: REACTAPP.TOP7 min de lectura1694 palabras

Fuentes: Forget JSON Web Tokens: Server-Side Sessions Cut Our Auth Bugs By 70%

La autenticación JWT sin estado parece elegante en una pizarra, pero muchos equipos descubren que el cierre de sesión, el cierre forzado y los restablecimientos de contraseña reintroducen silenciosamente el estado. Una vez que entran en juego una lista de bloqueo por revocación, un almacén de tokens de actualización y middleware personalizado, la capa de autenticación se convierte en la parte más frágil del sistema. Este artículo analiza ese patrón de fallo, muestra cómo una configuración de sesiones basada en Postgres en Express lo reemplaza y explica los casos excepcionales en los que los JWT siguen siendo la herramienta más adecuada.

Un patrón de fallo familiar

Imagínese un equipo que se siente orgulloso de su configuración JWT hasta que la cola de soporte se llena. Los usuarios se desconectan al azar, los restablecimientos de contraseña dejan de surtir efecto, y un cliente presenta tres tickets en un solo día, cada uno describiendo el mismo paradoja: está conectado y al mismo tiempo no lo está. Los ingenieros pasan cuatro horas trabajando en ese único ticket, decodificando su token y verificando su fecha de vencimiento, y todo parece válido.

Los errores como este suelen surgir en las conexiones entre los diferentes mecanismos, cada uno añadido para solucionar una limitación del anterior.

Por qué “stateless” rara vez permanece así

La justificación estándar para usar JWT es atractiva. No hay almacenamiento de sesiones ni búsquedas en la base de datos: se coloca el ID del usuario en el payload, se firma y se envía al cliente, dejando que el propio token sea la fuente de verdad.

El problema radica en la revocación. Un token firmado permanece válido hasta que vence, y el servidor no cuenta con una forma integrada de cancelarlo antes de tiempo. Para cerrar la sesión del usuario en todos los lugares, reaccionar ante un token robado o invalidar el acceso tras un cambio de contraseña se necesita una lista negra de tokens, que es exactamente el tipo de estado compartido que el diseño pretendía eliminar.

En el escenario anterior, el equipo termina utilizando una lista negra de Redis para los tokens revocados, un almacenamiento separado para los tokens de actualización y una capa de middleware personalizada que los conecta. Un sistema adoptado por ser sin estado se ha convertido en el componente con más estado de toda la arquitectura.

En qué se convierte la verificación por solicitud

Una versión simplificada del middleware que se ejecuta con cada solicitud se ve así. Extrae el token de autenticación del encabezado Authorization, lo busca en la lista negra de Redis y luego verifica la firma y los datos del token con jwt.verify.

// verify access token
function verifyToken(req, res, next) {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "No token" });

// check if revoked
  const revoked = await redis.get(`revoked:${token}`);
  if (revoked) return res.status(401).json({ error: "Token revoked" });
  jwt.verify(token, process.env.SECRET, (err, decoded) => {
    if (err) return res.status(401).json({ error: "Invalid token" });
    req.user = decoded;
    next();
  });
}

Tal como está escrito, la función ni siquiera se ejecutaría: utiliza await sin que esté declarada como async. El problema más grave es de tipo arquitectónico. Cada solicitud realiza ahora una llamada a la red hacia Redis, por lo que la principal ventaja de los JWTs, evitar búsquedas, desaparece. Además, se genera un costo en la CPU para verificar el token y se realiza una verificación adicional de su fecha de vencimiento y sus datos.

Los errores se ocultan en los espacios entre esas tres verificaciones:

  • El token de acceso vence y la llamada de actualización falla en silencio.
  • La clave de la lista negra en Redis expira antes que el JWT que se suponía debía bloquear, por lo que un token revocado vuelve a ser válido.
  • Una solicitud de cierre de sesión compite con la siguiente llamada a la API, y el usuario es cerrado y vuelto a iniciar en el mismo segundo.
  • Cada uno de estos problemas requiere una solución en un nivel diferente, y una corrección en un nivel puede romper fácilmente una suposición en otro.

    Sustituir tres mecanismos por un almacén de sesiones

    La alternativa son las sesiones del lado del servidor. El cliente solo almacena un ID de sesión opaco en una cookie, y los datos de la sesión se encuentran en su base de datos. En Express, express-session con el almacén connect-pg-simple guarda las sesiones en Postgres:

    const session = require("express-session");
    const PgSession = require("connect-pg-simple")(session);
    
    app.use(session({
      store: new PgSession({ pool: db }),
      secret: process.env.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: {
        httpOnly: true,
        secure: true,
        maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
      }
    }));
    

    Algunas configuraciones se encargan de la mayor parte de las medidas de seguridad. httpOnly impide que JavaScript en la página acceda a la cookie, lo cual evita el robo de tokens mediante XSS. secure asegura que se envíe únicamente a través de HTTPS. resave: false evita guardar sesiones sin cambios en cada solicitud, y saveUninitialized: false impide que el almacenamiento se llene de sesiones vacías para visitantes anónimos. maxAge establece una vida útil de siete días para las sesiones. Tenga en cuenta que connect-pg-simple espera que su tabla de sesiones exista, así que créela como parte de sus migraciones.

    Las operaciones resultantes se vuelven casi triviales:

    • El inicio de sesión establece req.session.userId.
    • El cierre de sesión llama a req.session.destroy().
    • Un cierre de sesión forzado elimina las filas del usuario de la tabla de sesiones.

    La revocación ahora se realiza con un simple borrado, sin que exista vencimiento de la lista de bloqueo que pueda causar desincronización, y cada solicitud lee la misma fila autoritativa. Vale la pena añadir dos medidas de refuerzo: llamar a req.session.regenerate() al iniciar sesión para evitar la fijación de sesiones, y establecer sameSite en la cookie (además de protección CSRF cuando sea necesario), ya que las cookies se envían automáticamente por el navegador, a diferencia de los encabezados portadores.

    En el escenario descrito aquí, la migración eliminó aproximadamente 340 líneas de lógica de autenticación, y cada línea eliminada representaba un lugar menos donde un error pudiera ocultarse.

    Midiendo el cambio

    Para evaluar de manera justa este cambio, el equipo registró cada informe de error relacionado con la autenticación durante 90 días antes y 90 días después de la migración. Los números que reportaron son:

    Before (JWT):
      - Auth bugs reported:        43
      - Avg debug time per bug:    2.4 hours
      - Force-logout capability:   not possible
    
    After (Sessions):
      - Auth bugs reported:        13
      - Avg debug time per bug:    0.6 hours
      - Force-logout time:         < 1 second (delete row)
    Reduction: ~70%
    

    Los informes de errores disminuyeron de 43 a 13, lo que representa una reducción aproximada del 70%, y el tiempo promedio de depuración por error pasó de 2.4 horas a 0.6. El cierre forzado de sesión pasó de ser imposible a una eliminación de registros que tarda menos de un segundo. Estas son las cifras de un equipo, no un punto de referencia.

    La objeción habitual es la búsqueda en la base de datos con cada solicitud. Para la mayoría de las aplicaciones, leer la clave primaria de una tabla de sesiones es algo sencillo en comparación con las consultas que ya se ejecutan en una solicitud típica, por lo que el verdadero cuello de botella está casi siempre en otro lugar.

    Comparación de los dos caminos de solicitud

    Al mostrarlos uno al lado del otro, la diferencia es evidente. En el camino JWT, el servidor debe decodificar el token, consultar Redis para obtener la lista de bloqueos y validar su fecha de vencimiento y sus atributos antes de que se ejecute el manejador de rutas:

    Client --> [Token in header] --> Server
                                        |
                                Decode token (CPU)
                                        |
                                Check Redis blocklist (network)
                                        |
                                Validate expiry + claims
                                        |
                                    Route handler
    

    En el camino de sesiones, el servidor lee la sesión desde Postgres y luego se la pasa al manejador de rutas:

    Client --> [Session cookie] --> Server
                                        |
                              Lookup session in Postgres
                                        |
                                    Route handler
    

    Una búsqueda en una única fuente de verdad significa que, cuando algo falla, hay exactamente un lugar donde buscar.

    Dónde siguen encajando los JWT

    Nada de esto hace que los JWT sean malos. Son adecuados para tokens de corta duración y con propósito específico: enlaces de verificación de correo electrónico, flujos de restablecimiento de contraseñas y otras pruebas autónomas de intención que expiran en cuestión de minutos y nunca necesitan ser revocadas durante su uso. Un token de verificación como el que se muestra a continuación contiene el ID del usuario y su propósito, y expira después de 15 minutos:

    // email verification — expires fast, never needs revocation
    function makeVerifyToken(userId) {
      return jwt.sign({ userId, purpose: "verify" }, process.env.SECRET, {
        expiresIn: "15m"
      });
    }
    

    Incluir una declaración de purpose es importante, ya que evita que un token de verificación sea aceptado en lugares donde se espera otro tipo de token. Tenga en cuenta que un JWT por sí solo no puede garantizar un uso único; si un enlace de restablecimiento debe funcionar solo una vez, registre que fue utilizado, o asocie el token con un valor que cambie después de su uso, como el hash de la contraseña actual.

    Nunca fue el estándar el problema. El problema radicó en intentar aplicarlo a sesiones de usuario persistentes, revocables y de larga duración, que es precisamente para lo que estaban diseñadas las sesiones.

    Por qué los equipos se aferran a autenticaciones complejas

    Las sesiones parecen anticuadas y los JWTs parecen modernos, además de que hay muchísimos tutoriales sobre JWTs en comparación con los sobre sesiones. Ese desequilibrio determina qué se considera correcto.

    Los ingenieros experimentados a menudo optan por el camino opuesto y eligen soluciones aburridas. Los numerosos incidentes de producción les han enseñado que los sistemas sencillos rara vez generan notificaciones por la noche, y las sesiones del lado del servidor han funcionado de manera fiable durante décadas, mucho antes de que existiera Redis.

    Elegir su enfoque

    • ¿Está creando una nueva aplicación web? Elija sesiones del lado del servidor: una cookie httpOnly, secure, sameSite, con las sesiones almacenadas en Postgres, la sesión se regenera al iniciar sesión y se elimina al cerrar sesión. Para una comparación más amplia, consulte nuestra guía sobre cómo elegir entre sesiones y JWTs.
    • ¿Tiene una configuración con JWT que funciona bien? Déjela tal como está; migrarla solo por el gusto de hacerlo añade riesgos.
  • ¿Ya está manejando tokens de actualización, una lista de bloqueo e invalidaciones hechas a mano? Entonces ya existen sesiones en su sistema, solo que con más infraestructura, más puntos de fallo y más horas de depuración.
  • La mejor decisión desde el punto de vista ingenieril suele ser aquella que elimina código. Menos líneas de código significan menos posibilidades de errores, y menos capas hacen que los errores restantes sean más fáciles de encontrar.

    Lecturas relacionadas