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

Header
Indica el algoritmo de firma y el tipo de token:
{
"alg": "HS256",
"typ": "JWT"
}
Algoritmos más usados:
| Algoritmo | Tipo | Clave | Cuándo usarlo |
|---|---|---|---|
HS256 | HMAC-SHA256 | Simétrica | Un sólo servicio que firma y verifica. La clave secreta debe mantenerse privada. |
RS256 | RSA-SHA256 | Asimétrica | Arquitecturas donde múltiples servicios verifican tokens pero sólo uno los firma. Se distribuye la clave pública. |
ES256 | ECDSA-SHA256 | Asimétrica | Como 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ó
}
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
- Cliente envía credenciales:
- Por ejemplo,
POST /auth/login { username, password }
- Por ejemplo,
- 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 }
- Ejemplo de payload de JWT:
- Si son incorrectas →
401 Unauthorized
- Si son correctas → Servidor genera un JWT firmado con su clave secreta
- Servidor responde con el token →
{ accessToken: "eyJ..." } - Cliente almacena el token y lo incluye en cada petición (cabecera HTTP) →
Authorization: Bearer eyJ... - 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
userIdyroledel payload.
- 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
| Criterio | JWT | Sesiones |
|---|---|---|
| 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?
| Lugar | Ventaja | Riesgo |
|---|---|---|
localStorage / sessionStorage | Simple de implementar | ❌ Accesible desde JS → vulnerable a XSS |
Cookie httpOnly | JS no puede leerla → mitiga XSS | ⚠️ Vulnerable a CSRF (mitigable con SameSite) |
Cookie httpOnly + SameSite=Strict | ✅ Mejor opción para apps web | Menos flexible entre subdominios |
| Memoria (variable JS) | No persiste entre tabs/recargas | Se 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:
- Atacante roba el refresh token del usuario.
- El usuario intenta refrescarlo → obtiene un nuevo token (rotación).
- El atacante también intenta usarlo → el servidor detecta que ese token ya fue usado → invalida toda la familia de tokens del usuario.
- 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
}