Ir al contenido

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.

Intermedio16 min de lectura

Al terminar sabrás

  • Explicar cómo funcionan una sesión con cookie y un token Bearer.
  • Configurar una cookie con HttpOnly, Secure y SameSite y 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.

NavegadorAplicaciónAlmacén de sesionesmemoria, Redis, BD1POST /loginusuario + contraseña2crea sesión 8f2c… → ana3Set-Cookie: SESSION=8f2c…HttpOnly; Secure; SameSite=Lax4GET /notes Cookie: SESSION=8f2c…el navegador la adjunta solo5¿quién es 8f2c…?6ana, ROLE_USER
La cookie solo transporta un identificador opaco; la identidad vive en el servidor.

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).

HTTP/1.1 200 OK
Set-Cookie: __Host-SESSION=8f2c41d0e9; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800
AtributoQué haceQué evita
HttpOnlyJavaScript no puede leerla (document.cookie no la ve)Que un XSS robe la sesión
SecureSolo viaja por HTTPSQue se lea en una red Wi-Fi o un proxy
SameSite=LaxNo se envía en peticiones POST iniciadas por otros sitios (sí al seguir un enlace)La mayoría de ataques CSRF
SameSite=StrictNo se envía en ninguna petición iniciada por otro sitioCSRF por completo, a costa de que un enlace externo llegue “sin sesión”
SameSite=NoneSe envía siempre (exige Secure)Nada: solo para integraciones cross-site legítimas
Max-Age / ExpiresDuración. Sin ellos, cookie de sesión del navegadorSesiones eternas
Prefijo __Host-Obliga a Secure, Path=/ y sin DomainQue un subdominio comprometido la sobrescriba

En el modelo con token, el cliente recibe una credencial y la envía explícitamente en una cabecera:

GET /api/notes HTTP/1.1
Host: api.example.com
Authorization: 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.

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

Tres partes en Base64URL separadas por puntos. El payload se lee; la firma impide modificarlo.
ClaimSignificado
issQuién lo emitió (issuer)
subDe quién es (subject): el usuario o el cliente
audPara quién es (audience): qué API debe aceptarlo
exp / nbf / iatCaduca en / no válido antes de / emitido en (segundos Unix)
scopePermisos concedidos
jtiIdentificador único (para detectar reutilizaciones o revocar)
Sesión (cookie)Token (Bearer)
Dónde vive la identidadEn el servidorEn el token
Cerrar sesión / revocarInmediato: borrar la sesiónDifícil: vale hasta que expira
Escalar a varias instanciasNecesita sesión compartida (Redis)Trivial: cualquier instancia valida la firma
Riesgo típicoCSRF (la cookie va sola)Robo del token (XSS si está en localStorage)
Clientes idealesWeb del mismo dominio, BFFMó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.

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.

LugarRiesgo
localStorage / sessionStorageCualquier 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.c2lnbmF0dXJlLWRlLWVqZW1wbG8

Decodifica el payload en la consola del navegador:

const token = 'eyJhbGciOi...'; // pega el token completo
JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
  1. ¿De quién es el token, quién lo emitió y qué permisos tiene?
  2. ¿Cuánto tiempo es válido desde que se emitió?
  3. 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?
  4. ¿Falta algún claim que una API debería exigir?
Pista

exp e iat son segundos desde 1970. Réstalos.

Mostrar respuestaOcultar respuesta
  1. sub: "ana", emitido por iss: "https://auth.example.com", con scope: "notes:read notes:write". Todo esto lo puede leer cualquiera que vea el token.
  2. exp − iat = 900 segundos: 15 minutos, una duración típica de access token.
  3. 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).
  4. Falta aud. Sin audiencia, un token emitido para otra API del mismo emisor serviría también aquí. Una API debería exigir que aud la incluya.