sequenceDiagram
participant C as Cliente
participant S as Servidor
C->>S: POST /auth/login {email, password}
S->>S: bcrypt.compare (cap. 14)
S-->>C: Set-Cookie: token=JWT; HttpOnly; Secure; SameSite
Note over C: el navegador guarda la cookie solo
C->>S: GET /tasks (cookie viaja automática)
S->>S: verifica firma del JWT → extrae sub=user_id
S-->>C: 200 {tasks: [...]}
24 15. Necesitamos que el servidor recuerde quién eres
HTTP no tiene memoria: cada request es un extraño
El login funcionó — y el siguiente request llega como de un extraño: HTTP no tiene memoria. La solución es un token: tras el login el servidor emite una credencial que el cliente presenta en cada request. Este capítulo construye el login de Taskflow: JWT firmado viajando en cookie httpOnly, con access corto + refresh largo.
24.1 El problema
El usuario hizo POST /auth/login con su email y clave — respondiste 200. Ahora pide GET /tasks. ¿Quién es? El request no trae memoria del anterior: HTTP es stateless por diseño (cada petición es independiente — es lo que hace que escale). Algo tiene que decir “soy yo, el del login de hace un segundo”. Dos modelos clásicos:
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
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.
- Sesión con estado: el servidor genera un id (
sess_abc123), lo guarda en una tablasessions, y el cliente lo presenta como una pulsera de discoteca. El servidor consulta la tabla cada vez. - Token sin estado (JWT): el servidor firma una credencial que dice quién eres dentro — el cliente la lleva, el servidor solo verifica la firma. No hay tabla que consultar.
El trade-off real: el JWT no necesita consulta (escala gratis) pero no se puede revocar — quien lo roba lo usa hasta que expire. La sesión en BD se revoca con DELETE, pero cuesta una query por request. El cap. 18 combina ambos; aquí entendemos el JWT primero.
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.
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.
24.2 Cómo lo resuelve un equipo
El flujo completo que construye este capítulo:
24.3 Conceptos nuevos
- U JWT por dentro:
header.payload.signature— firmado, NO cifrado (cualquiera lo lee, nadie lo falsifica) - U Claims:
sub(quién),exp(cuándo muere),iat(cuándo nació) — el payload es público - U Cookie
httpOnlyvslocalStorage: XSS, CSRF,SameSite,Secure - U Access token corto (15min) + refresh token largo (30d): expiración corta sin re-login constante
- TS
jose· Pypython-jose· Gogolang-jwt— claims idénticos en los tres
24.4 La explicación visual
Un JWT real son tres segmentos base64 separados por puntos:
eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiI0MiIsImV4cCI6MTcxNzAwMDAwMH0 . SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJVadQssw5c
└─ header ──────────┘ └─ payload (claims) ──────────────┘ └─ signature ──────────────────────┘
{"alg":"HS256"} {"sub":"42","exp":...,"iat":...} HMAC(secret, header.payload)
Pégalo en jwt.io y lo lees entero — sin la clave. La firma no oculta el contenido; garantiza que nadie lo modificó. Por eso la regla: el payload nunca lleva contraseñas ni datos sensibles — es una tarjeta de identidad legible, no un sobre sellado.
24.5 Implementación
Login que emite el JWT en cookie httpOnly + GET /me que lo verifica:
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.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
// login → set the cookie
app.post("/auth/login", async (req, res) => {
const user = await verifyUser(req.body.email, req.body.password); // cap. 14
if (!user) throw new UnauthorizedError("invalid credentials");
const token = await new SignJWT({}) // payload = claims
.setSubject(String(user.id)) // sub: who
.setIssuedAt() // iat: when born
.setExpirationTime("15m") // exp: when it dies
.sign(secret);
res.cookie("token", token, {
httpOnly: true, // JS can't read it → XSS can't steal it
secure: true, // HTTPS only
sameSite: "lax", // not sent cross-site → CSRF barrier
maxAge: 15 * 60 * 1000,
});
res.json({ user: { id: user.public_id, email: user.email } });
});
// GET /me → verify the token from the cookie
app.get("/me", async (req, res) => {
const { payload } = await jwtVerify(req.cookies.token, secret);
res.json({ userId: payload.sub }); // sub = your identity
});from jose import jwt
SECRET = os.environ["JWT_SECRET"]
@app.post("/auth/login")
def login(body: LoginIn, response: Response):
user = verify_user(body.email, body.password) # cap. 14
if not user:
raise UnauthorizedError("invalid credentials")
token = jwt.encode(
{"sub": str(user["id"]),
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(minutes=15)},
SECRET, algorithm="HS256",
)
response.set_cookie(
"token", token,
httponly=True, secure=True, samesite="lax",
max_age=15 * 60,
)
return {"user": {"id": user["public_id"], "email": user["email"]}}
@app.get("/me")
def me(request: Request):
payload = jwt.decode(request.cookies["token"], SECRET, algorithms=["HS256"])
return {"user_id": payload["sub"]}func login(w http.ResponseWriter, r *http.Request) {
var in struct{ Email, Password string }
json.NewDecoder(r.Body).Decode(&in)
user, err := verifyUser(r.Context(), in.Email, in.Password) // cap. 14
if err != nil || user == nil {
writeError(w, 401, "invalid credentials")
return
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": strconv.FormatInt(user.ID, 10),
"iat": time.Now().Unix(),
"exp": time.Now().Add(15 * time.Minute).Unix(),
})
signed, _ := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
http.SetCookie(w, &http.Cookie{
Name: "token", Value: signed,
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
MaxAge: 15 * 60, Path: "/",
})
writeJSON(w, 200, map[string]any{"user": map[string]any{
"id": user.PublicID, "email": user.Email,
}})
}El detalle clave del diseño: la cookie viaja sola. Con httpOnly, el navegador la adjunta automáticamente a cada request al dominio y ningún JavaScript puede leerla — un script malicioso inyectado (XSS) no puede robar el token. El precio: como viaja automática, un sitio maligno podría hacer que tu navegador la envíe sin saberlo (CSRF) — de ahí sameSite: "lax" y el cap. 18 profundiza.
¿Y el access + refresh? El access de 15 min muere rápido (si lo roban, dura poco); el refresh de 30 días solo sirve para pedir access nuevos y vive en otra cookie. El usuario no re-loguea cada 15 min, pero el token robado caduca rápido. El cap. 18 le suma revocación real.
Implementa login con JWT en mi API [Express / FastAPI / Go]. Requisitos:
token con claims sub (user id), iat, exp=15min, firmado HS256 con
JWT_SECRET de variable de entorno, enviado en cookie httpOnly + Secure
+ SameSite=lax (NO en el body ni en localStorage), endpoint GET /me que
verifique la cookie y devuelva el usuario. Explica: por qué httpOnly y
no localStorage, qué puede leer un atacante del JWT, y cómo añadirías
un refresh token de 30 días en cookie separada.
24.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| JWT en localStorage | Es lo que enseña el tutorial viejo | Cookie httpOnly |
| Datos sensibles en el payload | “Está cifrado” — NO, está firmado | El payload es público: solo sub/claims inocuos |
exp de días en el access token |
“Para que no re-loguee” | Access corto + refresh largo |
| Token sin verificar firma real | Decodificar sin verify — te puedes inventar el payload |
Siempre jwtVerify/decode con la clave |
JWT_SECRET débil en el repo |
HS256 se rompe por fuerza bruta si el secreto es “1234” | Secreto aleatorio largo en env/secret manager |
24.8 Buenas prácticas
- Claims mínimos:
sub,exp,iatbastan — roles y permisos se consultan frescos (cap. 17) o van en claims que cambian poco. Securesiempre en prod — sin HTTPS la cookie viaja en claro.- El secreto vive en el secret manager (cap. N4), no en el repo.
- Logout = borrar la cookie (
maxAge: 0) — con JWT stateless el token sigue “válido” hastaexp, pero sin cookie no viaja. La revocación real llega en cap. 18.
24.9 Ejercicio
- Decodifica a mano (jwt.io o
atob) un JWT y nombra sus tres partes y qué garantiza cada una. - ¿Por qué
subguarda elidinterno y no elpublic_id? (pista: ¿quién lo lee?) - Diseña las dos cookies (access + refresh): tiempos y para qué sirve cada una.
header(algoritmo),payload(claims: sub/exp/iat — legible por cualquiera),signature(HMAC del contenido: garantiza integridad — quien lo modifique sin el secreto produce firma inválida).- El
sublo lee el servidor para buscar al usuario en la BD — el id interno es la FK natural, rápida y estable. Elpublic_ides para URLs de la API (qué expone al mundo); dentro del token, lo interno es correcto porque el token es un contrato servidor↔︎servidor. - Access: cookie httpOnly, 15 min — la credencial de cada request. Refresh: cookie httpOnly, 30 días, path
/auth/refresh(solo viaja ahí) — renueva el access sin re-login. Si el access se roba, dura minutos; el refresh se rota en cada uso (cap. 18).
24.10 Mini reto
“Robar” tu propio token desde la consola del navegador y explicar por qué httpOnly lo impide (o no).
La terminal (consola, línea de comandos) es la interfaz de texto con el sistema operativo: escribes comandos, lees resultados. Es como hablarle a la computadora por cartas en vez de señalar con el mouse.
Abre DevTools → Console → document.cookie: la cookie token no aparece — httpOnly la excluye de la API de cookies de JS. Eso es el punto: un XSS que inyecte <script> no puede exfiltrarla por document.cookie. Pero honestidad: sí aparece en DevTools → Application → Cookies (tú eres el usuario legítimo mirando tu propia cookie) y viaja en cada request. httpOnly protege contra robo por script, no contra acceso físico a tu navegador. La defensa completa es el combo: httpOnly (anti-XSS) + SameSite (anti-CSRF) + Secure (anti-red) + exp corto (anti-robo).
24.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
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 objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
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.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
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”.
Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.
Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.
Un entorno es una instancia completa donde corre tu app con su propia config y datos: local (tu máquina), staging (réplica de prueba), producción (la real). Cada entorno tiene sus propias llaves y su propia base de datos.
Un repositorio (repo) es la carpeta del proyecto con todo su historial Git adentro. GitHub/GitLab son servicios que hospedan repos para compartirlos y respaldarlos.
Una foreign key es un puntero verificado: tasks.project_id apunta a projects.id y la BD garantiza que el proyecto existe. Es la diferencia entre “el dato dice 5” y “el dato apunta a algo real”.
La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.