Parte III · NestJS

12. Seguridad: autenticación, JWT, OAuth2 y autorización

La seguridad no es un módulo que se instala al final: es una propiedad transversal que emerge de mil decisiones pequeñas tomadas correctamente. Este capítulo recorre esas decisiones una a una —cómo se guarda una contraseña, qué va dentro de un token, dónde lo almacena el navegador, quién puede leer qué fila de qué tabla— y las convierte en código concreto de NestJS, Angular y MikroORM. Todo lo que aparece aquí es explotable si se hace mal, así que el criterio de corrección es más estricto que en cualquier otro capítulo del libro.

CORE NEST Tiempo de lectura: ~120 min Prerrequisitos: capítulos 1, 6, 9 y 10

12.1 Qué vas a poder hacer al terminar

Advertencia de vigencia Las recomendaciones criptográficas caducan. Los parámetros concretos que aparecen en este capítulo (memoria de Argon2, coste de bcrypt, iteraciones de PBKDF2) reflejan las guías vigentes de OWASP y NIST en el momento de escribirlo. Antes de fijarlos en producción, contrasta con la OWASP Password Storage Cheat Sheet actual: el método de razonamiento no cambia, los números sí.

12.2 Conceptos base: el vocabulario exacto

12.2.1 Identificación, autenticación y autorización

Estos tres términos se usan como sinónimos en las conversaciones de pasillo y eso produce diseños confusos. Son tres pasos distintos, en este orden:

PasoPregunta que respondeEjemplo técnicoFallo típico
Identificación ¿Quién dices que eres? El campo email del formulario; el sub de un token Tratar la identificación como prueba: confiar en un userId enviado por el cliente
Autenticación ¿Puedes demostrarlo? Contraseña, firma del JWT, certificado, segundo factor Verificar la firma pero no la expiración, el emisor o el algoritmo
Autorización Siendo quien eres, ¿puedes hacer esto sobre esto? Rol admin, ser el propietario de la factura 42 Comprobar el rol pero no la propiedad del recurso concreto (IDOR)
Analogía: el hotel

Llegas a recepción y dices que eres Ana Ruiz: eso es identificación, y por sí sola no vale nada. Enseñas el DNI y el recepcionista comprueba que la foto coincide: eso es autenticación. Te entrega una tarjeta que abre la habitación 402, el gimnasio y el parking, pero no la 403 ni el cuarto de servidores: eso es autorización.

La analogía se extiende con precisión: la tarjeta es un token. Caduca el día del check-out (exp), la emitió ese hotel y no otro (iss), y si la pierdes el hotel puede invalidarla en la centralita porque las cerraduras consultan un estado central. Un JWT clásico es una tarjeta cuyas cerraduras no consultan nada: solo miran si la firma del hotel es válida y si la fecha no ha pasado. Ahí nace todo el problema de la revocación que veremos en 12.5.

12.2.2 401 frente a 403 (y cuándo mentir con un 404)

El nombre de la especificación confunde: 401 Unauthorized significa en realidad no autenticado. Es el servidor diciendo «no sé quién eres, o tus credenciales no valen; vuelve a intentarlo con credenciales válidas». Según la RFC 9110 debe acompañarse de una cabecera WWW-Authenticate.

403 Forbidden significa autenticado pero sin permiso: el servidor sabe perfectamente quién eres y la respuesta no cambiará por reintentar con las mismas credenciales.

Nest lo hace por ti si usas las excepciones correctas UnauthorizedException produce 401 y ForbiddenException produce 403. Si tu guard lanza ForbiddenException cuando en realidad falta el token, estás dando al cliente una pista falsa y rompes el reintento automático del interceptor de Angular, que normalmente reacciona al 401 intentando refrescar.

12.2.3 Los cuatro principios que gobiernan el resto del capítulo

Mínimo privilegio

Cada actor recibe exactamente los permisos que necesita para su tarea, ni uno más, y durante el menor tiempo posible. Se aplica al usuario (un rol soporte no necesita borrar), al servicio (el usuario de base de datos de la API no necesita DROP TABLE), al token (un access token vive minutos) y al contenedor (no corre como root).

Defensa en profundidad

Ningún control es infalible, así que se apilan controles independientes. Si el filtro de entrada falla, la consulta parametrizada aguanta; si esta falla, el usuario de base de datos limitado reduce el daño; si todo falla, el registro de auditoría permite detectarlo. Un único control perfecto no existe.

Nunca confíes en el cliente

Todo lo que llega por HTTP es entrada del atacante: cuerpo, cabeceras, cookies, parámetros de ruta, orden de los campos, tipos de los valores. El navegador, la app móvil y tu propio SPA de Angular son entornos hostiles: su código es público y modificable. La validación del frontend es usabilidad; la del backend es seguridad.

Falla de forma segura y explícita

Ante la duda, denegar. Un guard que devuelve true cuando no encuentra metadatos, un try/catch que se traga un error de verificación o una comprobación que solo se ejecuta «si hay usuario» son puertas abiertas. El estado por defecto de un sistema seguro es «denegado».

12.2.4 Superficie de ataque y modelo de amenazas de una API

La superficie de ataque es el conjunto de puntos por los que un dato no confiable entra en tu sistema. En una API NestJS típica es más grande de lo que parece:

  ENTRADAS NO CONFIABLES DE UNA API NESTJS
  ────────────────────────────────────────────────────────────────────
  · Cuerpo JSON            · Parámetros de ruta (:id)
  · Query string           · Cabeceras (Authorization, X-Forwarded-For, Host)
  · Cookies                · Ficheros subidos (nombre, tipo MIME, contenido)
  · WebSockets             · Mensajes de cola (BullMQ, Kafka)
  · Webhooks de terceros   · Variables de entorno mal validadas
  · Respuestas de APIs externas que reenvías al cliente
  ────────────────────────────────────────────────────────────────────
  Regla: si el valor no lo ha generado tu proceso, es entrada del atacante.

Un modelo de amenazas básico se construye respondiendo a cuatro preguntas antes de escribir código. El método más conocido es STRIDE; en su versión mínima para una API:

Amenaza (STRIDE)Ejemplo en una API NestControl principal
Spoofing (suplantación)Reutilizar un token robado; falsificar el alg del JWTFirma verificada con algoritmo fijado, tokens de vida corta, cookies HttpOnly
Tampering (manipulación)Cambiar {"role":"user"} por "admin" en el payloadFirma criptográfica; nunca decidir permisos con datos no firmados
Repudiation (repudio)«Yo no borré ese cliente»Registro de auditoría inmutable con actor, acción, recurso y hora
Information disclosureDevolver la entidad completa con passwordHashDTOs de salida, serialización explícita, mensajes de error genéricos
Denial of serviceConsulta sin paginar sobre 10 millones de filas; JSON de 200 MBRate limiting, límites de tamaño, paginación obligatoria, timeouts
Elevation of privilegeIDOR: cambiar /facturas/41 por /facturas/42Autorización a nivel de recurso y filtrado en la capa de datos

12.2.5 Las capas de defensa de este stack

            ATACANTE
               │
  ═════════════▼═══════════════════════════════════════════════════════════
   CAPA 0 · BORDE          TLS 1.3 · WAF · rate limit de infraestructura
                           protege de: DDoS, escaneo masivo, TLS degradado
  ─────────────────────────────────────────────────────────────────────────
   CAPA 1 · TRANSPORTE     HSTS · cookies Secure · HTTP/2 · sin contenido mixto
                           protege de: sniffing, downgrade, robo de cookie
  ─────────────────────────────────────────────────────────────────────────
   CAPA 2 · CABECERAS      helmet · CSP · CORS estricto · SameSite
                           protege de: XSS reflejado, clickjacking, CSRF
  ─────────────────────────────────────────────────────────────────────────
   CAPA 3 · AUTENTICACIÓN  Argon2id · JWT con alg fijado · refresh rotativo · 2FA
                           protege de: credenciales robadas, replay, forja
  ─────────────────────────────────────────────────────────────────────────
   CAPA 4 · ENTRADA        ValidationPipe whitelist · límites de tamaño
                           protege de: mass assignment, inyección, DoS por payload
  ─────────────────────────────────────────────────────────────────────────
   CAPA 5 · AUTORIZACIÓN   RBAC + CASL + guard de propiedad
                           protege de: IDOR, escalada horizontal y vertical
  ─────────────────────────────────────────────────────────────────────────
   CAPA 6 · DATOS          filtros de MikroORM · consultas parametrizadas
                           usuario de BD con permisos mínimos · cifrado en reposo
                           protege de: fuga entre tenants, SQL injection
  ─────────────────────────────────────────────────────────────────────────
   CAPA 7 · OBSERVACIÓN    auditoría · alertas · detección de reutilización
                           no previene: DETECTA. Es la última red.
  ═════════════════════════════════════════════════════════════════════════

  Cada capa asume que la anterior ha fallado. Ese es el punto.

12.3 Contraseñas: el activo que nunca deberías poder leer

12.3.1 Por qué no se guardan en claro ni cifradas

Una base de datos se filtra. Ocurre por una copia de seguridad mal permisionada, una inyección SQL, un empleado descontento o un bucket S3 público. El diseño correcto parte de asumir que el atacante acabará teniendo la tabla de usuarios, y busca que aun así no consiga las contraseñas.

La pimienta (pepper): opcional y complementaria Además de la sal (pública, por usuario) puede añadirse una pimienta: un secreto global almacenado fuera de la base de datos (variable de entorno, HSM o gestor de secretos) que se mezcla con la contraseña antes de hashear, o mejor, un HMAC del hash. Si el atacante roba solo la base de datos y no el secreto, los hashes son inatacables. La contrapartida es la rotación: cambiar la pimienta obliga a recifrar en el siguiente inicio de sesión de cada usuario. Es un extra, nunca un sustituto del hash lento.

12.3.2 Argon2, bcrypt, scrypt y PBKDF2 comparados

CriterioArgon2idbcryptscryptPBKDF2
Año / origen 2015, ganador del Password Hashing Competition 1999, basado en Blowfish (Provos & Mazières) 2009, Colin Percival (Tarsnap) 2000, RSA Labs · RFC 2898 / 8018
Coste ajustable Tiempo, memoria y paralelismo, independientes Solo tiempo (factor exponencial) Tiempo y memoria (acopladas por N) Solo iteraciones
Uso de memoria Configurable, decenas de MiB Fijo, ~4 KiB Configurable, decenas–cientos de MiB Despreciable
Resistencia a GPU / ASIC Muy alta: la memoria es el cuello de botella y no se paraleliza barato Media: los 4 KiB estorban a las GPU, pero existen implementaciones FPGA eficientes Alta si N es grande Baja: es el caso ideal para una GPU
Límite de entrada Sin límite práctico 72 bytes (trunca en silencio) Sin límite Sin límite
Parámetros de partida m=19456 KiB, t=2, p=1 (o m=47104, t=1, p=1) coste 12 (212 iteraciones) N=2^17, r=8, p=1 600 000 iteraciones con HMAC-SHA-256
Disponibilidad en Node Paquete argon2 (addon nativo) Paquete bcrypt (nativo) o bcryptjs (JS puro, bloquea) Nativo en crypto.scrypt Nativo en crypto.pbkdf2
Recomendación Primera opción para proyectos nuevos Perfectamente aceptable; obligado si ya lo usas Buena alternativa sin dependencias nativas extra Solo si necesitas cumplimiento FIPS-140
El límite de 72 bytes de bcrypt no es anecdótico

bcrypt ignora todo lo que pase de 72 bytes sin avisar. Si un usuario usa una frase de contraseña larga o un gestor genera 100 caracteres, los últimos se descartan. Peor: si pre-hasheas con SHA-256 y pasas el resultado en hexadecimal, estás desperdiciando entropía; y si lo pasas como binario crudo, algunas implementaciones truncan en el primer byte nulo. La solución correcta si necesitas contraseñas largas con bcrypt es base64(sha256(password)): 44 caracteres ASCII, sin nulos y con los 256 bits de entropía intactos. Con Argon2 este problema sencillamente no existe.

12.3.3 Cómo se elige el factor de coste

El coste no se copia de un tutorial: se mide en tu hardware de producción. El procedimiento es siempre el mismo:

  1. Fija un presupuesto de latencia. Entre 250 ms y 500 ms por verificación es el rango habitual: imperceptible para la persona que inicia sesión, carísimo para quien prueba millones de combinaciones.
  2. Mide con los parámetros mínimos recomendados por OWASP y sube hasta agotar el presupuesto.
  3. Comprueba el impacto en concurrencia: si tienes 200 inicios de sesión por segundo y cada uno consume 46 MiB durante 300 ms, necesitas del orden de 2,8 GiB de RAM solo para eso. La memoria de Argon2 es una decisión de capacidad, no solo de seguridad.
  4. Revisa los parámetros una vez al año: el hardware del atacante mejora, el tuyo también.
scripts/calibrar-hash.ts · mide antes de decidir
import * as argon2 from 'argon2';
// Ejecútalo en una máquina equivalente a la de producción, no en tu portátil.
async function calibrar() {
  const candidatos = [
    { memoryCost: 19456, timeCost: 2, parallelism: 1 },   // mínimo OWASP
    { memoryCost: 47104, timeCost: 1, parallelism: 1 },   // más memoria, menos tiempo
    { memoryCost: 65536, timeCost: 3, parallelism: 1 },   // 64 MiB
  ];
  for (const opciones of candidatos) {
    const inicio = process.hrtime.bigint();
    await argon2.hash('contraseña-de-prueba-representativa', {
      type: argon2.argon2id,
      ...opciones,
    });
    const ms = Number(process.hrtime.bigint() - inicio) / 1e6;
    console.log(`m=${opciones.memoryCost} t=${opciones.timeCost} → ${ms.toFixed(0)} ms`);
  }
}
calibrar();
// Elige la configuración más cara que quepa en tu presupuesto de latencia
// Y comprueba: coste_total_RAM = memoryCost × logins_concurrentes

12.3.4 Implementación correcta en NestJS

Aquí conecta directamente con el capítulo 1: Node ejecuta JavaScript en un solo hilo. Un hash lento consume CPU de forma intensiva durante cientos de milisegundos. Si esa operación se hace de forma síncrona, el proceso entero queda congelado: ninguna otra petición avanza, los health checks fallan y un atacante consigue una denegación de servicio simplemente lanzando inicios de sesión en paralelo.

Los paquetes nativos argon2 y bcrypt delegan el cálculo al thread pool de libuv (4 hilos por defecto, ajustable con UV_THREADPOOL_SIZE), igual que crypto.scrypt. Sus versiones síncronas y la implementación en JavaScript puro bcryptjs no: bloquean el event loop.

hash.service.tsINCORRECTO
import { createHash } from 'node:crypto';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class HashService {
  // ERROR 1: SHA-256 es un hash RÁPIDO. Miles de millones por segundo en GPU.
  hashRapido(pwd: string): string {
    return createHash('sha256').update(pwd).digest('hex');
  }
  // ERROR 2: sin sal → dos usuarios con la misma contraseña
  //          tienen el mismo hash y una rainbow table los rompe a la vez.
  // ERROR 3: la versión Sync BLOQUEA el event loop ~300 ms.
  //          Con 10 logins simultáneos, 3 segundos sin atender nada.
  hashBloqueante(pwd: string): string {
    return bcrypt.hashSync(pwd, 10);
  }
  // ERROR 4: bcryptjs es JavaScript puro: aunque uses la variante async,
  //          el trabajo sigue corriendo en el hilo principal a trozos.
}
hash.service.tsCORRECTO
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
@Injectable()
export class HashService {
  // Parámetros centralizados: un solo sitio que revisar cada año.
  private readonly opciones: argon2.Options = {
    type: argon2.argon2id,   // híbrido: resistente a GPU y a side-channels
    memoryCost: 19456,       // 19 MiB
    timeCost: 2,
    parallelism: 1,
  };
  // argon2 es un addon nativo: el cálculo va al thread pool de libuv,
  // el event loop sigue atendiendo peticiones. La sal la genera y la
  // incrusta la propia librería en el string resultante.
  hash(pwd: string): Promise<string> {
    return argon2.hash(pwd, this.opciones);
  }
  // verify lee los parámetros y la sal DEL PROPIO HASH almacenado,
  // por eso los hashes antiguos siguen validando tras subir el coste.
  async verificar(hash: string, pwd: string): Promise<boolean> {
    try {
      return await argon2.verify(hash, pwd);
    } catch {
      // Hash corrupto o de formato desconocido: nunca lo trates como válido.
      return false;
    }
  }
  // ¿Este hash se generó con parámetros más débiles que los actuales?
  necesitaRehash(hash: string): boolean {
    return argon2.needsRehash(hash, this.opciones);
  }
}
El formato PHC: por qué el hash se guarda en una sola columna Argon2 y bcrypt devuelven una cadena autodescriptiva. Por ejemplo $argon2id$v=19$m=19456,t=2,p=1$c2FsYWxlYXRvcmlh$aGFzaHJlc3VsdGFudGU contiene, separados por $: algoritmo, versión, parámetros, sal en base64 y hash en base64. Por eso no necesitas una columna «salt» ni guardar los parámetros aparte, y por eso la verificación de un hash antiguo funciona aunque hayas subido el coste: la librería usa los parámetros que vienen dentro del propio hash.

12.3.5 Enumeración de usuarios: mensajes y tiempos idénticos

Si tu API responde «ese correo no está registrado» cuando el usuario no existe y «contraseña incorrecta» cuando sí existe, has construido un oráculo de existencia de cuentas. Un atacante prueba un millón de correos, se queda con los que existen y luego lanza un ataque de credential stuffing dirigido, o sencillamente vende la lista («estas 50.000 personas son clientes de esta clínica»).

Igualar el mensaje no basta: hay que igualar también el tiempo. Si cuando el usuario no existe respondes en 5 ms y cuando existe en 300 ms (porque has verificado el hash), el canal temporal delata la existencia igual de bien. La solución es ejecutar siempre una verificación de hash, aunque sea contra un hash señuelo.

auth.service.tsINCORRECTO
async validar(email: string, pwd: string) {
  const usuario = await this.usuarios.findOne({ email });
  if (!usuario) {
    // Fuga 1: mensaje distinto → enumeración directa.
    // Fuga 2: retorno inmediato → enumeración por tiempo.
    throw new UnauthorizedException('El usuario no existe');
  }
  if (!(await argon2.verify(usuario.passwordHash, pwd))) {
    throw new UnauthorizedException('Contraseña incorrecta');
  }
  return usuario;
}
// El registro suele tener el mismo problema:
// "Ese email ya está registrado" es un oráculo perfecto.
auth.service.tsCORRECTO
// Hash señuelo calculado UNA vez al arrancar, con los mismos parámetros.
// Sirve para consumir el mismo tiempo cuando el usuario no existe.
private hashSenuelo!: string;
async onModuleInit() {
  this.hashSenuelo = await this.hashService.hash(randomUUID());
}
async validar(email: string, pwd: string): Promise<Usuario> {
  const usuario = await this.usuarios.findOne({ email });
  // SIEMPRE se verifica un hash: mismo coste, mismo tiempo.
  const hash = usuario?.passwordHash ?? this.hashSenuelo;
  const coincide = await this.hashService.verificar(hash, pwd);
  if (!usuario || !coincide || !usuario.activo) {
    // Mensaje único e idéntico para todos los casos.
    throw new UnauthorizedException('Credenciales no válidas');
  }
  // Migración transparente del coste: tenemos la contraseña en claro
  // AQUÍ y solo aquí, así que es el único momento posible para rehashear.
  if (this.hashService.necesitaRehash(usuario.passwordHash)) {
    usuario.passwordHash = await this.hashService.hash(pwd);
    await this.em.flush();
  }
  return usuario;
}
El registro y la recuperación también enumeran

De poco sirve blindar el login si POST /registro responde «ese email ya existe» o si «¿olvidaste tu contraseña?» distingue entre correo conocido y desconocido. El patrón correcto es responder siempre lo mismo («si ese correo está registrado, recibirás un mensaje») y resolver el conflicto por correo electrónico: al que ya tiene cuenta se le envía un aviso de «alguien intentó registrarse con tu dirección», al que no la tiene, el enlace de activación.

Hay un caso en el que la enumeración es inevitable por diseño: un registro con validación inmediata de disponibilidad de nombre de usuario público (como en una red social). Ahí se asume el riesgo, pero se compensa con rate limiting agresivo y captcha.

12.3.6 Comparación en tiempo constante

Cuando compares dos secretos que no son hashes de contraseña —un token de API, un HMAC de webhook, un código de un solo uso—, la comparación normal con === es peligrosa: se detiene en el primer byte distinto, y ese tiempo diferencial permite reconstruir el secreto byte a byte. Se usa crypto.timingSafeEqual.

tiempo-constante.ts
import { timingSafeEqual, createHash } from 'node:crypto';
export function igualSeguro(a: string, b: string): boolean {
  // timingSafeEqual exige buffers de la MISMA longitud, y lanza si no lo son:
  // comparar longitudes con != volvería a filtrar información.
  // Truco estándar: hashear ambos a longitud fija primero.
  const ha = createHash('sha256').update(a).digest();
  const hb = createHash('sha256').update(b).digest();
  return timingSafeEqual(ha, hb);
}
// Uso típico: verificar la firma de un webhook de Stripe/GitHub.
// NUNCA: if (firmaRecibida === firmaEsperada)
// SÍ:    if (igualSeguro(firmaRecibida, firmaEsperada))
// Para contraseñas NO hace falta: argon2.verify y bcrypt.compare
// ya comparan en tiempo constante internamente.

12.3.7 Política de contraseñas realista

Durante veinte años se impusieron reglas de composición («una mayúscula, un número, un símbolo») y caducidad cada 90 días. La revisión 4 de NIST SP 800-63B las desaconseja expresamente, y OWASP se ha alineado. El motivo es empírico: esas reglas producen Verano2026!, Verano2026!!, Verano2026!!!, es decir, contraseñas predecibles que además el usuario apunta en un post-it.

Lo que sí funciona

  • Longitud mínima de 8 caracteres como suelo absoluto, y 12–15 como mínimo recomendable. La longitud aporta mucha más entropía que la complejidad.
  • Permitir hasta 64 caracteres o más y todo el rango Unicode, espacios incluidos: las frases de contraseña son fáciles de recordar y difíciles de romper.
  • Contrastar contra listas de contraseñas filtradas. Es la medida individual más eficaz.
  • Permitir pegar desde el portapapeles y no romper los gestores de contraseñas con autocomplete="off".
  • Medidor de fortaleza real (tipo zxcvbn), que estima intentos necesarios en lugar de contar tipos de carácter.
  • Segundo factor (TOTP o WebAuthn). Es lo que de verdad detiene el credential stuffing.

Lo que hay que abandonar

  • Reglas de composición obligatorias. Empujan a patrones predecibles.
  • Caducidad periódica sin motivo. Solo se fuerza el cambio ante indicio de compromiso.
  • Límites de longitud máxima bajos (32 caracteres o menos): delatan que quizá ni siquiera estás hasheando.
  • Preguntas de seguridad («nombre de tu primera mascota»): son datos públicos en redes sociales y no se pueden cambiar.
  • Pistas de contraseña almacenadas: son texto plano con otro nombre.
  • Prohibir caracteres «peligrosos» para evitar inyección: si necesitas eso, el problema está en tus consultas, no en la contraseña.
password-filtrada.service.ts · comprobación con k-anonimato
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
/**
 * Consulta Have I Been Pwned SIN enviar la contraseña ni su hash completo.
 * Se envían solo los 5 primeros caracteres hexadecimales del SHA-1 y el
 * servicio devuelve todos los sufijos que empiezan por ese prefijo
 * (unos 800). El emparejamiento final se hace en local: el servidor remoto
 * nunca sabe qué contraseña estabas comprobando. Esto es k-anonimato.
 */
@Injectable()
export class PasswordFiltradaService {
  async vecesFiltrada(pwd: string): Promise<number> {
    // SHA-1 aquí NO es un fallo criptográfico: es el formato que exige la
    // API pública, y no se usa para almacenar nada.
    const sha1 = createHash('sha1').update(pwd).digest('hex').toUpperCase();
    const prefijo = sha1.slice(0, 5);
    const sufijo = sha1.slice(5);
    const respuesta = await fetch(`https://api.pwnedpasswords.com/range/${prefijo}`, {
      headers: { 'Add-Padding': 'true' },   // rellena la respuesta: oculta el tamaño real
      signal: AbortSignal.timeout(2000),
    });
    if (!respuesta.ok) return 0;            // el servicio caído no debe bloquear el registro
    for (const linea of (await respuesta.text()).split('\n')) {
      const [suf, cuenta] = linea.trim().split(':');
      if (suf === sufijo) return Number(cuenta);
    }
    return 0;
  }
}

12.3.8 Recuperación de contraseña sin abrir un agujero

El flujo de «he olvidado mi contraseña» es, para un atacante, una puerta trasera oficial al sistema de autenticación. Si el token es predecible, no caduca o se puede reutilizar, has construido un mecanismo de suplantación con soporte del propio producto.

  FLUJO SEGURO DE RECUPERACIÓN
  ───────────────────────────────────────────────────────────────────────
  1. POST /auth/recuperar { email }
       └─► Respuesta SIEMPRE 202: "si el correo existe, recibirás un mensaje"
           (rate limit por IP y por email)

  2. Si el usuario existe:
       token = base64url(randomBytes(32))        ← 256 bits de entropía
       BD    ← { userId, sha256(token), expira: ahora+30min, usado: false }
       email ← https://app.example.com/reset?token=<token en claro>
       (el token en claro NO se guarda en ninguna parte del servidor)

  3. POST /auth/reset { token, nuevaPassword }
       ├─ buscar por sha256(token)               ← nunca por el token en claro
       ├─ ¿existe? ¿no usado? ¿no expirado?      ← las tres, o 400 genérico
       ├─ marcar usado = true  (un solo uso, en la MISMA transacción)
       ├─ passwordHash = argon2(nuevaPassword)
       ├─ INVALIDAR todas las sesiones y refresh tokens del usuario
       └─ enviar correo "tu contraseña ha cambiado" (detección por la víctima)
  ───────────────────────────────────────────────────────────────────────
  Errores que este flujo evita:
   · token adivinable (Math.random, id incremental, JWT sin jti)
   · token reutilizable → el atacante lo captura del historial y vuelve a entrar
   · sesión del atacante que sobrevive al cambio de contraseña
   · enumeración de correos en el paso 1
Tres detalles que se olvidan siempre

1. El token se guarda hasheado. Si se filtra la tabla, los tokens vivos no sirven de nada. Aquí sí basta con SHA-256 rápido, y esto es importante entenderlo: el hash lento existe para proteger secretos de baja entropía elegidos por humanos. Un token de 256 bits aleatorios no se puede romper por fuerza bruta ni con hash rápido, así que añadir Argon2 solo aportaría latencia.

2. El enlace viaja en la URL, y las URLs acaban en historiales, logs de proxy y cabeceras Referer. Por eso la caducidad es corta (15–60 minutos) y el uso es único.

3. Cambiar la contraseña debe expulsar al atacante. Si el intruso ya tenía una sesión abierta y la víctima cambia la contraseña, esa sesión debe morir. Sin invalidación de sesiones y refresh tokens, el robo persiste.

12.4 Sesiones frente a tokens

Toda autenticación web resuelve el mismo problema: HTTP no tiene memoria, así que después de demostrar quién eres hace falta un mecanismo para no repetirlo en cada petición. Hay dos familias de solución y una tercera que combina lo mejor de ambas.

CriterioSesión en servidor (cookie con identificador opaco)JWT autocontenido (portador en cabecera)
Qué viajaUn identificador aleatorio sin significado (~32 bytes)El estado completo firmado (~300–1500 bytes)
Estado en el servidorSí: tabla o Redis con las sesiones vivasNo: la verificación es solo criptográfica
Coste por peticiónUna lectura al almacén de sesionesUna verificación de firma (µs con HMAC)
Revocación inmediataTrivial: borras la fila y la sesión muereImposible sin estado: el token vale hasta que expira
Cambio de permisosEfecto inmediatoSe arrastra el rol antiguo hasta la caducidad
Escalado horizontalRequiere almacén compartido (Redis), no sesiones en memoriaNinguna coordinación entre instancias
Varios servicios / dominiosIncómodo: las cookies están atadas al dominioNatural: cualquier servicio con la clave pública valida
Exposición a XSSBaja con HttpOnly: el script no puede leer la cookieAlta si se guarda en localStorage: el script lo lee y lo exfiltra
Exposición a CSRFSí: el navegador envía la cookie sola. Necesita SameSite y/o token anti-CSRFNo, si viaja en la cabecera Authorization: el navegador no la añade solo
Cierre de sesión realSolo simulado en el cliente, salvo lista de revocación
Criterio de decisión

Aplicación web clásica, un solo backend, misma organización: sesión en servidor con cookie HttpOnly. Es la opción más segura y la que menos se rompe. La supuesta «no escalabilidad» es un mito: Redis atiende cientos de miles de lecturas por segundo.

API pública, apps móviles, microservicios, clientes de terceros: JWT, porque necesitas validación sin estado compartido y clientes que no son navegadores.

SPA de Angular contra tu propio backend: el modelo híbrido de abajo. Es lo que recomendamos en este libro.

12.4.1 El modelo híbrido recomendado

  ┌───────────────────────────────────────────────────────────────────────┐
  │ ACCESS TOKEN   JWT · 5–15 min · EN MEMORIA del SPA (variable/señal)   │
  │                viaja en cabecera Authorization: Bearer …              │
  │                · no es legible por otro origen  · inmune a CSRF       │
  │                · si hay XSS, el atacante lo roba, pero dura minutos   │
  ├───────────────────────────────────────────────────────────────────────┤
  │ REFRESH TOKEN  opaco aleatorio · 7–30 días · COOKIE HttpOnly Secure   │
  │                SameSite=Strict · Path=/auth/refresh                   │
  │                · JavaScript NO puede leerlo (inmune a XSS)            │
  │                · está en BD hasheado → revocación real e inmediata    │
  │                · como es cookie, ese endpoint necesita anti-CSRF      │
  └───────────────────────────────────────────────────────────────────────┘
   Al recargar la página el access token se pierde: el SPA llama a
   /auth/refresh con la cookie y recupera uno nuevo de forma transparente.

Este diseño reparte los riesgos: el secreto de larga duración queda fuera del alcance de cualquier XSS, y el secreto accesible por JavaScript tiene una ventana de utilidad de minutos. Es el mejor equilibrio disponible hoy para una SPA sin recurrir a un backend for frontend completo.

12.5 JWT a fondo

12.5.1 Estructura: tres partes y una firma

Un JWT firmado (técnicamente un JWS en serialización compacta, RFC 7515) son tres bloques codificados en Base64URL —una variante de Base64 sin +, / ni =, apta para URLs— unidos por puntos.

  eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDEifQ . eyJzdWIiOiI0MiIsImV4cCI6MTc… . MEUCIQDx7…
  └──────────── 1 · HEADER ────────────────┘   └──── 2 · PAYLOAD ───────┘   └─ 3 · FIRMA ─┘

  1 · HEADER   {"alg":"RS256","typ":"JWT","kid":"2026-01"}
               alg = algoritmo de firma · kid = qué clave se usó (rotación)

  2 · PAYLOAD  {"sub":"42","email":"ana@example.com","roles":["editor"],
                "iss":"https://api.example.com","aud":"web-app",
                "iat":1767200000,"exp":1767200900,"jti":"5f3a…"}

  3 · FIRMA    RSASSA-PKCS1-v1_5(SHA-256, base64url(header)+"."+base64url(payload))

  ═══════════════════════════════════════════════════════════════════════════
   Base64URL es CODIFICACIÓN, no cifrado. Cualquiera con el token lee el
   payload entero con atob() o en jwt.io. La firma garantiza INTEGRIDAD y
   AUTENTICIDAD, jamás confidencialidad.
  ═══════════════════════════════════════════════════════════════════════════
El error conceptual más extendido del ecosistema

«Los datos van seguros porque van en el token» es falso. El payload es texto legible para cualquiera que intercepte el token, para el propio usuario y para cualquier extensión del navegador. Nunca pongas dentro: contraseñas o sus hashes, números de tarjeta, DNI, direcciones, datos de salud, claves de API o cualquier dato personal que no sea imprescindible.

Si de verdad necesitas confidencialidad en el token, existe JWE (RFC 7516), que cifra el contenido. En la práctica casi nunca hace falta: es más simple poner en el token un identificador y consultar el resto en el servidor.

12.5.2 Claims estándar y propios

ClaimSignificadoPor qué importa verificarlo
issEmisorEvita aceptar tokens firmados por otro sistema al que también tienes acceso
subSujeto: el identificador del usuarioEs la identidad real; debe ser estable y opaco (no el email, que cambia)
audAudiencia: para quién es el tokenImpide que un token emitido para el servicio A se acepte en el servicio B
expExpira en (epoch en segundos)Limita la ventana de un token robado. Sin él, el token es eterno
nbfNo válido antes deTokens programados; poco habitual pero parte del estándar
iatEmitido enPermite invalidar «todo lo emitido antes de X» al cambiar la contraseña
jtiIdentificador único del tokenClave de la lista de revocación y de la detección de reutilización

12.5.3 Algoritmos y el ataque de confusión

FamiliaCómo funcionaCuándo usarla
HS256 (HMAC-SHA-256) Secreto simétrico: la misma clave firma y verifica Un único servicio emite y consume. Simple y rapidísimo. Todo el que puede verificar puede falsificar
RS256 (RSA-SHA-256) Clave privada firma, clave pública verifica Varios servicios validan tokens de un emisor central. La pública se publica en un JWKS y no compromete nada
ES256 (ECDSA P-256) Igual que RS256 pero con curva elíptica Misma decisión que RS256, con firmas ~8 veces más cortas y verificación más rápida. Preferible en tokens nuevos
Dos ataques históricos que siguen apareciendo en auditorías

alg: none. El estándar contempla un modo «sin firma» pensado para tokens ya protegidos por otro medio. Varias librerías antiguas lo aceptaban por defecto: bastaba con poner {"alg":"none"}, editar el payload a "roles":["admin"] y dejar la firma vacía.

Confusión de algoritmo. El servidor usa RS256 y publica su clave pública. El atacante toma esa clave pública, la usa como secreto HMAC para firmar un token con {"alg":"HS256"}, y una verificación ingenua que lee el algoritmo del propio token lo valida correctamente: usa la clave pública como secreto compartido. El token del atacante pasa.

La defensa es la misma para ambos y es absoluta: nunca dejes que el token decida cómo se verifica. Fija siempre la lista de algoritmos aceptados en el servidor.

verificar.tsINCORRECTO
import * as jwt from 'jsonwebtoken';
// El algoritmo se toma del header del token: el atacante lo controla.
const payload = jwt.verify(token, CLAVE_PUBLICA);
// Peor todavía: decode NO verifica NADA. Solo descodifica base64.
// Se ve en producción con una frecuencia alarmante.
const datos = jwt.decode(token) as { sub: string; roles: string[] };
if (datos.roles.includes('admin')) { /* … */ }
// Y esto tampoco verifica emisor ni audiencia: un token válido
// emitido por el entorno de staging sirve en producción.
verificar.tsCORRECTO
import * as jwt from 'jsonwebtoken';
const payload = jwt.verify(token, CLAVE_PUBLICA, {
  algorithms: ['RS256'],          // lista blanca: cierra alg:none y la confusión
  issuer: 'https://api.example.com',
  audience: 'web-app',
  clockTolerance: 30,             // 30 s de margen por relojes desincronizados
  maxAge: '15m',                  // límite duro aunque el exp diga otra cosa
});
// En Nest, lo mismo con JwtService:
await this.jwt.verifyAsync<Payload>(token, {
  algorithms: ['RS256'],
  issuer: this.config.get('JWT_ISSUER'),
  audience: this.config.get('JWT_AUDIENCE'),
});
// verifyAsync LANZA si algo falla. No captures el error para
// devolver null "por comodidad": falla de forma segura.
Relojes, caducidad y tamaño

Reloj desincronizado. exp y nbf se comparan contra la hora del servidor que verifica. Si dos máquinas difieren en un minuto, aparecen errores intermitentes de «token aún no válido». Sincroniza con NTP y deja una tolerancia de 30–60 segundos, nunca más.

Caducidad. 5–15 minutos para el access token. Cada minuto extra es un minuto que un token robado sigue funcionando. La comodidad la aporta el refresh, no un exp largo.

Tamaño. El token viaja en cada petición. Un JWT de 2 KB en una página que hace 40 llamadas son 80 KB de cabeceras. Si además lo pones en cookie, se suma a peticiones de imágenes y recursos estáticos.

12.5.4 El problema de la revocación

Un JWT firmado es válido por definición hasta que expira. No hay «borrarlo»: está en el cliente, no en tu base de datos. Si un usuario es despedido, si detectas un robo de token o si alguien cambia su contraseña, necesitas una respuesta. Estas son las cuatro estrategias reales:

EstrategiaCómo funcionaCosteCuándo usarla
Vida corta + refresh El access token dura minutos; la revocación se aplica al refrescar Ninguno por petición Siempre: es la base sobre la que se apoya todo lo demás
Lista de revocación por jti SET jti:5f3a 1 EX <segundos restantes> en Redis; el guard consulta Una lectura Redis por petición Cuando necesitas expulsión inmediata (banca, sanidad)
Versión de token por usuario El token lleva tv:7; incrementar tokenVersion en BD invalida todos los tokens del usuario Una lectura (cacheable) por petición «Cerrar sesión en todos los dispositivos», cambio de contraseña, cambio de rol
Marca temporal de corte Se rechaza todo token con iat < usuario.credencialesCambiadasEn Igual que la anterior Variante elegante de la anterior; no requiere contador

Fíjate en la ironía: en cuanto añades revocación inmediata, tu JWT deja de ser sin estado y empiezas a pagar una lectura por petición. Es exactamente lo que hacía la sesión clásica, con más pasos. Ese es el argumento honesto a favor de las sesiones cuando no necesitas federación entre servicios.

12.5.5 Implementación completa en NestJS

auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt', session: false }),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        // Secreto SIEMPRE desde configuración validada. Sin valor por defecto:
        // si falta, la aplicación no debe arrancar (ver 12.12).
        secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
        signOptions: {
          algorithm: 'HS256',
          expiresIn: '15m',
          issuer: config.getOrThrow<string>('JWT_ISSUER'),
          audience: config.getOrThrow<string>('JWT_AUDIENCE'),
        },
      }),
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy, HashService],
  controllers: [AuthController],
})
export class AuthModule {}
// Con RS256/ES256 se usarían privateKey y publicKey en lugar de secret,
// leídas de ficheros PEM o de un gestor de secretos.
auth/strategies/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
  constructor(private readonly auth: AuthService) {
    // passport-local espera 'username' y 'password'.
    // Aquí renombramos el primero a 'email'.
    super({ usernameField: 'email' });
  }
  // Lo que devuelva validate() acaba en request.user.
  // Si lanza, Passport traduce a 401 automáticamente.
  async validate(email: string, password: string): Promise<Usuario> {
    return this.auth.validarCredenciales(email, password);
  }
}
auth/strategies/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    private readonly config: ConfigService,
    private readonly usuarios: UsuariosService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,                 // NUNCA true
      secretOrKey: config.getOrThrow('JWT_ACCESS_SECRET'),
      algorithms: ['HS256'],                   // lista blanca obligatoria
      issuer: config.getOrThrow('JWT_ISSUER'),
      audience: config.getOrThrow('JWT_AUDIENCE'),
    });
  }
  // Solo se llama si la firma, exp, iss y aud son correctos.
  async validate(payload: JwtPayload): Promise<UsuarioActual> {
    const usuario = await this.usuarios.buscarPorId(payload.sub);
    // Revocación: el token es criptográficamente válido, pero
    // ¿sigue siéndolo en términos de negocio?
    if (!usuario || !usuario.activo || usuario.tokenVersion !== payload.tv) {
      throw new UnauthorizedException();
    }
    return { id: usuario.id, roles: usuario.roles, tenantId: usuario.tenantId };
  }
}
Sobre la consulta a base de datos en validate() Añade una lectura por petición y rompe la promesa «sin estado» del JWT. Es una decisión consciente: a cambio obtienes revocación real y datos frescos. Si el volumen lo exige, cachea el usuario en Redis con un TTL de 30–60 segundos, o comprueba solo tokenVersion (un entero, cacheable de forma trivial). Lo que no es aceptable es confiar ciegamente en los roles que vengan dentro del token durante 15 minutos en un sistema donde revocar importa.
auth/decorators · @Public() y @CurrentUser()
import { SetMetadata, createParamDecorator, ExecutionContext } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// Evita repetir @Req() y el casting de request.user en cada controlador.
export const CurrentUser = createParamDecorator(
  (campo: keyof UsuarioActual | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest<{ user: UsuarioActual }>();
    return campo ? request.user?.[campo] : request.user;
  },
);
// Uso:  @Get('perfil') perfil(@CurrentUser() u: UsuarioActual) { … }
//       @Get('mis-tareas') mias(@CurrentUser('id') id: string) { … }
auth/guards/jwt-auth.guard.ts · guard global con excepciones
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) { super(); }
  canActivate(context: ExecutionContext) {
    // getAllAndOverride: el decorador del método gana al de la clase.
    const esPublico = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (esPublico) return true;
    return super.canActivate(context);
  }
}
// app.module.ts — se activa para TODA la aplicación:
// providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]
//
// Este es el orden correcto: cerrado por defecto, abierto por excepción.
// Al revés (proteger endpoint por endpoint con @UseGuards) es cuestión de
// tiempo que alguien olvide uno, y ese será el que se explote.
auth.controller.tsINCORRECTO
@Controller('auth')
export class AuthController {
  // Con el guard global activo y SIN @Public(), este endpoint exige
  // un token válido... para poder obtener un token. Bucle imposible.
  // Síntoma clásico: "el login devuelve 401 y no entiendo por qué".
  @Post('login')
  @UseGuards(AuthGuard('local'))
  login(@Req() req) {
    return this.auth.emitirTokens(req.user);
  }
  // Y este devuelve la ENTIDAD completa, con passwordHash incluido.
  @Get('yo')
  yo(@Req() req) {
    return this.usuarios.findOne(req.user.id);
  }
}
auth.controller.tsCORRECTO
@Controller('auth')
export class AuthController {
  @Public()                                   // excepción explícita y visible
  @UseGuards(AuthGuard('local'))              // valida email + contraseña
  @HttpCode(HttpStatus.OK)
  @Throttle({ default: { limit: 5, ttl: 60_000 } })
  @Post('login')
  async login(
    @CurrentUser() usuario: UsuarioActual,
    @Res({ passthrough: true }) res: Response,
  ): Promise<{ accessToken: string }> {
    const { accessToken, refreshToken } = await this.auth.emitirPar(usuario);
    res.cookie('rt', refreshToken, {
      httpOnly: true,                    // invisible para JavaScript
      secure: true,                      // solo por HTTPS
      sameSite: 'strict',                // el navegador no la envía cross-site
      path: '/auth/refresh',             // solo se manda donde hace falta
      maxAge: 7 * 24 * 60 * 60 * 1000,
    });
    return { accessToken };              // el refresh NO va en el cuerpo
  }
  @Get('yo')
  yo(@CurrentUser() usuario: UsuarioActual): PerfilDto {
    return PerfilDto.desde(usuario);     // DTO explícito, sin campos internos
  }
}

12.5.6 La alternativa sin Passport

Passport aporta un ecosistema enorme de estrategias (Google, SAML, LDAP…), pero también una capa de indirección con una API antigua basada en request.user y tipado flojo. Para autenticación por JWT propia, un guard directo es más corto y más fácil de depurar.

auth/guards/jwt.guard.ts · sin Passport
@Injectable()
export class JwtGuard implements CanActivate {
  constructor(
    private readonly jwt: JwtService,
    private readonly reflector: Reflector,
    private readonly revocacion: RevocacionService,
  ) {}
  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY,
        [ctx.getHandler(), ctx.getClass()])) return true;
    const req = ctx.switchToHttp().getRequest<Request>();
    const cabecera = req.headers.authorization ?? '';
    // Comprobación estricta del esquema: "Bearer " y un único token.
    const [esquema, token] = cabecera.split(' ');
    if (esquema !== 'Bearer' || !token) throw new UnauthorizedException();
    try {
      const payload = await this.jwt.verifyAsync<JwtPayload>(token, {
        algorithms: ['HS256'],
        issuer: this.config.getOrThrow('JWT_ISSUER'),
        audience: this.config.getOrThrow('JWT_AUDIENCE'),
      });
      if (await this.revocacion.estaRevocado(payload.jti)) {
        throw new UnauthorizedException();
      }
      req.user = { id: payload.sub, roles: payload.roles, tenantId: payload.tid };
      return true;
    } catch {
      // Un único mensaje: no distingas "firma inválida" de "expirado".
      throw new UnauthorizedException();
    }
  }
}
Con @nestjs/passportGuard propio
VentajasEstrategias listas para OAuth, SAML, LDAP; convención conocida; menos código para casos estándarFlujo explícito y depurable; tipado propio; sin capa intermedia; control total de los errores
InconvenientesAbstracción con API de 2011; errores opacos; el tipado de request.user hay que ampliarlo a manoTú mantienes la verificación; hay que reimplementar cada proveedor externo
RecomendaciónGuard propio para el JWT interno; Passport para los proveedores OAuth externos. Conviven sin problema en la misma aplicación.

12.5.7 Refresh tokens: rotación y detección de robo

Un refresh token vive días o semanas: si se roba y no hay contramedidas, el atacante mantiene acceso indefinido. La rotación convierte cada uso en un token nuevo e invalida el anterior. Eso, por sí solo, no impide el robo, pero habilita algo mucho más potente: si alguna vez llega un token ya usado, es que existen dos copias, y eso solo puede significar robo. La respuesta es invalidar toda la familia.

  FLUJO COMPLETO CON ROTACIÓN Y DETECCIÓN DE REUTILIZACIÓN
  ═══════════════════════════════════════════════════════════════════════════
  CLIENTE                        API                          ALMACÉN (BD)
  ───────────────────────────────────────────────────────────────────────────
  1) POST /auth/login
     email + contraseña   ─────►  Argon2.verify OK
                                  familia F1 = uuid()
                                  RT1 = random(32 bytes)
                                                        ────► guarda
                                                              { sha256(RT1),
                                                                familia: F1,
                                                                usado: false,
                                                                expira: +7d }
                          ◄─────  AT1 (15 min, en el cuerpo)
                                  RT1 (cookie HttpOnly)

  2) GET /facturas
     Authorization: Bearer AT1 ─► firma OK, exp OK        (sin tocar la BD)
                          ◄─────  200 OK

  3) … pasan 15 minutos: AT1 expira …
     GET /facturas        ─────►  401 + WWW-Authenticate
                          ◄─────  el interceptor de Angular lo captura

  4) POST /auth/refresh  (cookie RT1)
                          ─────►  busca sha256(RT1)      ────► encontrado,
                                  ¿usado? NO                    usado: false
                                  ROTACIÓN:
                                    RT1.usado = true
                                    RT2 = random(32), familia F1
                                                      ─────►  guarda RT2
                          ◄─────  AT2 + cookie RT2      (todo en 1 transacción)

  5) ─── UN ATACANTE ROBÓ RT1 EN EL PASO 4 (copia de seguridad, proxy, malware) ───

  6) POST /auth/refresh  (cookie RT1)   ← el ATACANTE reutiliza el token viejo
                          ─────►  busca sha256(RT1)      ────► ¡usado = true!
                                  ══════════════════════════════════════════
                                   REUTILIZACIÓN DETECTADA
                                   · revocar TODA la familia F1 (RT1, RT2, …)
                                   · registrar incidente con IP y user-agent
                                   · avisar por correo al usuario
                                  ══════════════════════════════════════════
                          ◄─────  401

  7) El usuario legítimo intenta refrescar con RT2 → también revocado → 401
     → vuelve al login. Molestia de 10 segundos frente a una cuenta robada.
  ═══════════════════════════════════════════════════════════════════════════
  Clave: sin rotación, el paso 6 sería indistinguible de un uso legítimo
  y el atacante conservaría acceso hasta que expirase el token.
auth/refresh-token.entity.ts
import { Entity, PrimaryKey, Property, ManyToOne, Index } from '@mikro-orm/core';
@Entity()
export class RefreshToken {
  @PrimaryKey() id!: string;
  // Se guarda el SHA-256 del token, nunca el token en claro: si se filtra
  // la tabla, no hay sesiones utilizables. SHA-256 basta porque el token
  // tiene 256 bits de entropía aleatoria (no es un secreto humano).
  @Property({ length: 64 }) @Index() tokenHash!: string;
  // Todos los tokens que descienden de un mismo login comparten familia.
  @Property() @Index() familia!: string;
  @ManyToOne(() => Usuario) usuario!: Usuario;
  @Property({ default: false }) usado = false;
  @Property({ default: false }) revocado = false;
  @Property() expiraEn!: Date;
  // Contexto para auditoría y para "cerrar sesión en este dispositivo".
  @Property({ nullable: true }) ip?: string;
  @Property({ nullable: true }) userAgent?: string;
  @Property() creadoEn: Date = new Date();
}
auth/refresh.service.ts · rotación con detección
@Injectable()
export class RefreshService {
  private static readonly DIAS = 7;
  constructor(private readonly em: EntityManager, private readonly jwt: JwtService,
              private readonly auditoria: AuditoriaService) {}
  private hash(token: string): string {
    return createHash('sha256').update(token).digest('hex');
  }
  async emitir(usuario: Usuario, familia: string, ctx: ContextoPeticion) {
    const token = randomBytes(32).toString('base64url');   // 256 bits
    const expiraEn = new Date(Date.now() + RefreshService.DIAS * 864e5);
    this.em.persist(this.em.create(RefreshToken, {
      id: randomUUID(), tokenHash: this.hash(token), familia,
      usuario, expiraEn, ip: ctx.ip, userAgent: ctx.userAgent,
    }));
    await this.em.flush();
    return token;
  }
  async rotar(tokenRecibido: string, ctx: ContextoPeticion) {
    // Toda la operación en una transacción: dos peticiones simultáneas
    // con el mismo token no pueden rotar las dos (condición de carrera).
    return this.em.transactional(async (em) => {
      const fila = await em.findOne(RefreshToken,
        { tokenHash: this.hash(tokenRecibido) },
        { populate: ['usuario'], lockMode: LockMode.PESSIMISTIC_WRITE });
      // Token desconocido: puede ser basura, o un token ya purgado.
      if (!fila) throw new UnauthorizedException();
      // ─── DETECCIÓN DE REUTILIZACIÓN ───
      if (fila.usado || fila.revocado) {
        await em.nativeUpdate(RefreshToken,
          { familia: fila.familia }, { revocado: true });
        await this.auditoria.incidente('REFRESH_REUTILIZADO', {
          usuarioId: fila.usuario.id, familia: fila.familia,
          ip: ctx.ip, userAgent: ctx.userAgent,
        });
        // Aquí se avisa por correo al usuario: es su única forma de enterarse.
        throw new UnauthorizedException();
      }
      if (fila.expiraEn < new Date()) throw new UnauthorizedException();
      if (!fila.usuario.activo)        throw new UnauthorizedException();
      fila.usado = true;                              // consumido
      const nuevoRefresh = await this.emitir(fila.usuario, fila.familia, ctx);
      const accessToken  = await this.jwt.signAsync({
        sub: fila.usuario.id, roles: fila.usuario.roles,
        tv: fila.usuario.tokenVersion, jti: randomUUID(),
      });
      return { accessToken, nuevoRefresh };
    });
  }
  // "Cerrar sesión en todos los dispositivos"
  async revocarTodo(usuarioId: string) {
    await this.em.nativeUpdate(RefreshToken,
      { usuario: usuarioId, revocado: false }, { revocado: true });
    await this.em.nativeUpdate(Usuario, { id: usuarioId },
      { tokenVersion: raw('token_version + 1') });   // mata también los access
  }
}
La condición de carrera que rompe la rotación en producción Un SPA con tres peticiones en vuelo recibe tres 401 a la vez y lanza tres refrescos con el mismo token. Solo uno rota; los otros dos parecen reutilización y expulsan al usuario legítimo. Hay dos defensas complementarias: en el cliente, serializar el refresco (un único observable compartido, ver 12.6); en el servidor, una ventana de gracia de pocos segundos en la que el token recién rotado devuelve el mismo par ya emitido en vez de disparar la alarma. Sin una de las dos, la detección de robo genera falsos positivos constantes.

12.6 ¿Dónde guarda el token el cliente Angular?

No existe una opción perfecta: cada una intercambia riesgo de XSS por riesgo de CSRF o por comodidad. Esta es la tabla honesta, sin la simplificación habitual de «usa localStorage y listo».

AlmacénFrente a XSSFrente a CSRFPersistenciaVeredicto
Variable / señal en memoriaLa mejor: no hay API que el script pueda enumerar; el atacante debe robarlo en el instante exactoInmune (viaja en cabecera)Se pierde al recargar F5 y no se comparte entre pestañasRecomendado para el access token
localStorageMalo: cualquier script del origen lo lee y lo envía fuera en una líneaInmunePermanente hasta borrarloSolo si asumes el riesgo conscientemente
sessionStorageIgual de malo que localStorage, pero la ventana de exposición es menorInmunePor pestaña; muere al cerrarlaCompromiso pobre
Cookie HttpOnly + Secure + SameSiteLa mejor: document.cookie no la ve. El XSS puede usar la sesión, pero no exfiltrar el tokenVulnerable: el navegador la envía sola. Exige SameSite y token anti-CSRFSobrevive a recargas y pestañasRecomendado para el refresh token
Cookie legible por JSMaloVulnerablePersistenteLo peor de ambos mundos. No lo hagas
Recomendación razonada

Access token en memoria + refresh token en cookie HttpOnly. El razonamiento: si tienes XSS, cualquier cosa legible por JavaScript está perdida, así que lo importante es minimizar el daño persistente. Con este reparto, el atacante consigue como mucho un token de 15 minutos y no puede robar la credencial de larga duración ni mantener el acceso desde su propia máquina. Y como el access token viaja en cabecera, la superficie de CSRF se reduce a un único endpoint, /auth/refresh, que se protege con SameSite=Strict y un token anti-CSRF.

Nada de esto sustituye a evitar el XSS: interpolación de Angular (que escapa por defecto), cero bypassSecurityTrustHtml sin sanear, y una CSP estricta.

core/auth/auth.interceptor.ts · Angular · refresco transparente y serializado
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.accessToken();          // signal: vive SOLO en memoria
  const conAuth = token
    ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : req;
  return next(conAuth).pipe(
    catchError((error: HttpErrorResponse) => {
      // Un 401 en el propio refresco significa sesión muerta: no reintentar.
      const esRefresco = req.url.endsWith('/auth/refresh');
      if (error.status !== 401 || esRefresco) return throwError(() => error);
      return auth.refrescar$().pipe(
        switchMap((nuevo) =>
          next(req.clone({ setHeaders: { Authorization: `Bearer ${nuevo}` } }))),
      );
    }),
  );
};
core/auth/auth.service.ts · Angular · un solo refresco en vuelo
@Injectable({ providedIn: 'root' })
export class AuthService {
  private readonly http = inject(HttpClient);
  readonly accessToken = signal<string | null>(null);   // nunca en localStorage
  private refresco$?: Observable<string>;
  /**
   * Si diez peticiones fallan con 401 a la vez, TODAS comparten este único
   * observable. Sin esto se lanzarían diez refrescos con el mismo refresh
   * token: nueve parecerían reutilización y el servidor cerraría la sesión
   * (ver la condición de carrera descrita en 12.5.7).
   */
  refrescar$(): Observable<string> {
    this.refresco$ ??= this.http
      .post<{ accessToken: string }>('/auth/refresh', {}, { withCredentials: true })
      .pipe(
        map((r) => r.accessToken),
        tap((t) => this.accessToken.set(t)),
        catchError((e) => { this.cerrarSesionLocal(); return throwError(() => e); }),
        finalize(() => { this.refresco$ = undefined; }),
        shareReplay({ bufferSize: 1, refCount: true }),
      );
    return this.refresco$;
  }
  // withCredentials: true es obligatorio para que el navegador envíe la
  // cookie del refresh en una petición cross-origin. Y en el servidor,
  // CORS debe declarar credentials: true con un origen EXPLÍCITO (12.10).
}

12.7 OAuth2 y OpenID Connect

12.7.1 Los cuatro roles y el problema que resuelven

Antes de OAuth, para que una aplicación leyera tu agenda de Google tenías que darle tu usuario y tu contraseña de Google. Eso significaba acceso total, permanente y sin posibilidad de revocar sin cambiar la contraseña. OAuth 2.0 (RFC 6749) es un protocolo de delegación de autorización: permite que una aplicación obtenga permisos concretos y revocables sin ver jamás tus credenciales.

OAuth2 no es un protocolo de autenticación Es un error histórico muy extendido. OAuth2 responde a «esta aplicación tiene permiso para hacer X en nombre de alguien»; no dice de forma estandarizada quién es ese alguien. Usar un access token de OAuth como prueba de identidad abre el ataque de confused deputy: un token emitido para otra aplicación podría colarse. Para autenticar existe OpenID Connect, que añade encima de OAuth2 un id_token con audiencia y nonce verificables.

12.7.2 Flujos: cuál usar y cuáles están desaconsejados

FlujoPara quéEstado
Authorization code + PKCESPAs, apps móviles y aplicaciones con servidor. El correcto en el 95 % de los casosRecomendado. OAuth 2.1 lo exige para todos los clientes, con o sin secreto
Client credentialsMáquina a máquina: un cron, un microservicio llamando a otro. No hay persona implicadaRecomendado en su ámbito
Device authorization grantTelevisores, CLIs, dispositivos sin teclado cómodo ni navegadorRecomendado en su ámbito
Refresh token grantRenovar el access token sin volver a molestar al usuarioRecomendado, con rotación obligatoria en clientes públicos
ImplicitDevolvía el token directamente en el fragmento de la URLDesaconsejado. El token acaba en el historial, en el Referer y sin posibilidad de refresh seguro. PKCE lo sustituye
Resource owner password credentialsLa app pedía usuario y contraseña y los reenviabaDesaconsejado. Destruye la premisa de OAuth: la app vuelve a ver la contraseña. Imposibilita 2FA y federación. Eliminado en OAuth 2.1
  AUTHORIZATION CODE + PKCE  (RFC 7636)  ·  paso a paso
  ═══════════════════════════════════════════════════════════════════════════
  0) El cliente genera y GUARDA en su sesión/almacenamiento temporal:
       code_verifier  = base64url(randomBytes(32))      ← secreto de un solo uso
       code_challenge = base64url(sha256(code_verifier))
       state          = base64url(randomBytes(16))      ← anti-CSRF
       nonce          = base64url(randomBytes(16))      ← anti-replay (OIDC)

  CLIENTE                    NAVEGADOR              AUTHORIZATION SERVER
  ───────────────────────────────────────────────────────────────────────────
  1) redirige a  ─────────────────────────────────►  /authorize
       ?response_type=code & client_id=… & redirect_uri=…
       & scope=openid profile email
       & state=STATE & nonce=NONCE
       & code_challenge=CHALLENGE & code_challenge_method=S256

  2)                        el usuario se autentica y CONSIENTE los scopes
                                                     (aquí ocurre el 2FA)

  3) redirect_uri?code=CODE&state=STATE  ◄────────────  302
       ▲
       └─ EL CLIENTE VERIFICA QUE state COINCIDE con el que generó.
          Si no coincide → aborta. (Sin esto: CSRF de inicio de sesión)

  4) POST /token  ──────────────────────────────────►
       grant_type=authorization_code & code=CODE
       & redirect_uri=… & client_id=…
       & code_verifier=VERIFIER        ← el secreto original, EN CLARO

       El servidor comprueba:  sha256(code_verifier) == code_challenge
       guardado junto al código. Solo quien inició el flujo lo sabe.

  5)                        ◄──────────────────────  { access_token,
                                                       refresh_token,
                                                       id_token (OIDC) }
       El cliente valida el id_token: firma (JWKS), iss, aud == client_id,
       exp y nonce == el que generó en el paso 0.
  ═══════════════════════════════════════════════════════════════════════════
  ¿QUÉ ATAQUE PARA PKCE? La intercepción del código de autorización: en móvil,
  una app maliciosa registra el mismo esquema de URL y captura el "code". Sin
  PKCE lo canjearía por tokens; con PKCE no puede, porque no conoce el
  code_verifier y el challenge no es reversible.

12.7.3 OpenID Connect: la capa de identidad

ElementoQué esPara quién
access_tokenCredencial de acceso, formato opaco a menudoEl resource server. El cliente no debe interpretarlo
id_tokenJWT firmado con la identidad: sub, email, email_verified, name, nonceEl cliente. Nunca se envía a una API como si fuera un access token
/userinfoEndpoint que devuelve los claims del usuario a cambio del access tokenCliente, cuando necesita datos frescos o que no caben en el token
scopeopenid (obligatorio), profile, email, offline_accessDefine qué se pide y qué claims aparecen
Discovery + JWKS/.well-known/openid-configuration y el juego de claves públicasPermite validar firmas y soportar rotación de claves por kid

12.7.4 Implementación en NestJS con Passport

auth/strategies/google.strategy.ts
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(config: ConfigService, private readonly auth: AuthService) {
    super({
      clientID: config.getOrThrow('GOOGLE_CLIENT_ID'),
      clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'),
      // Debe coincidir EXACTAMENTE con la registrada en la consola de Google.
      callbackURL: config.getOrThrow('GOOGLE_CALLBACK_URL'),
      scope: ['openid', 'email', 'profile'],
      state: true,        // passport genera y verifica el state por nosotros
      pkce: true,         // exige express-session para guardar el verifier
    });
  }
  async validate(_at: string, _rt: string, perfil: Profile, done: VerifyCallback) {
    const correo = perfil.emails?.[0];
    // CRÍTICO: sin esta comprobación, cualquiera que consiga que el proveedor
    // le devuelva un email no verificado puede apropiarse de una cuenta ajena.
    if (!correo?.value || correo.verified !== true) {
      return done(new UnauthorizedException('Correo no verificado'), false);
    }
    const usuario = await this.auth.vincularOCrear({
      proveedor: 'google',
      proveedorId: perfil.id,          // identificador estable del proveedor
      email: correo.value.toLowerCase(),
      nombre: perfil.displayName,
    });
    done(null, usuario);
  }
}
auth/auth.service.ts · vinculación de cuentas
async vincularOCrear(datos: PerfilExterno): Promise<Usuario> {
  // 1. ¿Ya existe ESTA identidad externa? Es la clave estable: el email cambia.
  const identidad = await this.em.findOne(IdentidadExterna,
    { proveedor: datos.proveedor, proveedorId: datos.proveedorId },
    { populate: ['usuario'] });
  if (identidad) return identidad.usuario;
  // 2. Existe una cuenta local con ese correo: hay que VINCULAR.
  const existente = await this.em.findOne(Usuario, { email: datos.email });
  if (existente) {
    // Punto peligroso. Vincular automáticamente solo es aceptable si el
    // proveedor confirma email_verified Y nuestra cuenta local también tiene
    // el correo verificado. Si no, un atacante que registre una cuenta local
    // con el correo de la víctima "hereda" su acceso cuando esta entra con
    // Google: es el ataque de pre-secuestro de cuenta (pre-hijacking).
    if (!existente.emailVerificado) {
      throw new ConflictException(
        'Inicia sesión con tu contraseña y vincula Google desde tu perfil');
    }
    this.em.persist(this.em.create(IdentidadExterna, { ...datos, usuario: existente }));
    await this.em.flush();
    return existente;
  }
  // 3. Primer inicio de sesión: cuenta nueva SIN contraseña.
  const usuario = this.em.create(Usuario, {
    email: datos.email, nombre: datos.nombre,
    emailVerificado: true, passwordHash: null, roles: ['user'],
  });
  this.em.persist(usuario);
  this.em.persist(this.em.create(IdentidadExterna, { ...datos, usuario }));
  await this.em.flush();
  return usuario;
}
Las cuatro validaciones que no puedes saltarte
  • state. Aleatorio, ligado a la sesión del navegador y verificado al volver. Sin él, un atacante puede forzar que la víctima inicie sesión en la cuenta del atacante (login CSRF) y así capturar todo lo que la víctima haga después.
  • nonce. Se envía en la petición y debe aparecer dentro del id_token. Impide reutilizar un id_token capturado en otro contexto.
  • redirect_uri. Registrada previamente y comparada por igualdad exacta, nunca con «empieza por» ni comodines. Un redirect abierto convierte el flujo en una entrega gratuita del código de autorización.
  • email_verified. Algunos proveedores permiten correos sin verificar. Confiar en ellos equivale a dejar que cualquiera reclame la cuenta de cualquiera.

12.8 Autorización: roles, políticas y propiedad

12.8.1 RBAC: control basado en roles

auth/roles · decorador, jerarquía y guard
export type Rol = 'user' | 'editor' | 'admin' | 'owner';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Rol[]) => SetMetadata(ROLES_KEY, roles);
// Jerarquía explícita: cada rol incluye los de nivel inferior. Sin esto
// acabas escribiendo @Roles('editor','admin','owner') por todas partes
// y el día que añadas un rol tendrás que tocar cien decoradores.
const INCLUYE: Record<Rol, Rol[]> = {
  owner:  ['owner', 'admin', 'editor', 'user'],
  admin:  ['admin', 'editor', 'user'],
  editor: ['editor', 'user'],
  user:   ['user'],
};
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  canActivate(ctx: ExecutionContext): boolean {
    const requeridos = this.reflector.getAllAndOverride<Rol[]>(ROLES_KEY,
      [ctx.getHandler(), ctx.getClass()]);
    if (!requeridos?.length) return true;      // sin @Roles: solo autenticación
    const { user } = ctx.switchToHttp().getRequest<{ user?: UsuarioActual }>();
    // Si no hay usuario aquí, el guard de autenticación no se ejecutó antes:
    // denegar. Nunca asumas que otro guard ya hizo el trabajo.
    if (!user) throw new UnauthorizedException();
    const efectivos = new Set(user.roles.flatMap((r) => INCLUYE[r] ?? []));
    if (!requeridos.some((r) => efectivos.has(r))) {
      throw new ForbiddenException('No tienes permisos para esta operación');
    }
    return true;
  }
}
// Uso:  @Roles('admin') @Delete(':id') borrar(@Param('id') id: string) { … }

12.8.2 De RBAC a ABAC: cuando los roles no bastan

RBAC es simple y auditable, pero se rompe en cuanto la regla depende de algo que no es el rol: «un editor puede modificar un artículo solo si es suyo y solo mientras esté en borrador», «un gestor puede aprobar gastos de su departamento y por debajo de 5.000 €». Intentar expresar eso con roles produce la explosión de roles: editor_marketing_borradores, gestor_ventas_hasta5000, y en pocos meses trescientos roles que nadie entiende ni puede auditar.

ModeloDecide en función deVentajaInconveniente
RBACEl rol del sujetoTrivial de entender y de auditarNo expresa contexto ni propiedad; explota combinatoriamente
ABAC / PBACAtributos del sujeto, del recurso, de la acción y del entornoExpresa cualquier regla real de negocioMás difícil de razonar y de probar; hay que evitar reglas contradictorias
Permisos granularesVerbos concretos (factura:emitir)Mínimo privilegio de verdad; roles = conjuntos de permisosMuchos permisos que mantener y documentar
ReBACRelaciones en un grafo (estilo Google Zanzibar)Ideal para jerarquías y comparticiónRequiere un servicio dedicado

El patrón práctico que mejor funciona: roles agrupando permisos granulares (RBAC como interfaz humana) y políticas con condiciones para lo que dependa del recurso concreto. Es exactamente lo que modela CASL.

12.8.3 Propiedad del recurso: filtrar en la capa de datos

Este es el fallo número uno del OWASP Top 10 y el más frecuente en auditorías reales. El patrón peligroso es «carga por id y luego comprueba»; el seguro es «carga ya filtrado». La diferencia parece cosmética y no lo es: la primera versión depende de que nadie olvide el if, y alguien lo olvidará.

facturas.service.tsINCORRECTO
@Get(':id')
async buscar(@Param('id') id: string) {
  // IDOR de manual: el id lo elige el cliente y no se contrasta con nadie.
  // GET /facturas/999 devuelve la factura de otra empresa.
  return this.em.findOneOrFail(Factura, { id });
}
@Patch(':id')
async actualizar(@Param('id') id: string, @Body() dto: any,
                 @CurrentUser() u: UsuarioActual) {
  const factura = await this.em.findOneOrFail(Factura, { id });
  // La comprobación existe... pero llega tarde y es frágil:
  // ya has cargado el dato ajeno (puede acabar en un log o en un error),
  // y basta con que otro método del servicio no repita este if.
  if (factura.empresa.id !== u.tenantId) throw new ForbiddenException();
  // Y además: mass assignment. dto es 'any' y wrap() asigna lo que llegue,
  // incluidos 'estado', 'total' o 'empresa'.
  wrap(factura).assign(dto);
  await this.em.flush();
  return factura;
}
facturas.service.tsCORRECTO
@Get(':id')
async buscar(@Param('id', ParseUUIDPipe) id: string,
             @CurrentUser() u: UsuarioActual) {
  // La pertenencia forma parte de la CONSULTA, no de un if posterior.
  // Si no es suya, findOneOrFail lanza NotFound: ni siquiera revelamos
  // que la factura existe (ver 12.2.2).
  return this.em.findOneOrFail(Factura, { id, empresa: u.tenantId });
}
@Patch(':id')
async actualizar(@Param('id', ParseUUIDPipe) id: string,
                 @Body() dto: ActualizarFacturaDto,   // DTO cerrado + whitelist
                 @CurrentUser() u: UsuarioActual) {
  const factura = await this.em.findOneOrFail(Factura, { id, empresa: u.tenantId });
  // Regla de negocio adicional, ortogonal a la propiedad.
  if (factura.estado !== EstadoFactura.BORRADOR) {
    throw new ConflictException('Solo se pueden editar facturas en borrador');
  }
  // assign con campos explícitos: nada de volcar el cuerpo entero.
  wrap(factura).assign({ concepto: dto.concepto, lineas: dto.lineas });
  await this.em.flush();
  return FacturaDto.desde(factura);
}
entities/factura.entity.ts · filtro global de MikroORM (multi-tenancy)
import { Entity, Filter, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core';
// El filtro se aplica a TODAS las consultas de esta entidad, incluidas las
// que escriba un compañero dentro de seis meses sin acordarse del tenant.
// Es defensa en profundidad: la última red bajo la comprobación explícita.
@Entity()
@Filter({
  name: 'tenant',
  cond: (args: { tenant: string }) => ({ empresa: args.tenant }),
  default: true,                    // activo salvo desactivación explícita
})
export class Factura {
  @PrimaryKey() id!: string;
  @ManyToOne(() => Empresa) empresa!: Empresa;
  @Property() total!: number;
}
// Middleware/interceptor por petición: el EntityManager está en contexto
// (fork por petición), así que el parámetro no se filtra entre usuarios.
@Injectable()
export class TenantInterceptor implements NestInterceptor {
  constructor(private readonly em: EntityManager) {}
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const { user } = ctx.switchToHttp().getRequest<{ user?: UsuarioActual }>();
    if (user) this.em.setFilterParams('tenant', { tenant: user.tenantId });
    return next.handle();
  }
}
// A partir de aquí, em.find(Factura, {}) NUNCA devuelve datos de otro tenant.
// Para tareas administrativas se desactiva de forma explícita y auditable:
//   em.find(Factura, {}, { filters: { tenant: false } })

12.8.4 CASL: políticas declarativas integradas en Nest

casl/ability.factory.ts
import { AbilityBuilder, createMongoAbility, MongoAbility,
         ExtractSubjectType, InferSubjects } from '@casl/ability';
export enum Accion { Gestionar = 'manage', Crear = 'create', Leer = 'read',
                     Actualizar = 'update', Borrar = 'delete' }
type Sujetos = InferSubjects<typeof Factura | typeof Usuario> | 'all';
export type AppAbility = MongoAbility<[Accion, Sujetos]>;
@Injectable()
export class AbilityFactory {
  crear(u: UsuarioActual): AppAbility {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
    if (u.roles.includes('admin')) {
      can(Accion.Gestionar, 'all');                    // 'manage' = cualquier acción
      // Ni siquiera el administrador borra facturas emitidas: obligación legal.
      cannot(Accion.Borrar, Factura, { estado: 'emitida' })
        .because('Una factura emitida no se borra, se rectifica');
    } else {
      // Condiciones sobre CAMPOS del recurso: esto es ABAC en la práctica.
      can(Accion.Leer, Factura, { empresa: u.tenantId });
      can(Accion.Crear, Factura);
      can(Accion.Actualizar, Factura,
          ['concepto', 'lineas'],                      // permiso a nivel de CAMPO
          { empresa: u.tenantId, estado: 'borrador', creadaPor: u.id });
    }
    return build({
      detectSubjectType: (item) =>
        item.constructor as ExtractSubjectType<Sujetos>,
    });
  }
}
casl/policies.guard.ts · integración con el ciclo de Nest
export const CHECK_POLICIES = 'check_policies';
export type Politica = (ability: AppAbility) => boolean;
export const Politicas = (...p: Politica[]) => SetMetadata(CHECK_POLICIES, p);
@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector,
              private readonly abilities: AbilityFactory) {}
  canActivate(ctx: ExecutionContext): boolean {
    const politicas = this.reflector.get<Politica[]>(CHECK_POLICIES, ctx.getHandler()) ?? [];
    const req = ctx.switchToHttp().getRequest<{ user?: UsuarioActual; ability?: AppAbility }>();
    if (!req.user) throw new UnauthorizedException();
    const ability = this.abilities.crear(req.user);
    req.ability = ability;        // disponible después para comprobar instancias
    if (!politicas.every((p) => p(ability))) throw new ForbiddenException();
    return true;
  }
}
// El guard resuelve permisos de TIPO ("¿puede actualizar facturas en general?").
// La comprobación por INSTANCIA necesita la entidad cargada, así que va en el
// servicio, donde CASL da un mensaje de error preciso:
//
//   ForbiddenError.from(ability).throwUnlessCan(Accion.Actualizar, factura);
//
// Para listados, @casl/ability/extra ofrece rulesToQuery(), que traduce las
// condiciones a un objeto de consulta reutilizable como filtro de MikroORM.

12.8.5 Autorización en el frontend: solo cosmética

Ocultar un botón en Angular no impide absolutamente nada: cualquiera abre las herramientas de desarrollo, lee el bundle (que es público), descubre la ruta DELETE /api/usuarios/:id y la llama con curl. La autorización del cliente existe para no mostrar acciones que van a fallar, que es una cuestión de experiencia de usuario, no de seguridad.

12.9 OWASP Top 10 aplicado a este stack

El OWASP Top 10 es una lista consensuada de las categorías de riesgo más críticas en aplicaciones web, basada en datos reales de vulnerabilidades. No es una norma de cumplimiento ni una lista exhaustiva: es el mínimo por el que empezar a auditar. A continuación, cada categoría traducida a este stack concreto.

CategoríaCómo aparece en una API NestJSMitigación concreta
A01 · Control de acceso rotoIDOR (findOne({ id }) con el id del cliente), falta de @Roles, endpoints internos sin guard, @Public() copiado por accidenteGuard global cerrado por defecto; filtrar por propietario/tenant en la consulta; filtros de MikroORM; tests de autorización por endpoint (12.8)
A02 · Fallos criptográficosSHA-256 para contraseñas, TLS opcional, secretos en el repositorio, tokens sin expiración, datos personales sin cifrar en reposoArgon2id; HTTPS forzado con HSTS; gestor de secretos; cifrado en la base de datos para columnas sensibles (12.3, 12.12)
A03 · InyecciónSQL crudo concatenado con em.getConnection().execute(); $where u operadores en MongoDB; inyección en logs; XSS reflejado desde la APIConsultas parametrizadas siempre; ValidationPipe con whitelist; listas blancas para lo que no se puede parametrizar (nombres de columna, ORDER BY)
A04 · Diseño inseguroNo hay límite de intentos de login, el flujo de recuperación no invalida sesiones, no existe modelo de amenazasModelar amenazas antes de programar (12.2.4); límites y cuotas como requisito funcional; historias de abuso además de historias de usuario
A05 · Configuración inseguraCORS con origin: '*', sin helmet, synchronize/debug activos en producción, trazas de error completas al cliente, endpoint de Swagger públicoConfiguración validada al arrancar; helmet; filtro de excepciones que no filtra internals; Swagger protegido o desactivado (12.10)
A06 · Componentes vulnerablesDependencias con CVE conocidos, paquetes abandonados, imagen base de Docker sin actualizarnpm audit en CI que rompe el build; Dependabot/Renovate; SBOM; fijar versiones con lockfile e imágenes por digest
A07 · Fallos de identificación y autenticaciónEnumeración de usuarios, sin 2FA, sesión que no muere al cambiar la contraseña, JWT sin verificar alg/iss/audMensajes y tiempos idénticos; rate limit progresivo; TOTP/WebAuthn; verificación estricta del token (12.3, 12.5)
A08 · Fallos de integridadnpm install de paquetes sin fijar en el despliegue, webhooks sin verificar firma, deserialización de datos no confiables, CI que puede publicar sin revisiónnpm ci con lockfile; verificar HMAC de webhooks en tiempo constante; firmar artefactos; revisión obligatoria en la rama principal
A09 · Fallos de registro y monitorizaciónNadie sabe que hubo 40.000 intentos de login fallidos; los logs no tienen actor ni correlación; se registran tokens completosAuditoría estructurada de eventos de seguridad; alertas sobre umbrales; redacción de datos sensibles (12.13)
A10 · SSRFUn endpoint que descarga una URL enviada por el usuario (avatar, importación, webhook de prueba) y alcanza 169.254.169.254 o servicios internosLista blanca de dominios y esquemas; resolver DNS y bloquear rangos privados; sin seguir redirecciones; egress restringido en red
La lista cambia cada pocos años OWASP publica revisiones periódicas que reordenan, fusionan y renombran categorías (por ejemplo, elevando la cadena de suministro de software a categoría propia y subiendo la configuración insegura). Los nombres y el orden de arriba corresponden a la edición más difundida; consulta siempre la lista vigente en owasp.org. Lo que no cambia es la ingeniería: las mitigaciones concretas de la tercera columna siguen siendo las mismas.
busqueda.service.tsINCORRECTO
async buscar(q: string, orden: string) {
  // Inyección SQL clásica: el ORM no te protege si escribes el SQL a mano.
  //   q = "' OR 1=1 --"       → devuelve todo
  //   q = "'; DROP TABLE …"   → según permisos del usuario de BD
  return this.em.getConnection().execute(
    `SELECT * FROM factura WHERE concepto LIKE '%${q}%' ORDER BY ${orden}`,
  );
}
// Variante NoSQL: si el DTO no está validado, el cliente envía
//   { "email": { "$ne": null }, "password": { "$ne": null } }
// y el filtro deja de comparar valores para comparar operadores.
async login(dto: any) {
  return this.coleccion.findOne({ email: dto.email, password: dto.password });
}
busqueda.service.tsCORRECTO
// ORDER BY no se puede parametrizar: va por lista blanca, nunca interpolado.
const ORDENES = { fecha: 'creado_en', total: 'total' } as const;
async buscar(q: string, orden: keyof typeof ORDENES = 'fecha') {
  // Opción 1 · QueryBuilder: el ORM parametriza por ti.
  return this.em.createQueryBuilder(Factura)
    .where({ concepto: { $like: `%${q}%` } })
    .orderBy({ [ORDENES[orden]]: 'DESC' })
    .limit(50)
    .getResultList();
}
async buscarConSqlCrudo(q: string) {
  // Opción 2 · si necesitas SQL nativo, SIEMPRE con marcadores.
  // El driver envía valor y sentencia por separado: el contenido
  // jamás se interpreta como código.
  return this.em.getConnection().execute(
    'SELECT * FROM factura WHERE concepto LIKE ? LIMIT 50', [`%${q}%`],
  );
}
// Y en main.ts, la defensa que corta la inyección NoSQL de raíz:
// new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true,
//                      transform: true })
// con un DTO donde email y password son @IsString(): un objeto
// { $ne: null } falla la validación antes de llegar al servicio.
seguridad/url-externa.ts · mitigación de SSRF
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
const DOMINIOS_PERMITIDOS = new Set(['cdn.example.com', 'images.example.com']);
/** Bloquea loopback, enlaces locales (metadatos de la nube) y rangos privados. */
function esInterna(ip: string): boolean {
  if (isIP(ip) === 6) return ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd');
  const [a, b] = ip.split('.').map(Number);
  return a === 127 || a === 10 || a === 0
      || (a === 172 && b >= 16 && b <= 31)
      || (a === 192 && b === 168)
      || (a === 169 && b === 254);        // 169.254.169.254 = metadatos AWS/GCP
}
export async function descargarSeguro(entrada: string): Promise<Response> {
  const url = new URL(entrada);                       // lanza si no es válida
  if (url.protocol !== 'https:') throw new BadRequestException('Solo https');
  if (!DOMINIOS_PERMITIDOS.has(url.hostname)) throw new BadRequestException('Dominio no permitido');
  const { address } = await lookup(url.hostname);
  if (esInterna(address)) throw new BadRequestException('Destino no permitido');
  return fetch(url, {
    redirect: 'manual',                 // una redirección a 127.0.0.1 saltaría todo lo anterior
    signal: AbortSignal.timeout(5000),
  });
}
// Honestidad técnica: entre el lookup y la conexión, el DNS puede cambiar la
// respuesta (DNS rebinding). La defensa completa exige un agente HTTP que
// valide la IP en el momento de conectar, y restricciones de salida en la red.
// La lista blanca de dominios es lo que de verdad cierra el vector.

12.10 Cabeceras, CORS, cookies y CSRF

12.10.1 helmet y las cabeceras que importan

CabeceraQué haceNota práctica
Content-Security-PolicyDeclara de qué orígenes puede cargarse script, estilo, imagen o fuente. Es la defensa más fuerte contra XSSLa única que requiere trabajo real. En una API JSON basta con default-src 'none'; en el SPA, evita unsafe-inline usando nonces
Strict-Transport-SecurityObliga al navegador a usar HTTPS durante el tiempo indicado, incluso si el usuario escribe http://Un año, includeSubDomains. Cuidado: no se puede revertir rápido; prueba antes con un max-age corto
X-Content-Type-Options: nosniffImpide que el navegador adivine el tipo de contenido e interprete como script algo que no lo esSin efectos secundarios. Ponla siempre
Referrer-PolicyControla cuánta URL se envía al navegar a otro sitiono-referrer o strict-origin-when-cross-origin: evita filtrar tokens que viajen en la URL
Permissions-PolicyDesactiva APIs del navegador (cámara, micrófono, geolocalización) para la página y sus iframeshelmet no la fija: se añade a mano, por ejemplo camera=(), microphone=(), geolocation=()
X-Frame-Options / frame-ancestorsImpide que tu página se incruste en un iframe (clickjacking)frame-ancestors 'none' en la CSP es la versión moderna y tiene prioridad
main.ts · configuración de seguridad de arranque
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
const app = await NestFactory.create(AppModule, { bodyParser: false });
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'none'"],          // una API no carga recursos: prohíbelo todo
      frameAncestors: ["'none'"],
      baseUri: ["'none'"],
      formAction: ["'none'"],
    },
  },
  hsts: { maxAge: 31_536_000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'no-referrer' },
  crossOriginResourcePolicy: { policy: 'same-site' },
}));
app.use((_req, res, next) => {
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
  next();
});
app.use(cookieParser());
app.use(json({ limit: '100kb' }));      // límite de payload: DoS barato si falta
app.use(urlencoded({ extended: false, limit: '100kb' }));
app.enableCors({
  origin: config.getOrThrow<string>('CORS_ORIGINS').split(','),  // lista explícita
  credentials: true,                    // imprescindible para la cookie de refresh
  methods: ['GET', 'POST', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
  maxAge: 86_400,                       // cachea el preflight 24 h
});
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,              // descarta propiedades no declaradas en el DTO
  forbidNonWhitelisted: true,   // …y además responde 400 si llegan
  transform: true,              // convierte tipos según el DTO
  transformOptions: { enableImplicitConversion: false },
}));
await app.listen(3000);

12.10.2 CORS explicado de verdad

CORS no es un mecanismo de seguridad de tu servidor: es una relajación controlada de la política del mismo origen que impone el navegador. Sin CORS, un JavaScript de malicioso.com no puede leer la respuesta de api.example.com. Con CORS, tú decides qué orígenes sí pueden. Tres consecuencias que se malinterpretan constantemente:

main.tsINCORRECTO
// 1) Combinación imposible: el navegador la rechaza y además,
//    si funcionara, cualquier web robaría sesiones autenticadas.
app.enableCors({ origin: '*', credentials: true });
// 2) Reflejo ciego del origen: equivale a permitir a todo el mundo,
//    pero SÍ funciona con credenciales. Es el peor caso posible.
app.enableCors({ origin: (o, cb) => cb(null, true), credentials: true });
// 3) Comprobación por substring: 'https://miapp.com.atacante.io'
//    contiene 'miapp.com' y pasa el filtro.
app.enableCors({ origin: (o, cb) => cb(null, o?.includes('miapp.com')) });
main.tsCORRECTO
const PERMITIDOS = new Set(
  config.getOrThrow<string>('CORS_ORIGINS').split(','),   // 'https://app.example.com'
);
app.enableCors({
  origin: (origen, cb) => {
    // Sin cabecera Origin = misma web, curl o app móvil: no es CORS.
    if (!origen) return cb(null, true);
    // Igualdad EXACTA contra un conjunto cerrado. Nada de includes ni regex.
    return PERMITIDOS.has(origen)
      ? cb(null, true)
      : cb(new ForbiddenException('Origen no permitido'), false);
  },
  credentials: true,
  maxAge: 86_400,
});
// Nest añade Vary: Origin al reflejar el origen, necesario para las cachés.

12.10.3 Cookies seguras y CSRF

AtributoEfectoValor recomendado
HttpOnlyJavaScript no puede leerla ni escribirlaSiempre, salvo en la cookie del token anti-CSRF, que debe ser legible
SecureSolo se envía por HTTPSSiempre en producción
SameSite=StrictNo se envía en ninguna navegación que venga de otro sitioCookies de sesión y de refresh. Efecto secundario: al llegar desde un enlace externo, el usuario aparece deslogueado
SameSite=LaxSe envía en navegaciones de nivel superior con GET, no en POST ni en subrecursosValor por defecto de los navegadores actuales; buen equilibrio
SameSite=NoneSe envía siempre; obliga a SecureSolo si de verdad necesitas contexto cross-site; exige anti-CSRF explícito
Path / DomainLimitan a dónde se envíaPath=/auth/refresh; nunca amplíes Domain a un dominio padre compartido con otros equipos
Prefijo __Host-El navegador exige Secure, Path=/ y sin DomainImpide que un subdominio comprometido sobrescriba la cookie
¿Necesitas protección CSRF? Depende, y la respuesta es binaria

La necesitas si el servidor autentica con algo que el navegador adjunta automáticamente: cookies de sesión o autenticación básica. El ataque consiste en que malicioso.com envíe un formulario a tu API; el navegador añade la cookie y la operación se ejecuta.

No la necesitas si autenticas solo con Authorization: Bearer: esa cabecera hay que ponerla explícitamente con JavaScript, y para ello haría falta saltarse CORS.

En el modelo híbrido de este capítulo, la única superficie CSRF es /auth/refresh. Se protege con SameSite=Strict más un token. Nota importante: el paquete csurf está deprecado y sin mantenimiento; usa csrf-csrf en Express o @fastify/csrf-protection en Fastify.

12.11 Protección de la API

app.module.ts · rate limiting por capas con @nestjs/throttler
ThrottlerModule.forRoot({
  // Varias ventanas simultáneas: frena tanto la ráfaga como el goteo lento.
  throttlers: [
    { name: 'corto', ttl: 1_000,  limit: 5   },     // 5 por segundo
    { name: 'medio', ttl: 60_000, limit: 100 },     // 100 por minuto
    { name: 'largo', ttl: 3_600_000, limit: 1_000 },
  ],
  // Con varias instancias, el contador DEBE ser compartido: en memoria
  // cada réplica cuenta por su cuenta y el límite real se multiplica.
  storage: new ThrottlerStorageRedisService(redis),
  // Detrás de un proxy hay que configurar 'trust proxy' en Express, o
  // req.ip será siempre la IP del balanceador y bloquearás a todo el mundo.
  getTracker: (req) => req.ips?.[0] ?? req.ip,
}),
// providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }]
// Y en el login, un límite mucho más estricto que el global:
@Throttle({ corto: { limit: 3, ttl: 60_000 } })
@Post('login') login() { … }

12.12 Secretos y configuración

config/env.validation.ts · que no arranque si la configuración es insegura
import * as Joi from 'joi';
export const esquemaEnv = Joi.object({
  NODE_ENV: Joi.string().valid('development', 'test', 'production').required(),
  // Sin valor por defecto: un secreto por defecto acaba SIEMPRE en producción.
  // La longitud mínima evita el clásico JWT_SECRET=secret.
  JWT_ACCESS_SECRET: Joi.string().min(32).required(),
  JWT_ISSUER: Joi.string().uri().required(),
  JWT_AUDIENCE: Joi.string().required(),
  DATABASE_URL: Joi.string().uri().required(),
  CORS_ORIGINS: Joi.string().required(),
  // Reglas que solo aplican en producción.
  COOKIE_SECURE: Joi.boolean().when('NODE_ENV',
    { is: 'production', then: Joi.valid(true).required(), otherwise: Joi.optional() }),
}).unknown(true);
// ConfigModule.forRoot({ isGlobal: true, validationSchema: esquemaEnv,
//                        validationOptions: { abortEarly: false } })
//
// Fallar al arrancar es lo correcto: un despliegue que no sube es un
// incidente de cinco minutos; uno que sube inseguro puede durar meses.

12.13 Registro y respuesta a incidentes

Qué registrar

  • Inicios de sesión correctos y fallidos, con IP y user-agent.
  • Cambios de contraseña, de correo, de rol y de permisos.
  • Denegaciones de autorización (403): un pico es un ataque en curso.
  • Reutilización de refresh tokens y cierres de sesión globales.
  • Accesos a datos sensibles y exportaciones masivas.
  • Identificador de correlación por petición, para reconstruir el hilo completo.

Qué no registrar jamás

  • Contraseñas, ni siquiera las fallidas (suelen ser la de otra cuenta con un dedazo).
  • Tokens, cookies de sesión o cabeceras Authorization completas.
  • Números de tarjeta, CVV o datos bancarios.
  • Datos personales sin necesidad demostrable (RGPD: minimización).
  • El cuerpo completo de las peticiones «por si acaso».
  • Entradas del usuario sin sanear saltos de línea: permiten falsificar entradas de log.
logging/redaccion.ts · pino con redacción automática
// La redacción se configura UNA vez y protege todo el sistema. Confiar en que
// nadie registre por error un objeto entero es una estrategia perdedora.
export const opcionesPino = {
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie',
            'req.body.password', 'req.body.newPassword',
            'res.headers["set-cookie"]', '*.token', '*.refreshToken',
            '*.passwordHash', '*.tarjeta'],
    censor: '[REDACTADO]',
  },
  // Nunca serialices la petición entera: elige los campos.
  serializers: {
    req: (req) => ({ id: req.id, method: req.method, url: req.url, ip: req.ip }),
  },
};
// Evento de auditoría: estructurado, consultable y con actor explícito.
this.logger.warn({
  evento: 'AUTH_LOGIN_FALLIDO',
  usuarioId: usuario?.id ?? null,      // null si no existe: no filtres el email
  ip: ctx.ip, correlacionId: ctx.correlacionId,
  intentosPrevios: 4,
}, 'Intento de inicio de sesión fallido');
Plan mínimo ante una filtración

1. Contener. Revocar tokens y sesiones, rotar secretos y claves, cerrar el vector si se conoce. Antes de investigar: cada minuto cuenta.

2. Preservar evidencias. Copia de los logs antes de que roten o los borre el atacante. No reinstales la máquina comprometida hasta tener la imagen.

3. Evaluar el alcance. Qué datos, de cuántas personas, durante cuánto tiempo. Aquí es donde el registro de auditoría vale su peso en oro; sin él, la respuesta honesta es «no lo sabemos», que legalmente es la peor.

4. Notificar. El RGPD obliga a comunicar a la autoridad de control (en España, la AEPD) en un plazo de 72 horas desde que se tiene constancia, y a informar a las personas afectadas si el riesgo para sus derechos es alto.

5. Corregir y aprender. Post mortem sin buscar culpables, con acciones concretas y fecha. Un incidente que no cambia nada del sistema se repetirá.

12.14 Cumplimiento y datos personales

12.15 Errores comunes y cómo solucionarlos

Síntoma o errorCausa realSolución
El login devuelve 401 sin llegar al controladorGuard global de JWT activo y el endpoint de login no está marcado como excepciónAñadir @Public() y comprobar que el guard usa getAllAndOverride sobre handler y clase
Un token manipulado se acepta como válidoVerificación sin fijar el algoritmo: alg: none o confusión RS256/HS256algorithms: ['RS256'] en la verificación, más issuer y audience
Los tokens de staging funcionan en producciónMismo secreto en ambos entornos o falta de aud/issUn secreto distinto por entorno y verificación estricta de emisor y audiencia
JWT_SECRET por defecto en producciónValor de respaldo en el código (process.env.X ?? 'secret')Validar la configuración al arrancar con getOrThrow y esquema Joi; sin valores por defecto
El navegador bloquea las peticiones por CORS pese a «permitir todo»origin: '*' junto a credentials: true: combinación prohibida por la especificaciónLista blanca de orígenes exactos con credentials: true
Un usuario ve datos de otro clienteIDOR: se carga por el id que envía el cliente sin filtrar por propietario o tenantIncluir la propiedad en la consulta y activar un filtro global de MikroORM como red de seguridad
Un usuario despedido sigue entrandoTokens sin expiración o de vida muy larga y sin mecanismo de revocaciónexp de 15 minutos, tokenVersion por usuario y revocación de refresh tokens
Una cuenta robada sigue comprometida tras cambiar la contraseñaEl cambio no invalida sesiones ni refresh tokens existentesIncrementar tokenVersion y revocar todos los refresh en la misma transacción
Sesiones cerradas al azar en el SPARefrescos concurrentes con el mismo token interpretados como reutilizaciónSerializar el refresco en el cliente con shareReplay y añadir ventana de gracia en el servidor
Las contraseñas se rompen en horas tras una filtraciónHash rápido (SHA-256, MD5) o sin salArgon2id con parámetros calibrados; forzar cambio de contraseña de todos los usuarios afectados
La API se congela con varios logins simultáneosHash síncrono (bcrypt.hashSync o bcryptjs) bloqueando el event loopVersión asíncrona de un binding nativo; ajustar UV_THREADPOOL_SIZE si hace falta
Un atacante sabe qué correos están registradosMensajes o tiempos distintos entre usuario inexistente y contraseña incorrectaMensaje único, hash señuelo para igualar tiempos y rate limiting
Aparecen tokens y contraseñas en los logsSe serializa la petición completa o el objeto de error sin filtrarRedacción declarativa en el logger y serializadores que eligen campos explícitos
El rate limit no frena nada en producciónContador en memoria con varias réplicas, o req.ip es la IP del balanceadorAlmacenamiento compartido en Redis y trust proxy configurado correctamente
La respuesta incluye passwordHashSe devuelve la entidad de MikroORM directamenteDTOs de salida explícitos o ClassSerializerInterceptor con @Exclude()

12.16 Buenas y malas prácticas

Haz esto

  • Cerrado por defecto. Guard global de autenticación y excepciones explícitas con @Public().
  • Argon2id (o bcrypt con coste 12+) siempre asíncrono, con rehash al iniciar sesión.
  • Access token corto en memoria y refresh rotativo en cookie HttpOnly.
  • Fija el algoritmo, el emisor y la audiencia en cada verificación de JWT.
  • Filtra por propietario en la consulta, no con un if posterior.
  • Valida toda la entrada con whitelist y forbidNonWhitelisted.
  • DTOs de salida: la entidad nunca sale por el cable tal cual.
  • Configuración validada al arrancar: si falta un secreto, que no arranque.
  • Registra los eventos de seguridad y ponles alertas con umbral.
  • Escribe tests de autorización: un test por endpoint que compruebe el 403 del usuario ajeno.

Evita esto

  • Inventar criptografía o «mejorar» un algoritmo estándar. Usa librerías auditadas.
  • Guardar el JWT en localStorage por comodidad y olvidarte del XSS.
  • jwt.decode() para tomar decisiones: no verifica absolutamente nada.
  • Datos personales dentro del token: el payload es legible por cualquiera.
  • Confiar en el userId que envía el cliente en el cuerpo o en la query.
  • Comparar secretos con === en lugar de timingSafeEqual.
  • CORS con reflejo ciego del origen o comprobaciones por includes.
  • Mensajes de error distintos según exista o no el usuario.
  • Seguridad solo en el frontend: ocultar un botón no protege nada.
  • Dejar Swagger, el modo debug o las trazas completas accesibles en producción.

12.17 Preguntas frecuentes

¿JWT o sesiones? Dame una respuesta directa.
Si tienes una SPA y un backend propio, usa el modelo híbrido: access token JWT corto en memoria y refresh opaco en cookie HttpOnly. Si tu aplicación es un monolito con plantillas renderizadas en el servidor, la sesión clásica es más simple y más segura. El JWT puro sin estado solo compensa cuando varios servicios independientes deben validar sin consultar a un emisor central; en cuanto necesitas revocación inmediata, vuelves a tener estado y pierdes su única ventaja real.
¿Es seguro guardar el token en localStorage? He leído las dos cosas.
Es seguro mientras no tengas XSS, lo cual es una garantía que nadie puede dar. Cualquier script inyectado —incluida una dependencia npm comprometida— lee localStorage y exfiltra el token en una línea. La cookie HttpOnly no elimina el problema (con XSS el atacante puede hacer peticiones autenticadas desde el navegador de la víctima), pero impide que se lleve el token a su propia máquina y lo use durante días. La diferencia entre «te roban una sesión mientras la víctima tiene la pestaña abierta» y «te roban una credencial de 30 días» es enorme.
¿Puedo revocar un JWT?
No, no de forma nativa: un token firmado es válido hasta que expira porque la verificación es puramente criptográfica y no consulta nada. Lo que puedes hacer es añadir estado: lista de jti revocados en Redis con TTL igual al tiempo restante, contador tokenVersion por usuario, o una marca temporal de «credenciales cambiadas en». Todas funcionan y todas convierten tu JWT en algo con estado. Por eso la práctica estándar es hacer el access token tan corto que la revocación se aplique de facto en el siguiente refresco.
¿Bcrypt está obsoleto?
No. Está superado por Argon2id, que es preferible en proyectos nuevos por su coste en memoria, pero bcrypt con un factor de coste actual (12 o superior) sigue siendo una elección defendible y OWASP lo mantiene como opción válida. Lo que sí es obsoleto es MD5, SHA-1 y cualquier hash rápido sin sal. Si ya tienes millones de hashes bcrypt, migrar no es urgente: se hace de forma transparente rehasheando en el siguiente inicio de sesión de cada usuario.
¿Necesito protección CSRF si uso JWT?
Si el token viaja exclusivamente en la cabecera Authorization, no: el navegador no añade esa cabecera por su cuenta, así que un formulario de otro sitio no puede autenticar la petición. Si guardas el JWT en una cookie, sí la necesitas, porque entonces el navegador lo envía automáticamente y estás exactamente en el escenario clásico de CSRF. En el modelo híbrido, la única superficie es el endpoint de refresco.
¿Cuánto debe durar un access token?
Entre 5 y 15 minutos en la mayoría de aplicaciones. El razonamiento es de riesgo: es el tiempo durante el que un token robado sigue sirviendo. Alargarlo a horas para «no molestar al usuario» es innecesario, porque la comodidad la aporta el refresh token, que se renueva de forma transparente. En entornos de riesgo alto (banca, sanidad) se baja a 2–5 minutos y se añade comprobación de revocación por petición.
¿Debo cifrar el contenido del JWT?
Casi nunca. Si necesitas confidencialidad, la respuesta correcta suele ser no meter ese dato en el token: pon un identificador y consulta el resto en el servidor. Si aun así lo necesitas —por ejemplo, un token que atraviesa sistemas de terceros—, existe JWE (RFC 7516), que cifra el payload. A cambio, el token crece, deja de ser inspeccionable para depurar y añade gestión de claves de cifrado además de las de firma.
¿HS256 o RS256?
HS256 si el mismo servicio emite y verifica: es más rápido y no hay claves que distribuir. RS256 o ES256 en cuanto haya un segundo consumidor, porque solo necesita la clave pública y, aunque se filtre, no puede emitir tokens. ES256 es preferible a RS256 en sistemas nuevos: firmas mucho más cortas y verificación más rápida con seguridad equivalente. Con asimétricos, publica el JWKS y usa kid para poder rotar sin cortes.
¿Cómo cierro sesión de verdad con JWT?
Tres acciones combinadas: borrar el access token del cliente (elimina el uso normal), revocar el refresh token en la base de datos (impide obtener nuevos) y, si necesitas efecto inmediato sobre el access token que ya está emitido, añadir su jti a la lista de revocación con TTL igual al tiempo que le queda. Para «cerrar sesión en todos los dispositivos», incrementa tokenVersion del usuario y revoca todas sus familias de refresh.
¿Los roles pueden ir dentro del token?
Pueden, y ahorran una consulta por petición, pero introducen retardo: si degradas a un usuario, su token seguirá diciendo admin hasta que expire. Es aceptable con tokens de 15 minutos en la mayoría de aplicaciones. Cuando no lo sea, comprueba tokenVersion contra la base de datos o cachea el usuario en Redis con TTL corto. Lo que nunca debe ir en el token es una lista de trescientos permisos granulares: hincharías cada petición.
¿CASL o guards con roles a mano?
Empieza con un RolesGuard propio: en una aplicación con tres roles y reglas simples, CASL es maquinaria innecesaria. Migra a CASL cuando aparezcan condiciones sobre el recurso («solo si es suyo y está en borrador»), permisos a nivel de campo o la necesidad de compartir las mismas reglas con el frontend, que es donde CASL brilla porque serializa sus reglas a JSON. El coste es una curva de aprendizaje y un modelo mental que todo el equipo debe compartir.
Mi API es interna y está detrás de una VPN. ¿Puedo relajarme?
No. Es exactamente el razonamiento que hace que un único portátil comprometido con phishing se convierta en acceso total a la organización. El modelo de confianza cero parte de que la red no otorga confianza: cada petición se autentica y se autoriza por sí misma, esté donde esté su origen. La VPN es una capa más de defensa en profundidad, nunca un sustituto de la autenticación.
¿Cada cuánto reviso los parámetros criptográficos?
Una vez al año como rutina, y de inmediato cuando aparezca un aviso relevante. Los puntos a revisar son: parámetros de Argon2 o coste de bcrypt frente al hardware actual, versiones de TLS aceptadas, tamaño y antigüedad de las claves de firma, y dependencias criptográficas con CVE. Añádelo al calendario del equipo como una tarea concreta con responsable; si depende de que alguien se acuerde, no ocurrirá.

12.18 Ejercicios

Nivel 1 · básico

12.1 Implementa HashService con Argon2id, calíbralo en tu máquina hasta situarte entre 250 y 500 ms por verificación y anota los parámetros elegidos junto al razonamiento.

12.2 Escribe el endpoint POST /auth/login de forma que sea imposible distinguir «usuario inexistente» de «contraseña incorrecta», ni por el mensaje ni por el tiempo de respuesta. Demuéstralo midiendo 100 peticiones de cada caso.

12.3 Añade un guard global de JWT con @Public() y localiza en un proyecto existente todos los endpoints que quedarían rotos. ¿Cuántos estaban desprotegidos sin que nadie lo supiera?

Nivel 2 · intermedio

12.4 Implementa el flujo completo de refresh con rotación, familia y detección de reutilización. Escribe un test que simule el robo: rota una vez, reutiliza el token antiguo y comprueba que toda la familia queda revocada.

12.5 Crea un PropietarioGuard genérico y parametrizable que cargue la entidad indicada y verifique la propiedad antes de llegar al controlador. Compáralo con la alternativa de filtrar en la consulta: ¿cuál prefieres y por qué?

12.6 Configura CORS con lista blanca y credenciales, y comprueba con las herramientas de red del navegador qué ocurre exactamente en el preflight desde un origen no autorizado.

12.7 Implementa bloqueo progresivo en el login: retardo creciente por cuenta y bloqueo temporal tras diez fallos, sin permitir que un atacante bloquee cuentas ajenas de forma permanente.

Nivel 3 · avanzado

12.8 Integra CASL con MikroORM: traduce las reglas de lectura a un filtro de consulta con rulesToQuery para que los listados devuelvan únicamente lo accesible, sin filtrar en memoria después.

12.9 Implementa authorization code + PKCE contra un proveedor real, verificando state, nonce, firma del id_token con JWKS y email_verified. Documenta qué ocurre si eliminas cada verificación.

12.10 Audita la API insegura de la solución comentada y entrega un informe con hallazgos clasificados por categoría OWASP, severidad y corrección propuesta.

12.11 Diseña la rotación de claves de firma con RS256: dos claves activas, kid en el header, JWKS publicado y retirada de la antigua sin invalidar ningún token vivo.

Solución comentada · 12.4 · Test del flujo de refresh con rotación
describe('RefreshService · rotación y detección de robo', () => {
  it('rota el token y revoca la familia si se reutiliza el anterior', async () => {
    const usuario = await crearUsuario();
    const ctx = { ip: '10.0.0.1', userAgent: 'test' };
    // 1 · Login: nace la familia F1 con el token RT1.
    const { refreshToken: rt1 } = await service.login(usuario, ctx);
    // 2 · Uso legítimo: RT1 se consume y nace RT2 en la MISMA familia.
    const { nuevoRefresh: rt2 } = await service.rotar(rt1, ctx);
    expect(rt2).not.toBe(rt1);
    expect(await filaDe(rt1)).toMatchObject({ usado: true });
    // 3 · El atacante reutiliza RT1: debe fallar Y disparar la alarma.
    await expect(service.rotar(rt1, ctx)).rejects.toThrow(UnauthorizedException);
    // 4 · Consecuencia clave: RT2, que estaba sano, también queda revocado.
    //     Se sacrifica la sesión legítima porque no podemos saber cuál de
    //     los dos poseedores es el usuario real. Prima contener el robo.
    expect(await filaDe(rt2)).toMatchObject({ revocado: true });
    await expect(service.rotar(rt2, ctx)).rejects.toThrow(UnauthorizedException);
  });
  it('no expulsa al usuario por refrescos concurrentes', async () => {
    const { refreshToken: rt1 } = await service.login(await crearUsuario(), ctx);
    // Sin ventana de gracia en el servidor (o sin serialización en el
    // cliente), este caso genera un falso positivo de robo. Es EL fallo
    // que rompe la rotación en producción.
    const [a, b] = await Promise.allSettled([
      service.rotar(rt1, ctx), service.rotar(rt1, ctx),
    ]);
    expect([a.status, b.status]).toContain('fulfilled');
    expect(await familiaRevocada(rt1)).toBe(false);
  });
});

Detalles que suelen fallar al implementarlo: la rotación debe ir en una transacción con bloqueo de la fila (si no, dos peticiones simultáneas rotan las dos y el sistema pierde la pista); el token se busca siempre por su hash; y la revocación de la familia se hace con una sola sentencia UPDATE, no cargando entidades en memoria.

Solución comentada · 12.5 · Guard de propiedad genérico
export const RECURSO = 'recurso_propio';
export interface OpcionesRecurso {
  entidad: EntityName<object>;
  parametro?: string;      // nombre del parámetro de ruta, por defecto 'id'
  campoDueno?: string;     // propiedad que apunta al usuario, por defecto 'usuario'
}
export const Propietario = (o: OpcionesRecurso) => SetMetadata(RECURSO, o);
@Injectable()
export class PropietarioGuard implements CanActivate {
  constructor(private readonly reflector: Reflector, private readonly em: EntityManager) {}
  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const op = this.reflector.get<OpcionesRecurso>(RECURSO, ctx.getHandler());
    if (!op) return true;
    const req = ctx.switchToHttp().getRequest();
    if (!req.user) throw new UnauthorizedException();
    const id = req.params[op.parametro ?? 'id'];
    const entidad = await this.em.findOne(op.entidad, {
      id,
      [op.campoDueno ?? 'usuario']: req.user.id,   // la propiedad va EN la consulta
    });
    // 404 y no 403: no confirmamos que el recurso exista (ver 12.2.2).
    if (!entidad) throw new NotFoundException();
    req.recurso = entidad;    // se reutiliza en el controlador: una sola lectura
    return true;
  }
}
// @Propietario({ entidad: Factura, campoDueno: 'creadaPor' })
// @Patch(':id') actualizar(@Req() req, @Body() dto: ActualizarFacturaDto) { … }

Comparación honesta. El guard centraliza la regla, la hace declarativa y visible en la firma del endpoint, y evita repetir la comprobación. Sus inconvenientes: añade una consulta si el servicio vuelve a cargar la entidad (por eso se guarda en req.recurso), y solo cubre reglas expresables como «campo X igual al usuario». En cuanto la regla se complica («o pertenece a su departamento, o tiene rol de auditor»), el guard genérico se queda corto y conviene mover la decisión a CASL o al servicio. La opción más robusta sigue siendo filtrar en la capa de datos: no depende de que nadie olvide poner el decorador.

Solución comentada · 12.10 · Auditoría de una API insegura
// ══ CÓDIGO A AUDITAR ══ (cada línea marcada esconde al menos un fallo)
@Controller('usuarios')
export class UsuariosController {
  @Get(':id')                                                        // (1)
  async ver(@Param('id') id: string) {
    return this.em.findOne(Usuario, { id });                         // (2)
  }
  @Post('login')
  async login(@Body() body: any) {                                   // (3)
    const u = await this.em.findOne(Usuario, { email: body.email });
    if (!u) throw new NotFoundException('Usuario no encontrado');    // (4)
    if (createHash('md5').update(body.password).digest('hex') !== u.pwd) { // (5)
      throw new UnauthorizedException('Contraseña incorrecta');      // (4)
    }
    console.log('login ok', body);                                   // (6)
    return { token: sign({ id: u.id, rol: u.rol }, 'secret') };      // (7)(8)
  }
  @Patch(':id')
  async editar(@Param('id') id: string, @Body() body: any) {         // (1)
    const u = await this.em.findOneOrFail(Usuario, { id });
    wrap(u).assign(body);                                            // (9)
    await this.em.flush();
    return u;                                                        // (2)
  }
}

Hallazgos, por categoría OWASP y severidad:

  • (1) A01 · Crítica. Sin autenticación ni autorización: cualquiera lee y edita cualquier usuario. IDOR puro. Corrección: guard global de JWT y filtrado por propiedad o rol.
  • (2) A02/A01 · Alta. Devuelve la entidad completa, incluido el hash de la contraseña y campos internos. Corrección: DTO de salida explícito.
  • (3) A03 · Alta. body: any sin DTO ni validación: abre inyección NoSQL y asignación masiva. Corrección: DTO con class-validator y ValidationPipe con whitelist.
  • (4) A07 · Media. Mensajes distintos y tiempos distintos: enumeración de usuarios. Corrección: mensaje único, hash señuelo y rate limiting.
  • (5) A02 · Crítica. MD5 sin sal: la tabla de contraseñas es de lectura directa con cualquier diccionario. Corrección: Argon2id y forzar cambio de contraseña a todos los usuarios.
  • (6) A09 · Alta. Registra el cuerpo entero, con la contraseña en claro, mediante console.log. Corrección: logger estructurado con redacción.
  • (7) A02 · Crítica. Secreto de firma literal en el código. Corrección: configuración validada, secreto por entorno y rotación tras el incidente.
  • (8) A07 · Alta. Token sin exp, iss, aud ni jti: válido para siempre e irrevocable. Corrección: 15 minutos, claims completos y refresh con rotación.
  • (9) A01 · Crítica. Asignación masiva: {"rol":"admin"} en el cuerpo escala privilegios. Corrección: asignar campos explícitos desde un DTO cerrado.

Cómo entregar la auditoría: cada hallazgo con identificador, categoría OWASP, severidad (basada en impacto y facilidad de explotación), prueba de concepto reproducible y corrección concreta. Ordena por severidad, no por orden de aparición en el fichero, y separa lo que hay que arreglar hoy de lo que puede esperar al siguiente ciclo.

12.19 Resumen del capítulo

  • Identificación, autenticación y autorización son tres pasos distintos. 401 es «no sé quién eres», 403 es «sé quién eres y no puedes», y a veces la respuesta correcta es un 404 deliberado.
  • Las contraseñas se guardan con hash lento y sal (Argon2id preferentemente), siempre de forma asíncrona, con parámetros calibrados y migración de coste al iniciar sesión.
  • Mensajes y tiempos idénticos en login, registro y recuperación: si no, tu API es un directorio de usuarios.
  • El payload de un JWT no está cifrado. La firma da integridad y autenticidad, nunca confidencialidad.
  • Fija siempre el algoritmo, el emisor y la audiencia al verificar. Nunca dejes que el token decida cómo se valida.
  • Un JWT no se puede revocar sin añadir estado. Vida corta más refresh con rotación y detección de reutilización es la respuesta práctica.
  • Access token en memoria, refresh en cookie HttpOnly: reparte el riesgo entre XSS y CSRF de la forma menos mala.
  • Authorization code + PKCE es el único flujo correcto para SPAs y móviles; implicit y password grant están descartados.
  • Filtrar en la capa de datos es más seguro que comprobar después. Los filtros globales de MikroORM son la red que salva el día que alguien olvide el if.
  • La autorización del frontend es cosmética. Toda regla debe existir en el servidor, y el servidor debe ser su única fuente de verdad.
  • Defensa en profundidad: cabeceras, CORS estricto, rate limiting, validación, límites de tamaño y auditoría. Cada capa asume que la anterior falló.
  • Los secretos nunca van al repositorio ni al bundle del frontend, y si se filtran, se rotan antes que nada.

12.20 Recursos adicionales

Siguiente paso La seguridad que no se prueba se degrada en silencio: un refactor quita un guard, un endpoint nuevo olvida el decorador y nadie se entera hasta que alguien lo explota. El capítulo 13 aborda testing, logging y observabilidad, incluidos los tests de autorización que convierten todo lo anterior en una garantía verificable en cada despliegue.