FundamentosLección 05
Sesiones, cookies y tokens
HTTP no recuerda nada. Cómo una aplicación recuerda quién eres entre peticiones, qué atributos hacen segura una cookie y cuándo elegir sesión o JWT.
Al terminar sabrás
- Explicar cómo funcionan una sesión con cookie y un token Bearer.
- Configurar una cookie con
HttpOnly,SecureySameSitey saber qué evita cada uno. - Leer un JWT y saber qué garantiza (y qué no) su firma.
- Elegir entre sesión y token, y dónde guardar un token en el navegador.
HTTP es un protocolo sin estado: cada petición llega sola, sin recuerdo de las anteriores. Si un usuario inicia sesión y en la siguiente petición el servidor ya no sabe quién es, algo tiene que viajar con cada petición para recordárselo. Hay dos grandes formas de hacerlo.
Sesión con cookie (stateful)
Sección titulada «Sesión con cookie (stateful)»La cookie es automática: el navegador la adjunta a cada petición hacia ese dominio sin que el JavaScript haga nada. Esa comodidad es también su mayor riesgo (lo verás en la lección de CSRF).
Anatomía de una cookie segura
Sección titulada «Anatomía de una cookie segura»HTTP/1.1 200 OKSet-Cookie: __Host-SESSION=8f2c41d0e9; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800| Atributo | Qué hace | Qué evita |
|---|---|---|
HttpOnly | JavaScript no puede leerla (document.cookie no la ve) | Que un XSS robe la sesión |
Secure | Solo viaja por HTTPS | Que se lea en una red Wi-Fi o un proxy |
SameSite=Lax | No se envía en peticiones POST iniciadas por otros sitios (sí al seguir un enlace) | La mayoría de ataques CSRF |
SameSite=Strict | No se envía en ninguna petición iniciada por otro sitio | CSRF por completo, a costa de que un enlace externo llegue “sin sesión” |
SameSite=None | Se envía siempre (exige Secure) | Nada: solo para integraciones cross-site legítimas |
Max-Age / Expires | Duración. Sin ellos, cookie de sesión del navegador | Sesiones eternas |
Prefijo __Host- | Obliga a Secure, Path=/ y sin Domain | Que un subdominio comprometido la sobrescriba |
Token Bearer (stateless)
Sección titulada «Token Bearer (stateless)»En el modelo con token, el cliente recibe una credencial y la envía explícitamente en una cabecera:
GET /api/notes HTTP/1.1Host: api.example.comAuthorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbmEi...“Bearer” significa “portador”: quien lo porta, lo usa. No hay más comprobación de identidad que poseerlo, así que un token robado equivale a una sesión robada.
Los tokens pueden ser:
- Opacos: una cadena aleatoria sin significado. La API debe preguntar al emisor qué significa (introspección). Revocables al instante, pero con una llamada extra por petición.
- Autocontenidos (JWT): llevan los datos dentro, firmados. La API los valida sola, con la clave pública del emisor.
Anatomía de un JWT
Sección titulada «Anatomía de un JWT»eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImsxIn0.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJhbmEiLCJzY29wZSI6Im5vdGVzOnJlYWQiLCJleHAiOjE3OTM3MDAwMDB9.Qm9vb2hfc2lnbmF0dXJlX2J5dGVzX2Jhc2U2NHVybA
Header
{
"alg": "RS256",
"typ": "JWT",
"kid": "k1"
}cómo se firmó
Payload (claims)
{
"iss": "https://auth.example.com",
"sub": "ana",
"scope": "notes:read",
"exp": 1793700000
}lo puede leer CUALQUIERA — nunca pongas secretos
Firma
RS256( base64url(header) + "." + base64url(payload), privateKey )
prueba que nadie cambió header ni payload
| Claim | Significado |
|---|---|
iss | Quién lo emitió (issuer) |
sub | De quién es (subject): el usuario o el cliente |
aud | Para quién es (audience): qué API debe aceptarlo |
exp / nbf / iat | Caduca en / no válido antes de / emitido en (segundos Unix) |
scope | Permisos concedidos |
jti | Identificador único (para detectar reutilizaciones o revocar) |
¿Sesión o token?
Sección titulada «¿Sesión o token?»| Sesión (cookie) | Token (Bearer) | |
|---|---|---|
| Dónde vive la identidad | En el servidor | En el token |
| Cerrar sesión / revocar | Inmediato: borrar la sesión | Difícil: vale hasta que expira |
| Escalar a varias instancias | Necesita sesión compartida (Redis) | Trivial: cualquier instancia valida la firma |
| Riesgo típico | CSRF (la cookie va sola) | Robo del token (XSS si está en localStorage) |
| Clientes ideales | Web del mismo dominio, BFF | Móviles, servicios, APIs públicas |
Ninguna opción es “la moderna”. Si el único cliente es una web de tu propio dominio, una sesión con cookie HttpOnly y protección CSRF suele ser más segura que un JWT accesible desde JavaScript.
Tokens de acceso y de refresco
Sección titulada «Tokens de acceso y de refresco»Como un token no se puede revocar fácilmente, se hace de vida corta (5–15 minutos). Para no pedir la contraseña cada cuarto de hora, el cliente recibe además un refresh token de vida larga que solo sirve para pedir un access token nuevo al emisor, que sí lo guarda y lo puede revocar. Si además cada uso del refresh token lo reemplaza por otro (rotación), un refresh token robado y reutilizado se detecta.
¿Dónde guarda el token una SPA?
Sección titulada «¿Dónde guarda el token una SPA?»| Lugar | Riesgo |
|---|---|
localStorage / sessionStorage | Cualquier XSS lo lee y lo envía a otro servidor |
| Memoria de la app (variable JS) | Un XSS aún puede usarlo mientras la página está abierta, pero no robarlo para después; se pierde al recargar |
Cookie HttpOnly gestionada por un backend propio (BFF) | El JavaScript nunca ve el token. Hay que proteger CSRF |
La recomendación actual para SPAs es el patrón BFF (Backend for Frontend): un backend de tu dominio hace el login OAuth2, guarda los tokens del lado del servidor y al navegador solo le da una cookie de sesión HttpOnly.
Mini lab · 10 min
Lee un JWT como lo haría un atacante
Objetivo: Decodificar un token, interpretar sus claims y razonar qué protege la firma.
Este es un token de ejemplo (la firma es falsa):
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImsxIn0.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJhbmEiLCJzY29wZSI6Im5vdGVzOnJlYWQgbm90ZXM6d3JpdGUiLCJpYXQiOjE3OTM3MDAwMDAsImV4cCI6MTc5MzcwMDkwMH0.c2lnbmF0dXJlLWRlLWVqZW1wbG8Decodifica el payload en la consola del navegador:
const token = 'eyJhbGciOi...'; // pega el token completoJSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));- ¿De quién es el token, quién lo emitió y qué permisos tiene?
- ¿Cuánto tiempo es válido desde que se emitió?
- Si cambias
"notes:read notes:write"por"notes:read notes:write notes:admin", vuelves a codificar el payload y envías el token, ¿qué debería responder una API bien configurada? - ¿Falta algún claim que una API debería exigir?
Pista
exp e iat son segundos desde 1970. Réstalos.
Mostrar respuestaOcultar respuesta
sub: "ana", emitido poriss: "https://auth.example.com", conscope: "notes:read notes:write". Todo esto lo puede leer cualquiera que vea el token.exp − iat = 900segundos: 15 minutos, una duración típica de access token.- 401. La firma se calculó sobre el header y el payload originales; al cambiar un solo carácter, la verificación con la clave pública falla. Por eso puedes leer un JWT pero no modificarlo. (Si la API aceptara el token, estaría validando mal: por ejemplo, sin comprobar la firma o aceptando
alg: none). - Falta
aud. Sin audiencia, un token emitido para otra API del mismo emisor serviría también aquí. Una API debería exigir queaudla incluya.