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 18. Necesitamos que robar una sesión no sea suficiente
La cookie se filtró: ¿qué puede hacer el atacante? ¿puedes revocarla?
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:
Originpregunta,Access-Control-Allow-*responde — es el navegador, no tu API
27.4 La explicación visual
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-Afteren 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
- Diseña el flujo de “cerrar sesión en todos mis dispositivos” con la tabla
sessions. - Explica por qué la rotación detecta el robo — qué ven el ladrón y la víctima cuando ambos usan el mismo refresh.
- ¿Por qué CORS no te protege de curl/Postman/scripts? ¿A quién protege?
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 suexp(~15 min, el precio del híbrido). El usuario re-loguea en su dispositivo actual tras confirmar contraseña (operación sensible = re-auth).- 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. - 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.