↓Ir al contenido

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.

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) #

ClaimNombreTipoDescripción
subSubjectstring/numberEl “sujeto” del token — generalmente el ID del usuario
expExpirationtimestamp UnixCuándo expira el token — segundos desde 1970-01-01
iatIssued Attimestamp UnixCuándo fue creado el token
issIssuerstringQuién emitió el token (ej: "mi-api")
audAudiencestring/arrayPara quién está destinado el token
jtiJWT IDstringIdentificador único del token
nbfNot Beforetimestamp UnixEl 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 TokenRefresh Token
VidaCorta (15 min – 24 horas)Larga (7 – 30 días)
Qué haceAutoriza cada petición a la APIObtiene un nuevo access token
Dónde se envíaHeader Authorization en cada peticiónSolo al endpoint /auth/refresh
Si se filtraEl daño es limitado por su corta vidaMá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:

LugarVulnerable aProtección
localStorageXSS — JavaScript malicioso puede leerloSaneamiento de HTML, CSP
Cookie HttpOnlyCSRF — peticiones automáticas desde otro origenSameSite=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)
ClaveUna sola clave secreta compartidaPar de claves: privada (firma) + pública (verifica)
Quién puede verificarSolo quien tenga la clave secretaCualquiera con la clave pública
Uso típicoAplicación con un solo backendSistemas 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 #

ConceptoClave
Tres partesheader.payload.signature — cada una en Base64url
No es cifradoEl payload es legible por cualquiera — no pongas datos secretos
La firma garantiza integridadSin la SECRET, nadie puede generar una firma válida
subID del usuario — el claim más importante del payload
expTimestamp Unix de expiración — la librería lo verifica automáticamente
iatCuándo fue creado — útil para invalidar tokens emitidos antes de X fecha
Access tokenVida corta (15 min–24h) — viaja en cada petición
Refresh tokenVida 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 bytesGenerar con crypto.randomBytes(32) — nunca hardcodeada
StatelessEl servidor no guarda el token — no hay tabla de sesiones