27  18. Necesitamos que robar una sesión no sea suficiente

La cookie se filtró: ¿qué puede hacer el atacante? ¿puedes revocarla?

NotaEn una frase

El JWT del cap. 15 tiene un agujero: no se puede revocar — quien lo robe lo usa hasta que expire, y “cerrar sesión en todos mis dispositivos” es imposible. Este capítulo añade la capa que falta: sesiones persistidas y revocables, refresh token rotation (que detecta el robo), rate limiting en login, y CORS explicado de verdad.

27.1 El problema

El JWT stateless es escalable pero ciego: el servidor verifica la firma y confía — sin consultar nada. Eso significa que no hay interruptor: el token robado vale hasta su exp, el logout no invalida nada en el servidor, y “cerrar todas las sesiones” (tu laptop robada) no existe como operación. Además el login es el endpoint más atacado del mundo: sin límite de intentos, la fuerza bruta prueba contraseñas a máquina.

Una firma (HMAC) prueba que un mensaje es auténtico y no fue tocado: se calcula con un secreto sobre el contenido. Los webhooks la usan para que verifiques “esto realmente vino de Stripe”.

La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.

Una sesión es la conversación continuada entre tú y el servidor: HTTP no recuerda nada entre requests, así que la sesión (vía cookie o token) es el “pulso de mano” que te identifica en cada llamada.

Un token es una credencial portable: una cadena que dice quién eres y hasta cuándo. El servidor la emite tras el login; el cliente la presenta en cada request en vez de la contraseña.

Un JWT (JSON Web Token) es un token firmado: el servidor lo genera con su secreto y puede verificarlo sin consultar la BD. Ojo: está firmado, no cifrado — cualquiera puede leer su contenido, pero no modificarlo.

27.2 Cómo lo resuelve un equipo

El diseño que la industria convergió — híbrido:

  • Access token: JWT stateless, vida corta (15 min). Escala gratis, no se consulta nada — y si se roba, vive poco.

Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.

  • Refresh token: opaco (no JWT, un string aleatorio), vida larga (30 días), guardado en la BD. Sirve solo para pedir access nuevos — y como vive en tabla, se puede DELETE = revocación real.

Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.

  • Rotación: cada vez que el refresh se usa, se emite uno nuevo y el viejo muere. Si un token ya usado reaparece → alguien lo robó → se invalida la sesión entera (detección, no solo expiración).
  • Rate limiting en /auth/*: 5 intentos por minuto por IP — la fuerza bruta se vuelve impráctica.
  • CORS: el navegador pregunta “¿este origen puede llamarte?” y el servidor responde con headers — no es autenticación, es política del navegador.

27.3 Conceptos nuevos

  • U JWT stateless vs sesión en BD: una query a cambio de revocación real
  • U “Cerrar sesión en todos mis dispositivos”: imposible con JWT puro, trivial con tabla de sesiones
  • U Refresh rotation + reuse detection: el token viejo reutilizado delata al ladrón
  • U Rate limiting: el 429 como amigo — fuerza bruta vuelta impráctica
  • U CORS en serio: Origin pregunta, Access-Control-Allow-* responde — es el navegador, no tu API

27.4 La explicación visual

sequenceDiagram
  participant C as Cliente
  participant S as Servidor
  participant DB as sessions table
  Note over C,S: login → access(15m) + refresh(30d, en BD)
  C->>S: request con access expirado
  S-->>C: 401 expired
  C->>S: POST /auth/refresh con refresh cookie
  S->>DB: ¿este refresh existe y no se usó?
  DB-->>S: válido → lo marca usado
  S-->>C: nuevo access + nuevo refresh (rotación)
  Note over S,DB: si el refresh YA usado reaparece<br/>→ robo detectado → DELETE sesión entera

27.5 Implementación

La tabla de sesiones y el refresh con rotación — la parte que hace revocable lo que el JWT no puede:

CREATE TABLE sessions (
  id           BIGSERIAL PRIMARY KEY,
  user_id      BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  refresh_hash TEXT NOT NULL UNIQUE,     -- hash del refresh, no el token
  expires_at   TIMESTAMPTZ NOT NULL,
  used_at      TIMESTAMPTZ,              -- rotación: cuándo se canjeó
  revoked_at   TIMESTAMPTZ,              -- logout / "cerrar todas"
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

¿Por qué refresh_hash? Misma lección del cap. 14: los tokens también son secretos — si la tabla sessions se filtra, los refresh en claro son sesiones regaladas. Se guarda el hash; lo que viaja al cliente es el token opaco.

Un hash es una función de un solo sentido: contraseña →$2b\(10\)…`. No se puede revertir — por eso las contraseñas se hashean, no se cifran. bcrypt es lento a propósito: fuerza bruta cara para el atacante.

Un secreto es un dato que no puede publicarse: contraseñas de BD, llaves de API, el secreto que firma los JWT. Viven en .env (fuera de git) o en un secret manager — nunca en el código ni en el repo.

El refresh con rotación + detección de reuso (patrón, válido en los tres stacks):

def refresh_session(refresh_token: str):
    row = conn.execute(
        "SELECT * FROM sessions WHERE refresh_hash = %s",
        (hash(refresh_token),),
    ).fetchone()

    if not row:
        raise UnauthorizedError("invalid session")
    if row["used_at"] or row["revoked_at"] or row["expires_at"] < now():
        # a used/expired token came back — someone stole it
        revoke_session_family(row["user_id"])     # kill the whole session
        raise UnauthorizedError("session reuse detected")
    mark_used(row["id"])                          # this refresh dies now
    return issue_access(row["user_id"]), issue_refresh(row["user_id"])

Y el rate limit del login — en los tres stacks es un middleware delante del handler (mismo patrón del cap. 16):

Un middleware es un filtro en la cadena del request: pasa por él antes de llegar a tu handler. Auth es middleware — “verifica el token” vive una vez y protege todas las rutas, no se copia en cada una.

POST /auth/login → rate_limit(5 req/min per IP) → handler
  6th attempt → 429 {"error": {"code": "RATE_LIMITED"}}
  + Retry-After: 42

CORS sin magia: tu frontend corre en localhost:5173 y tu API en :8000 — orígenes distintos. El navegador manda Origin: y, para requests “no simples”, primero un OPTIONS (el preflight): “¿puedo hacer POST con Content-Type: json desde este origen?”. El servidor responde con headers o el navegador bloquea la respuesta aunque el request llegó:

Access-Control-Allow-Origin: http://localhost:5173   ← ese origen exacto, no *
Access-Control-Allow-Credentials: true               ← porque viajan cookies
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE

Detalle crítico: Allow-Origin: * + Allow-Credentials: true es combinación prohibida por el estándar (el navegador la rechaza) — con cookies siempre se nombra el origen exacto. Y CORS no protege tu API de llamadas fuera del navegador (curl no pregunta) — protege al usuario de sitios malignos.

Una cookie es un dato que el servidor pone en tu navegador y el navegador devuelve en cada request. httpOnly = JavaScript no puede leerla (el XSS no la roba); Secure = solo viaja por HTTPS.

Implementa autenticación revocable para mi API [Express / FastAPI / Go]:
access JWT de 15min + refresh opaco de 30d persistido en tabla sessions
(hash del token, used_at, revoked_at, expires_at), endpoint
POST /auth/refresh con ROTATION (cada uso emite refresh nuevo e invalida
el viejo) y reuse detection (si un refresh usado reaparece, revoca la
sesión entera), POST /auth/logout que revoque, "cerrar todas las sesiones"
que revoque todas las del usuario, rate limit de 5 req/min en /auth/*,
y config CORS para mi frontend en localhost:5173 con credentials. Dame
el esquema de sessions, los endpoints, y explícame el flujo de rotación.

27.6 ¿Por qué este híbrido y no “solo JWT” o “solo sesiones”?

Lo único que necesitas llevarte: el access stateless hace el trabajo pesado (millones de requests sin tocar la BD) y el refresh persistido da el control (revocación real, detección de robo). JWT puro no puede desloguear; sesión pura paga una consulta por request. El híbrido cobra la consulta una vez cada 15 minutos — el precio justo por el botón “cerrar sesión en todos mis dispositivos”.

Alternativa Qué es Qué te cuesta Cuándo elegirla
JWT puro Todo stateless Sin revocación real APIs internas, tokens de corta vida
Sesión opaca en BD Una query por request El lookup siempre Apps donde el control manda
Access+refresh híbrido Stateless + tabla para refresh La complejidad de este capítulo El default de apps serias
Refresh en Redis Lookup más barato que Postgres Otra pieza de infra Sesiones calientes a escala
Todo tercero (Auth0, Clerk, Supabase Auth) Ellos lo operan Costo + lock-in + tu user table queda afuera MVP donde auth no es tu diferencial

27.7 Errores comunes

Error Por qué pasa Fix
Refresh en claro en la BD “Es solo un token” — es la sesión refresh_hash, como las contraseñas
Refresh JWT stateless también Recién reinventaste el problema Refresh opaco persistido — eso es lo que se revoca
Sin rotación El refresh robado vale 30 días Rotación + reuse detection
Allow-Origin: * con cookies Funciona en el tutorial Inválido con credentials; nombra el origen
Rate limit solo en login /refresh y /register también se abusan Todo /auth/* limitado
CORS “arreglado” con * y silencio No entendiste que es política del navegador Entender qué pregunta el preflight

27.8 Buenas prácticas

  • Refresh opaco, aleatorio, hasheado, rotable — las cuatro propiedades que hacen sesión seria.
  • Reuse detection = alarma gratis: un refresh usado que reaparece es un robo; revoca la familia y marca el evento en logs.

Un log es el diario del programa: qué pasó, cuándo, con qué request. Estructurado = en JSON con campos (request_id, user_id), para filtrar por máquina y no con los ojos.

  • Rate limit por IP y por cuenta — el botnet tiene mil IPs pero la cuenta objetivo es una.
  • Retry-After en el 429 — el cliente bien portado espera lo que le dices.
  • CORS con lista explícita de orígenes — localhost:5173 en dev, tu dominio en prod, nunca * con cookies.

27.9 Ejercicio

  1. Diseña el flujo de “cerrar sesión en todos mis dispositivos” con la tabla sessions.
  2. Explica por qué la rotación detecta el robo — qué ven el ladrón y la víctima cuando ambos usan el mismo refresh.
  3. ¿Por qué CORS no te protege de curl/Postman/scripts? ¿A quién protege?
  1. UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL — todos los refresh del usuario mueren; los access siguen vivos hasta su exp (~15 min, el precio del híbrido). El usuario re-loguea en su dispositivo actual tras confirmar contraseña (operación sensible = re-auth).
  2. El ladrón usa el refresh robado → recibe uno nuevo y el viejo queda used_at. La víctima intenta usar el suyo (el mismo viejo, si fue robado a ella — o el ladrón reusa uno): quien sea segundo dispara “token ya usado” → se revoca la sesión entera → ambos quedan fuera y hay que re-loguear. El robo queda detectado, no solo expirado.
  3. CORS lo enforcea el navegador: curl y scripts de servidor no hacen preflight ni respetan los headers — ven la respuesta igual. CORS protege al usuario con navegador de un sitio maligno que haría requests con sus cookies; no es una frontera de la API sino una política del navegador. Por eso la seguridad real es SameSite+httpOnly+rate limit; CORS es el candado de la vitrina.

27.10 Mini reto

Listar los endpoints donde un 429 debería existir en cualquier API seria.

Los que cuestan recursos o revelan información por repetición:

  • POST /auth/login — fuerza bruta de contraseñas (5/min por IP + por email).
  • POST /auth/register — creación masiva de cuentas.
  • POST /auth/refresh — adivinación de tokens.
  • POST /auth/forgot-password — spam de emails + enumeración de usuarios registrados.
  • POST /auth/verify / códigos OTP — fuerza bruta de códigos de 6 dígitos (aquí el límite debe ser muy bajo: ~5 por código).
  • Endpoints caros: exportaciones, reportes, búsquedas pesadas — cuestan CPU/dinero por llamada.

Patrón: limita donde cada intento cuesta algo tuyo (CPU, email, dinero) o revela algo (¿existe este email? ¿es válido este código?).

27.11 Vocabulario técnico del capítulo

Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.

Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.

Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.

Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.

return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.

localhost significa “esta misma máquina” — es la dirección que tu computadora usa para hablarse a sí misma. Cuando desarrollas, el “servidor” y el “cliente” viven en tu laptop: por eso todo es localhost:3000.

El CPU es el cerebro que ejecuta instrucciones; cada núcleo puede ejecutar una cosa a la vez (por eso importan la concurrencia y los procesos paralelos). “CPU-bound” = el límite es el cálculo, no la espera.

El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.

27.12 Lo que deberías saber hacer ahora