Saltar al contenido principal

JSON Web Tokens (JWT)

Un JSON Web Token (JWT) es un estándar abierto (RFC 7519) para transmitir información de forma compacta y autocontenida entre partes como un objeto JSON firmado digitalmente. En el contexto de la autenticación, un JWT actúa como una credencial portátil: el servidor la emite al hacer login y el cliente la presenta en cada petición posterior.

La clave del modelo es que el servidor no necesita almacenar nada. Toda la información necesaria para verificar la identidad del usuario está dentro del propio token. Esto lo convierte en un mecanismo stateless.

Librerías para el uso de JWT en Node.js:

  • jsonwebtoken: Generación y verificación de JWTs. El estándar de facto en Node.js.
  • jose: Librería moderna (ES Modules). Soporta RS256, ES256, JWKS y JWE (tokens cifrados). Ideal para OpenID Connect (OIDC).

Estructura del token

Un JWT tiene tres partes separadas por puntos (.), cada una codificada en Base64URL:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9        ← Header
.eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9 ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature

Indica el algoritmo de firma y el tipo de token:

{
"alg": "HS256",
"typ": "JWT"
}

Algoritmos más usados:

AlgoritmoTipoClaveCuándo usarlo
HS256HMAC-SHA256SimétricaUn sólo servicio que firma y verifica. La clave secreta debe mantenerse privada.
RS256RSA-SHA256AsimétricaArquitecturas donde múltiples servicios verifican tokens pero sólo uno los firma. Se distribuye la clave pública.
ES256ECDSA-SHA256AsimétricaComo RS256 pero con claves más cortas y mejor rendimiento. Recomendado en microservicios.

Payload

Contiene los claims, que son afirmaciones sobre el usuario u otros datos:

{
"sub": "user_123", // Subject: identificador del usuario
"role": "admin", // Claim personalizado
"iat": 1700000000, // Issued At: cuándo se emitió (Unix timestamp)
"exp": 1700000900, // Expiration: cuándo expira
"iss": "api.miapp.com" // Issuer: quién lo emitió
}
Cifrado del payload

El payload NO está cifrado, sólo codificado en Base64URL. Cualquiera puede leerlo decodificándolo.

Nunca incluyas contraseñas, datos bancarios ni información sensible en el payload.

Signature

El servidor calcula la firma combinando el header y el payload con su clave secreta:

HMAC-SHA256(
base64url(header) + "." + base64url(payload),
SECRET_KEY
)

Si alguien modifica el payload (por ejemplo, cambia "role": "user" por "role": "admin"), la firma ya no coincidirá y el servidor rechazará el token. La firma garantiza integridad, no confidencialidad.

Flujo de autenticación con JWT

  1. Cliente envía credenciales:
    • Por ejemplo, POST /auth/login { username, password }
  2. Servidor verifica credenciales contra la base de datos:
    • Si son correctas → Servidor genera un JWT firmado con su clave secreta
      • Ejemplo de payload de JWT: { sub: userId, role, iat, exp }
    • Si son incorrectas → 401 Unauthorized
  3. Servidor responde con el token → { accessToken: "eyJ..." }
  4. Cliente almacena el token y lo incluye en cada petición (cabecera HTTP) → Authorization: Bearer eyJ...
  5. Servidor recibe la petición (sin consultar ninguna base de datos):
    • Extrae el token de la cabecera.
    • Verifica la firma con su clave secreta.
    • Comprueba que no ha expirado (campo exp).
    • Extrae userId y role del payload.
  6. Comprueba el validez del token:
    • Si es válido → procesa la petición
    • Si es inválido (o expirado) → 401/403

¿Cuándo usar JWT y cuándo usar sesiones?

Esta es una de las decisiones de arquitectura más importantes. No hay una respuesta universal, depende del contexto.

JWT es mejor opción en las siguientes situaciones:

  • Arquitecturas de microservicios o múltiples servicios: Con sesiones, cada microservicio necesitaría acceder al mismo store de sesiones (Redis compartido) para validar al usuario, añadiendo latencia y acoplamiento. Con JWT, cada servicio puede verificar el token de forma independiente con solo conocer la clave pública (RS256/ES256). Cero dependencias entre servicios en el camino de autenticación.
  • APIs consumidas por clientes no-web (móviles, CLIs, otros backends): Las cookies son un mecanismo del navegador. Un cliente móvil o una CLI no gestiona cookies de forma natural. JWT es un simple string que puede viajar en cualquier cabecera HTTP o incluso en el cuerpo de una petición.
  • Escalabilidad horizontal sin estado compartido: Si tu backend tiene múltiples instancias detrás de un load balancer, con sesiones necesitas sticky sessions (siempre al mismo servidor) o un store compartido. Con JWT, cualquier instancia puede validar cualquier token sin coordinación.
  • Integración con proveedores de identidad externos (OAuth2/OIDC): Los identity providers (Auth0, Cognito, Google) emiten JWTs. Si tu arquitectura ya los usa, tiene sentido trabajar nativamente con ese formato.

El uso de sesiones es mejor opción cuando:

  • Necesitas logout inmediato y garantizado: con JWT no puedes invalidar un token emitido antes de que expire (a menos que implementes una blocklist, lo que vuelve el modelo stateful).
  • Tu aplicación es una web tradicional con servidor renderizando HTML (sin separación frontend/backend).
  • Manejas datos de sesión extensos (carrito de compra, wizard de varios pasos) que sería ineficiente transportar en cada petición.
  • Tienes requisitos estrictos de revocación (banca, seguridad crítica).

Tabla comparativa

CriterioJWTSesiones
Estado en servidor❌ No (stateless)✅ Sí (stateful)
Escalabilidad horizontal✅ Nativa⚠️ Requiere store compartido
Logout inmediato❌ No (hasta que expira)✅ Sí
Revocación de tokens❌ Difícil sin blocklist✅ Trivial
APIs para móviles/terceros✅ Ideal⚠️ Incómodo (cookies)
Microservicios✅ Ideal⚠️ Acoplamiento al store
Datos extensos de sesión⚠️ Token crece✅ Solo ID en cookie
Seguridad ante filtración del secret❌ Todos los tokens comprometidos✅ Solo afecta a la cookie firmada

Implementación básica con jsonwebtoken

import jwt from 'jsonwebtoken';

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;

// Generar access token (vida corta)
export const generateAccessToken = (user) => {
return jwt.sign(
{
sub: user.id,
role: user.role,
},
ACCESS_SECRET,
{
expiresIn: '15m',
issuer: 'api.miapp.com',
}
);
};

// Middleware de verificación
export const verifyToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader?.split(' ')[1]; // "Bearer <token>"

if (!token) return res.status(401).json({ error: 'Token requerido' });

try {
const decoded = jwt.verify(token, ACCESS_SECRET, {
issuer: 'api.miapp.com', // Verifica que el issuer coincide
});
req.user = decoded; // { sub, role, iat, exp, iss }
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expirado', code: 'TOKEN_EXPIRED' });
}
return res.status(403).json({ error: 'Token inválido' });
}
};

// Endpoint de login
app.post('/auth/login', async (req, res) => {
const { username, password } = req.body;
const user = await findUser(username);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Credenciales incorrectas' });
}
const accessToken = generateAccessToken(user);
res.json({ accessToken });
});

// Ruta protegida
app.get('/profile', verifyToken, async (req, res) => {
const user = await findUserById(req.user.sub);
res.json(user);
});

¿Dónde guardar el JWT en el cliente?

LugarVentajaRiesgo
localStorage / sessionStorageSimple de implementar❌ Accesible desde JS → vulnerable a XSS
Cookie httpOnlyJS no puede leerla → mitiga XSS⚠️ Vulnerable a CSRF (mitigable con SameSite)
Cookie httpOnly + SameSite=Strict✅ Mejor opción para apps webMenos flexible entre subdominios
Memoria (variable JS)No persiste entre tabs/recargasSe pierde al cerrar la pestaña. Requiere refresh token en cookie

La combinación más segura es: access token en memoria + refresh token en cookie httpOnly (ver sección siguiente).

Refresh Tokens

El problema de la expiración

Los access tokens deben tener una vida corta (5-15 minutos) para limitar el daño si son interceptados. Pero no podemos pedir al usuario que haga login cada 15 minutos. Los refresh tokens resuelven este dilema.

Un refresh token es una credencial de larga duración (días o semanas) que el cliente usa exclusivamente para obtener nuevos access tokens sin que el usuario intervenga. A diferencia del access token, se almacena en el servidor (en la base de datos), lo que permite invalidarlo explícitamente en el logout.

Flujo completo

Implementación

import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; // Secreto diferente al del access token

// Login: emitir ambos tokens
app.post('/auth/login', async (req, res) => {
const user = await authenticateUser(req.body.username, req.body.password);
if (!user) return res.status(401).json({ error: 'Credenciales incorrectas' });

const accessToken = jwt.sign({ sub: user.id, role: user.role }, ACCESS_SECRET, { expiresIn: '15m' });

// Refresh token: string aleatorio opaco (no JWT) almacenado en BD
const refreshToken = crypto.randomBytes(64).toString('hex');
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 días

await db.saveRefreshToken({ userId: user.id, tokenHash, expiresAt });

// Enviar refresh token en cookie httpOnly (nunca en el body)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/auth/refresh', // Solo se envía en este endpoint
});

res.json({ accessToken });
});

// Refrescar access token
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
if (!refreshToken) return res.status(401).json({ error: 'Refresh token requerido' });

const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const stored = await db.findRefreshToken(tokenHash);

if (!stored || stored.expiresAt < new Date()) {
return res.status(403).json({ error: 'Refresh token inválido o expirado' });
}

// Rotación: el token usado se elimina y se crea uno nuevo
await db.deleteRefreshToken(stored.id);

const newRefreshToken = crypto.randomBytes(64).toString('hex');
const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
await db.saveRefreshToken({
userId: stored.userId,
tokenHash: newHash,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
familyId: stored.familyId // Para detección de reuso (ver abajo)
});

res.cookie('refreshToken', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'Strict',
maxAge: 30 * 24 * 60 * 60 * 1000, path: '/auth/refresh',
});

const accessToken = jwt.sign(
{ sub: stored.userId },
ACCESS_SECRET,
{ expiresIn: '15m' }
);

res.json({ accessToken });
});

// Logout: invalidar el refresh token
app.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
await db.deleteRefreshToken(tokenHash);
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
res.json({ message: 'Sesión cerrada' });
});

Token rotation y detección de reuso

Con la rotación de refresh tokens, cada vez que se usa un refresh token se emite uno nuevo y el anterior se invalida. Esto permite detectar robos:

  1. Atacante roba el refresh token del usuario.
  2. El usuario intenta refrescarlo → obtiene un nuevo token (rotación).
  3. El atacante también intenta usarlo → el servidor detecta que ese token ya fue usado → invalida toda la familia de tokens del usuario.
  4. El usuario tiene que volver a hacer login.
// Si se detecta reuso de un token ya rotado:
async function handleTokenReuse(familyId) {
// Invalidar todos los refresh tokens de esta familia
await db.deleteRefreshTokensByFamily(familyId);
// Opcional: notificar al usuario por email
}