Inicio / Artículos

Implementación conjunta de tokens de acceso y tokens de actualización en Node.js

Tokens de acceso de corta duración junto con tokens de actualización rotativos en Node.js: el flujo JWT, dónde almacenar cada token y cómo la rotación evita que los tokens de actualización sean robados.

Editor: REACTAPP.TOP11 min de lectura2600 palabras

Fuentes: Access Tokens vs Refresh Tokens in Node.js: Why Real Applications Need Both

Cuando se crea el primer flujo de autenticación en Node.js, el enfoque típico es recurrir a un único token JSON Web (JWT). El usuario envía su correo electrónico y contraseña, el servidor los verifica, firma un token y se lo devuelve. A partir de ese momento, el frontend incluye este token dentro del encabezado Authorization en cada solicitud.

Este diseño funciona bien mientras se prueban las aplicaciones en localhost. Pero tan pronto como los usuarios reales comienzan a utilizar la aplicación, surge una tensión estructural.

Establezca la vida útil del token en un período corto, como 15 minutos; de esta manera, los usuarios serán desconectados en medio de una tarea, quizás mientras escriben un comentario o a mitad de un formulario. Si se establece en un período largo, como 30 días, se mantendrá a las personas conectadas, pero también se creará una grave brecha de seguridad: si ese token es robado, no hay forma de revocarlo sin invalidar todos los tokens de todos los usuarios en todo el sistema.

Esta tensión entre la conveniencia para los usuarios y la seguridad es precisamente la razón por la cual los sistemas de nivel profesional no dependen de un solo token: utilizan dos, a saber, un token de acceso y un token de actualización.

Analicemos por qué existe este modelo de dos tokens, cuál es la función de cada uno y cómo implementarlo correctamente en un backend de Node.js.

El problema fundamental de la autenticación con un solo token

Antes de explicar por qué son necesarios dos tokens, es útil revisar primero lo que hace únicos a los JWT: son sin estado.

Cuando el servidor Node.js recibe un JWT, no necesita consultar una base de datos para verificar si existe una sesión. Simplemente compara la firma criptográfica del token con una clave secreta:

Token payload + Secret key = Signature verified?
├── Yes -> Request allowed
└── No  -> 401 Unauthorized

El hecho de ser sin estado es lo que hace que las APIs basadas en JWT sean rápidas y fáciles de escalar, ya que no se consulta la base de datos en cada solicitud recibida. Pero esta misma característica tiene una desventaja: una vez emitido un token, el servidor no cuenta con una forma sencilla de invalidarlo tempranamente.

Considere esta secuencia de eventos:

  • Su único JWT está configurado para durar 30 días.
  • Un atacante lo consigue, quizás a través de un ataque XSS o interceptando el tráfico en una red no segura.
  • El propietario de la cuenta se da cuenta de que algo no está bien y cambia su contraseña.
  • El atacante sigue trabajando como si nada hubiera pasado. El token robado permanece válido en teoría hasta que se agota su vida útil de 30 días, independientemente del cambio de contraseña.
  • Una solución alternativa es mantener una lista negra de tokens revocados en un sistema como Redis, pero verificar esa lista en cada llamada a la API convierte efectivamente su sistema de tokens “sin estado” en uno con estado.

    Este es exactamente el problema que está diseñado para resolver el enfoque de dos tokens.

    ¿Qué son los tokens de acceso y los tokens de actualización?

    En lugar de hacer que un único token sea responsable tanto del acceso habitual a la API como de la persistencia a largo plazo de la sesión, la carga de trabajo se divide entre dos tokens:

    +------------------+-----------------------------+-----------------------------+
    | Feature          | Access Token                | Refresh Token               |
    +------------------+-----------------------------+-----------------------------+
    | Purpose          | Authenticate API requests   | Obtain new access tokens    |
    | Lifetime         | Short (5 to 15 minutes)     | Long (7 to 30 days)         |
    | Storage          | Memory or short-lived state | HttpOnly, Secure Cookie     |
    | Database lookup  | No (purely stateless)       | Yes (checked on refresh)    |
    | Exposure risk    | High (sent on every request)| Low (sent only to /refresh) |
    +------------------+-----------------------------+-----------------------------+
    

    1. El token de acceso

    Considere el token de acceso como un pase de corta duración. El cliente lo adjunta a los encabezados de las solicitudes API habituales, como GET /api/orders o POST /api/profile.

    Dado que solo es válido durante un breve período (generalmente de 10 a 15 minutos), cualquier daño potencial causado por una fuga se limita. El servidor puede verificarlo únicamente mediante comprobaciones de firma basadas en la CPU, sin necesidad de acceder a la base de datos, lo que mantiene la API ágil.

    2. El token de actualización

    El token de actualización funciona como un vale de renovación a largo plazo. Nunca se envía junto con las llamadas API habituales; su única función es comunicarse con un endpoint dedicado, como POST /api/auth/refresh.

    Una vez que vence el token de acceso de corta duración, el cliente llama silenciosamente a esta ruta de actualización. El servidor luego consulta la base de datos para confirmar que la cuenta del usuario sigue estando activa y autorizada, y a continuación emite un nuevo token de acceso.

    Dado que esta llamada de actualización ocurre aproximadamente cada 15 minutos y no con cada interacción del usuario, agregar una consulta a la base de datos apenas afecta el rendimiento.

    El ciclo de vida de la solicitud

    Así es como las tres partes —cliente, servidor y base de datos— interactúan con el paso del tiempo:

    Client                             Server                           Database
      │                                  │                                 │
      ├─────── 1. Login (credentials) ──>│                                 │
      │                                  ├─── Verify credentials ─────────>│
      │                                  │<── User record valid ───────────┤
      │                                  ├─── Store Refresh Token Hash ───>│
      │<────── 2. Return Tokens ─────────┤                                 │
      │       (Access: 15m, Refresh: 7d) │                                 │
      │                                  │                                 │
      │─────── 3. GET /api/dashboard ───>│                                 │
      │       [Bearer Access Token]      ├─── Verify signature (Stateless) │
      │<────── 4. 200 OK + Data ─────────┤                                 │
      │                                  │                                 │
      │    ... 15 minutes pass ...       │                                 │
      │                                  │                                 │
      │─────── 5. GET /api/dashboard ───>│                                 │
      │       [Expired Access Token]     ├─── Token expired!               │
      │<────── 6. 401 Unauthorized ──────┤                                 │
      │                                  │                                 │
      │─────── 7. POST /api/auth/refresh>│                                 │
      │       [Cookie: Refresh Token]    ├─── Check if token in DB ───────>│
      │                                  │<── Token valid & not revoked ───┤
      │                                  ├─── Generate new Access Token    │
      │<────── 8. Return new Access Token┤                                 │
      │                                  │                                 │
      │─────── 9. Retry GET /dashboard ─>│                                 │
      │       [New Access Token]         ├─── Verify signature             │
      │<────── 10. 200 OK + Data ────────┤                                 │
    

    Desde el punto de vista del usuario, todo este proceso de renovación es invisible. Su sesión simplemente sigue funcionando en segundo plano, sin que mensajes abruptos como “Sesión vencida” interrumpan su uso activo.

    Poniéndolo en práctica con Node.js

    Ahora construyamos una configuración funcional de Express que muestre cómo emitir, verificar y renovar tokens.

    Comience instalando los paquetes necesarios:

    npm install express jsonwebtoken cookie-parser dotenv
    

    1. Creación de los tokens

    Configure un módulo auxiliar que genere ambos tokens, cada uno firmado con su propia clave secreta:

    // tokenService.js
    const jwt = require('jsonwebtoken');
    
    const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_123';
    const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_456';
    
    function generateAccessToken(userId) {
      // Short lifespan: 15 minutes
      return jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
    }
    
    function generateRefreshToken(userId) {
      // Long lifespan: 7 days
      return jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
    }
    
    module.exports = {
      generateAccessToken,
      generateRefreshToken,
      ACCESS_TOKEN_SECRET,
      REFRESH_TOKEN_SECRET
    };
    

    Punto clave: siempre proporcione al token de acceso y al token de actualización claves secretas separadas. Si comparten una misma clave secreta, alguien podría tomar un token de actualización vencido y presentarlo donde se espera un token de acceso, evitando así la expiración que pretendía imponerse.

    2. Manejo del inicio de sesión y almacenamiento del token de actualización

    La ruta de inicio de sesión debe almacenar el token de actualización, o mejor aún su hash SHA-256, en su base de datos, vinculado a ese usuario, para poder consultarlo posteriormente:

    // server.js
    const express = require('express');
    const cookieParser = require('cookie-parser');
    const jwt = require('jsonwebtoken');
    const {
      generateAccessToken,
      generateRefreshToken,
      ACCESS_TOKEN_SECRET,
      REFRESH_TOKEN_SECRET
    } = require('./tokenService');
    
    const app = express();
    app.use(express.json());
    app.use(cookieParser());
    
    // Simulated database table for refresh tokens
    const refreshTokenDatabase = new Set();
    
    app.post('/api/auth/login', async (req, res) => {
      const { email, password } = req.body;
    
      // In a real app: verify email and hashed password from DB
      const mockUserId = 'usr_987654';
    
      const accessToken = generateAccessToken(mockUserId);
      const refreshToken = generateRefreshToken(mockUserId);
    
      // Save the refresh token to our database so we can revoke it later
      refreshTokenDatabase.add(refreshToken);
    
      // Send refresh token inside an HttpOnly cookie
      res.cookie('refreshToken', refreshToken, {
        httpOnly: true,     // Prevents JavaScript from reading the cookie (protects from XSS)
        secure: process.env.NODE_ENV === 'production', // Requires HTTPS
        sameSite: 'strict', // Protects against CSRF attacks
        maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
      });
    
      // Send access token in JSON body for frontend memory storage
      res.json({ accessToken });
    });
    

    3. Protección de los puntos finales empresariales

    Una función de middleware verifica el token de acceso obtenido del encabezado Authorization antes de permitir que pase una solicitud:

    function authenticateAccessToken(req, res, next) {
      const authHeader = req.headers['authorization'];
      // The header format is "Bearer <token>"
      const token = authHeader && authHeader.split(' ')[1];
    
      if (!token) {
        return res.status(401).json({ error: 'Access token missing' });
      }
    
      jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
        if (err) {
          // 403 signals to the client that the token is invalid or expired
          return res.status(403).json({ error: 'Invalid or expired access token' });
        }
        req.userId = decoded.sub;
        next();
      });
    }
    
    // Protected route
    app.get('/api/orders', authenticateAccessToken, (req, res) => {
      res.json({
        message: `Orders retrieved for user ${req.userId}`,
        data: [{ id: 1, item: 'Mechanical Keyboard' }]
      });
    });
    

    4. Creación de la ruta de actualización

    Una vez que el cliente recibe un código 403 al realizar una llamada como /api/orders, accede a este punto final para obtener un nuevo token de acceso:

    app.post('/api/auth/refresh', (req, res) => {
      const refreshToken = req.cookies.refreshToken;
    
      if (!refreshToken) {
        return res.status(401).json({ error: 'Refresh token missing' });
      }
    
      // Check if token exists in the database
      if (!refreshTokenDatabase.has(refreshToken)) {
        return res.status(403).json({ error: 'Refresh token revoked or unknown' });
      }
    
      jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
        if (err) {
          // Remove invalid token from database
          refreshTokenDatabase.delete(refreshToken);
          return res.status(403).json({ error: 'Refresh token expired' });
        }
    
        // Token is valid! Issue a new access token
        const newAccessToken = generateAccessToken(decoded.sub);
        res.json({ accessToken: newAccessToken });
      });
    });
    

    5. Cierre de sesión y revocación del acceso

    Cerrar la sesión de un usuario es sencillo: elimine el token de actualización de su base de datos y borre la cookie que lo almacena. El token de acceso no requiere ningún tratamiento especial; simplemente expirará por sí solo en unos minutos.

    app.post('/api/auth/logout', (req, res) => {
      const refreshToken = req.cookies.refreshToken;
    
      if (refreshToken) {
        refreshTokenDatabase.delete(refreshToken);
      }
    
      res.clearCookie('refreshToken');
      res.json({ message: 'Logged out successfully' });
    });
    

    ¿Dónde deben almacenarse los tokens en el cliente?

    Una pregunta que surge constantemente es dónde debe guardar la interfaz frontal estos tokens.

    Un error frecuente es almacenar ambos tokens dentro de localStorage.

    El problema es que cualquier script que se ejecute en su página, ya sea proveniente de una dependencia npm comprometida o de una etiqueta incrustada, puede leer localStorage con tan solo usar localStorage.getItem('token').

    Un patrón más seguro sería este:

    • Mantenga el token de acceso únicamente en memoria. Guárdelo en el estado en tiempo de ejecución de su aplicación: React Context, un almacén Pinia o incluso una variable simple. Dado que expira rápidamente y desaparece en el momento en que se cierra la pestaña, incluso una inyección de script exitosa solo expone un breve período de riesgo.
    • Mantenga el token de actualización en una cookie HttpOnly. JavaScript no tiene acceso alguno a las cookies HttpOnly. El navegador la adjunta automáticamente cada vez que el cliente llama a /api/auth/refresh, lo que significa que sus credenciales de larga duración nunca entran en contacto con código que un atacante podría controlar.

    Fortaleciendo la seguridad con rotación de tokens de actualización

    Incluso con la configuración anterior, un esquema de token de actualización simple presenta una debilidad persistente: si un atacante logra obtener el token de actualización, ya sea del almacenamiento de cookies o al interceptar el tráfico, podrá seguir generando nuevos tokens de acceso mientras ese token de actualización siga siendo válido, posiblemente durante los 7 días completos.

    Los sistemas en producción cierran esta brecha mediante la rotación de tokens de actualización.

    Bajo este sistema de rotación:

    • Cada vez que un token de actualización se canjea por un nuevo token de acceso, el servidor elimina el antiguo token de actualización y entrega uno completamente nuevo.
    • Tanto los tokens de acceso como los de actualización se reemplazan juntos, solicitud tras solicitud.
    Request 1: Send Refresh Token A -> Receive Access Token 2 + Refresh Token B (Token A invalidated)
    Request 2: Send Refresh Token B -> Receive Access Token 3 + Refresh Token C (Token B invalidated)
    

    Detección automática de brechas

    La rotación de tokens de actualización permite utilizar una poderosa medida de protección llamada detección de reutilización.

    Imagínese un escenario en el que un atacante logra copiar Refresh Token A.

    • El usuario legítimo canjea primero Refresh Token A, y el servidor responde con Refresh Token B.
    • Algún tiempo después, el atacante intenta canjear el ya vencido Refresh Token A.
    • El servidor verifica sus registros, ve que Refresh Token A ya fue utilizado y lo marca como señal de compromiso.
    • Responde revocando toda la cadena de tokens, incluido Refresh Token B, lo que obliga tanto al atacante como al usuario legítimo a volver a la pantalla de inicio de sesión.

    En lugar de que un atacante siga actuando en silencio en segundo plano, el sistema reacciona de inmediato y bloquea a todos hasta que se realice una nueva autenticación.

    Riesgos que vale la pena evitar

    Tenga cuidado con estos cuatro errores recurrentes:

    • Incluir información sensible en el token de acceso. La carga útil de un JWT solo está codificada en Base64, no cifrada, por lo que cualquiera que la intercepte puede leer su contenido directamente. Mantenga las contraseñas, los números de tarjeta y otros detalles privados completamente fuera de la carga útil.
    • Permitir que los registros del token de actualización se acumulen indefinidamente. Sin una columna de vencimiento ni una rutina de limpieza, su tabla de tokens de actualización crecerá sin límites. Añada un campo expires_at y programe una tarea que elimine periódicamente las entradas vencidas.
  • Ignorar las condiciones de carrera en el cliente. Cuando varias llamadas a la API se realizan al mismo tiempo y el token de acceso ya ha expirado, todas recibirán el código 403 simultáneamente. Si su frontend responde enviando varias llamadas paralelas /refresh, la lógica de rotación lo interpretará como reutilización y finalizará la sesión. Para evitar esto, coloque las solicitudes salientes en cola detrás de una única llamada de actualización en curso.
  • Situaciones en las que un único token podría ser suficiente

    Este enfoque de doble token es la opción estándar para clientes web y móviles modernos, pero no es obligatorio en todos los casos:

    • Traáfico interno de servicio a servicio: las máquinas que se comunican entre sí suelen utilizar flujos de credenciales del cliente, tokens de corta duración o TLS mutuo, ninguno de los cuales requiere un ciclo de actualización interactivo.
    • Aplicaciones básicas renderizadas en el servidor: para una aplicación tradicional renderizada del lado del servidor —por ejemplo, una construida con plantillas clásicas de Express—, un sencillo cookie de sesión respaldado por un almacenamiento en memoria como Redis suele ser más fácil de gestionar y ofrece una seguridad comparable.

    Utilice el patrón de token de acceso y actualización específicamente cuando esté desarrollando aplicaciones de página única (React, Vue o clientes móviles) que se comunican con backends REST o GraphQL sin estado.

    Conclusión

    Depender de un único JWT para todo obliga a tomar una decisión que no debería ser necesaria: o mantener las sesiones cortas y molestar a los usuarios con inicios de sesión frecuentes, o mantenerlas largas y exponerse a graves riesgos de seguridad.

    Dividir la autenticación en dos tokens resuelve esa tensión:

    • Tokens de acceso mantienen cada solicitud rápida y sin estado, evitando que su base de datos realice búsquedas constantes.
    • Tokens de actualización gestionan de forma segura la persistencia a largo plazo de las sesiones, permitiéndole revocar instantáneamente las sesiones comprometidas.

    Invertir esfuerzo en configurar correctamente ambos tipos de tokens le brinda una experiencia fluida para los usuarios y un sistema capaz de resistir bien los problemas de seguridad mencionados aquí.