Estructura JWT — JSON Web Tokens
Esta semana estás construyendo la estructura modular de NestJS con inyección de dependencias. El siguiente paso natural en cualquier aplicación con usuarios es la autenticación — y el mecanismo más usado en APIs REST modernas es JWT. Este material cubre cómo está construido un token, qué contiene y por qué funciona como lo hace.
1. Qué es un JWT #
Un JWT (JSON Web Token) es una string compacta que representa información verificable. Se usa principalmente para autenticación: después de hacer login, el servidor emite un JWT que el cliente adjunta a cada petición siguiente para demostrar que está autenticado.
Un JWT real se ve así:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImlhdCI6MTczMzAwMDAwMCwiZXhwIjoxNzMzMDg2NDAwfQ.4Zk3W2mHqPvXnYtLwRsKjE8CqD1fBuA9oMdGvIpQxNc
No parece legible, pero no está cifrado — está codificado en Base64url, un formato que convierte datos binarios en caracteres seguros para URLs. Cualquier persona puede decodificarlo.
2. Las Tres Partes #
Un JWT siempre tiene exactamente tres segmentos separados por puntos:
HEADER.PAYLOAD.SIGNATURE
↑ ↑ ↑
parte 1 parte 2 parte 3
Cada parte es un JSON codificado en Base64url. Puedes decodificar las dos primeras en cualquier momento — la tercera (la firma) es la que le da valor al token.
Header #
El header describe el tipo de token y el algoritmo de firma:
{
"alg": "HS256",
"typ": "JWT"
}
Codificado en Base64url:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
"alg": "HS256" significa HMAC-SHA256 — el algoritmo más común para firmar JWTs en aplicaciones monolíticas donde el mismo servidor emite y verifica tokens.
Payload #
El payload contiene los claims — afirmaciones sobre el usuario y el token mismo:
{
"sub": 1,
"email": "user@example.com",
"iat": 1733000000,
"exp": 1733086400
}
Codificado en Base64url:
eyJzdWIiOjEsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImlhdCI6MTczMzAwMDAwMCwiZXhwIjoxNzMzMDg2NDAwfQ
Signature #
La firma se calcula combinando el header codificado, el payload codificado y una clave secreta:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
SECRET_KEY
)
El resultado codificado:
4Zk3W2mHqPvXnYtLwRsKjE8CqD1fBuA9oMdGvIpQxNc
El servidor usa SECRET_KEY — una string larga y aleatoria que nunca sale del servidor. Sin ella, nadie puede generar una firma válida.
3. Claims — Lo que va en el Payload #
Los claims son pares clave-valor en el payload. Hay dos tipos: estándar y personalizados.
Claims estándar (registrados en la especificación RFC 7519) #
| Claim | Nombre | Tipo | Descripción |
|---|---|---|---|
sub | Subject | string/number | El “sujeto” del token — generalmente el ID del usuario |
exp | Expiration | timestamp Unix | Cuándo expira el token — segundos desde 1970-01-01 |
iat | Issued At | timestamp Unix | Cuándo fue creado el token |
iss | Issuer | string | Quién emitió el token (ej: "mi-api") |
aud | Audience | string/array | Para quién está destinado el token |
jti | JWT ID | string | Identificador único del token |
nbf | Not Before | timestamp Unix | El token no es válido antes de esta fecha |
Los más usados en la práctica son sub, exp e iat.
Claims personalizados #
Puedes agregar cualquier dato adicional que necesites:
{
"sub": 42,
"email": "user@example.com",
"rol": "ADMIN",
"iat": 1733000000,
"exp": 1733086400
}
Qué NO poner en el payload #
El payload no está cifrado — cualquiera con el token puede leerlo. Nunca incluyas:
{
"sub": 42,
"contrasena": "miContraseña123", // ❌ jamás
"tarjeta": "4111111111111111", // ❌ datos sensibles
"cvv": "123" // ❌ nunca
}
El payload es para datos que el cliente necesita leer (como el rol para decidir qué mostrar en la UI) y que el servidor necesita verificar en cada petición. Los datos secretos van en la base de datos, no en el token.
4. Por Qué Funciona — El Modelo de Seguridad #
La seguridad de un JWT no viene de que el contenido sea ilegible — cualquiera puede decodificarlo. Viene de que nadie puede falsificarlo sin la clave secreta.
Si un atacante modifica el payload (por ejemplo, cambiando "sub": 42 por "sub": 1 para hacerse pasar por otro usuario), la firma deja de coincidir:
Firma original: HMAC-SHA256(header + "." + payload_original, SECRET)
Firma del payload modificado: HMAC-SHA256(header + "." + payload_modificado, SECRET)
Son diferentes. El servidor recalcula la firma con el payload que recibió y la compara con la firma adjunta al token — si no coinciden, el token es inválido.
Cliente envía: header.payload_modificado.firma_original
Servidor calcula: HMAC-SHA256(header + "." + payload_modificado, SECRET) ≠ firma_original
Resultado: 401 Unauthorized
Para forjar un token válido, el atacante necesita SECRET_KEY. Si esa clave es suficientemente larga y aleatoria, y nunca se expone, el sistema es seguro.
5. El Flujo Completo de Autenticación con JWT #
1. LOGIN
Cliente ──POST /auth/login { email, password }──▶ Servidor
Servidor verifica credenciales contra DB
Servidor genera JWT firmado con SECRET
Servidor ──{ accessToken: "eyJ..." }──▶ Cliente
Cliente guarda el token
2. PETICIÓN AUTENTICADA
Cliente ──GET /api/cursos
Authorization: Bearer eyJ...──▶ Servidor
Servidor extrae el token del header
Servidor verifica la firma con SECRET
Servidor verifica que no ha expirado (exp)
Servidor extrae el sub del payload → ID del usuario
Servidor ──[ { id: 1, titulo: "..." }, ... ]──▶ Cliente
3. TOKEN EXPIRADO
Servidor ──401 Unauthorized──▶ Cliente
Cliente redirige al login o usa refresh token
El servidor no guarda el token — no hay tabla de sesiones activas. La validez del token la determina la firma y la fecha de expiración. Esto hace que el sistema sea stateless (sin estado).
6. exp — Expiración del Token #
exp es el timestamp Unix (segundos desde el 1 de enero de 1970) en que el token deja de ser válido. Una biblioteca JWT lo verifica automáticamente — si el token expiró, lanza un error.
// Timestamp actual: 1733000000 (lunes 10 nov 2024, ~UTC)
// Token con 24 horas de vida:
{
"exp": 1733000000 + (60 * 60 * 24) // = 1733086400
}
// Para generar el timestamp actual en JavaScript:
Math.floor(Date.now() / 1000) // Date.now() es milisegundos → dividir por 1000
Access Token vs Refresh Token #
Una práctica común es usar dos tipos de token con vidas diferentes:
| Access Token | Refresh Token | |
|---|---|---|
| Vida | Corta (15 min – 24 horas) | Larga (7 – 30 días) |
| Qué hace | Autoriza cada petición a la API | Obtiene un nuevo access token |
| Dónde se envía | Header Authorization en cada petición | Solo al endpoint /auth/refresh |
| Si se filtra | El daño es limitado por su corta vida | Más grave — permite obtener nuevos access tokens |
El flujo con ambos:
Login → { accessToken (15 min), refreshToken (30 días) }
Petición normal: Authorization: Bearer {accessToken}
Access token expirado:
POST /auth/refresh { refreshToken }
→ { accessToken nuevo (15 min) }
(sin necesidad de hacer login de nuevo)
7. Implementación en TypeScript #
Instalación #
npm install jsonwebtoken
npm install -D @types/jsonwebtoken
Firmar un token (login) #
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET ?? ""; // mínimo 32 caracteres aleatorios
function generarToken(userId: number, email: string): string {
return jwt.sign(
{
sub: userId,
email: email,
},
SECRET,
{
expiresIn: "24h", // también acepta segundos: 86400
}
);
}
// jwt.sign() calcula exp e iat automáticamente cuando usas expiresIn
Verificar un token (petición protegida) #
type PayloadToken = {
sub: number;
email: string;
iat: number;
exp: number;
};
function verificarToken(token: string): PayloadToken {
// Lanza error si la firma es inválida o el token expiró
return jwt.verify(token, SECRET) as PayloadToken;
}
// En el middleware de autenticación:
function autenticar(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
// El header llega como "Bearer eyJ..."
const token = authHeader?.split(" ")[1];
if (!token) {
res.status(401).json({ error: "Token requerido" });
return;
}
try {
const payload = verificarToken(token);
req.usuarioId = payload.sub; // disponible en los Controllers
next();
} catch {
res.status(401).json({ error: "Token inválido o expirado" });
}
}
Decodificar sin verificar (solo para leer el payload) #
// jwt.decode() no verifica la firma — nunca uses esto para autenticar
const payload = jwt.decode(token);
// Útil solo para leer el exp o el sub en el cliente (frontend)
// sin necesitar la SECRET
En NestJS — con @nestjs/jwt #
En NestJS el módulo oficial envuelve jsonwebtoken con inyección de dependencias:
// En el módulo de autenticación
import { JwtModule } from "@nestjs/jwt";
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: "24h" },
})
// En el servicio
import { JwtService } from "@nestjs/jwt";
constructor(private readonly jwtService: JwtService) {}
const token = this.jwtService.sign({ sub: usuario.id, email: usuario.email });
const payload = this.jwtService.verify(token);
Preguntas Frecuentes #
¿Un JWT es lo mismo que una sesión? #
No. Una sesión guarda el estado en el servidor — hay una tabla o un store con los IDs de sesión activos. Un JWT es stateless: el servidor no guarda nada. La validez está codificada dentro del propio token (firma + exp). La ventaja es que escala sin necesitar estado compartido entre servidores; la desventaja es que no puedes invalidar un token antes de que expire (salvo implementar una lista negra, lo que reintroduce estado).
¿Dónde guarda el cliente el JWT? #
Hay dos opciones principales con sus tradeoffs:
| Lugar | Vulnerable a | Protección |
|---|---|---|
localStorage | XSS — JavaScript malicioso puede leerlo | Saneamiento de HTML, CSP |
Cookie HttpOnly | CSRF — peticiones automáticas desde otro origen | SameSite=Strict, CSRF token |
Las cookies HttpOnly con SameSite=Strict son generalmente más seguras porque JavaScript no puede leerlas. Para aplicaciones SPA modernas, ambas opciones son válidas con las mitigaciones correctas.
¿Puedo invalidar un JWT antes de que expire? #
No directamente — eso rompería el modelo stateless. Las soluciones habituales son:
- Vida corta: access tokens de 15 minutos limitan el daño si uno se filtra.
- Lista negra (blocklist): guardar en Redis los JTI de tokens revocados — vuelve el sistema parcialmente stateful.
- Rotar el secret: invalida todos los tokens de golpe — solo para emergencias.
¿Qué tan larga debe ser la SECRET_KEY? #
Mínimo 256 bits (32 bytes) de datos aleatorios. Genera una en la terminal:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# → "a3f8c1e2b4d7f9a0e1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4"
Guárdala en .env como JWT_SECRET y nunca la subas a un repositorio.
¿Cuál es la diferencia entre HS256 y RS256? #
| HS256 (HMAC-SHA256) | RS256 (RSA-SHA256) | |
|---|---|---|
| Clave | Una sola clave secreta compartida | Par de claves: privada (firma) + pública (verifica) |
| Quién puede verificar | Solo quien tenga la clave secreta | Cualquiera con la clave pública |
| Uso típico | Aplicación con un solo backend | Sistemas distribuidos, microservicios |
Para una API con un solo backend, HS256 es suficiente y más simple. RS256 tiene sentido cuando múltiples servicios necesitan verificar tokens sin tener la clave de firma.
Resumen #
| Concepto | Clave |
|---|---|
| Tres partes | header.payload.signature — cada una en Base64url |
| No es cifrado | El payload es legible por cualquiera — no pongas datos secretos |
| La firma garantiza integridad | Sin la SECRET, nadie puede generar una firma válida |
sub | ID del usuario — el claim más importante del payload |
exp | Timestamp Unix de expiración — la librería lo verifica automáticamente |
iat | Cuándo fue creado — útil para invalidar tokens emitidos antes de X fecha |
| Access token | Vida corta (15 min–24h) — viaja en cada petición |
| Refresh token | Vida larga (7–30 días) — solo para obtener nuevos access tokens |
jwt.sign(payload, secret, options) | Crea el token — expiresIn calcula exp automáticamente |
jwt.verify(token, secret) | Verifica firma Y expiración — lanza error si algo falla |
jwt.decode(token) | Decodifica sin verificar — nunca para autenticar |
| SECRET mínimo 32 bytes | Generar con crypto.randomBytes(32) — nunca hardcodeada |
| Stateless | El servidor no guarda el token — no hay tabla de sesiones |