Arquitectura · 28 de octubre de 2026 · Daniel Murillo
Autenticación: por qué preferir cookies (y qué hacer si no puedes)
Si controlas la web, sesión en cookie HttpOnly suele mitigar mejor el robo por XSS que un JWT en localStorage. Cuándo no alcanza y cómo resolverlo.
Si puedes autenticar una app web con sesión en cookie
HttpOnly+Secure+SameSite, empieza por ahí. JWT enlocalStorageno es «más moderno»: suele ser más fácil de robar con XSS.
Introducción
Autenticación y autorización no son lo mismo:
- Autenticación: demuestra quién hace la petición.
- Autorización: qué puede hacer esa identidad sobre un recurso concreto.
Este artículo responde tres preguntas prácticas:
- ¿Por qué, en apps web que controlas, conviene preferir cookies de sesión?
- ¿Qué inseguridades mitiga (y cuáles no) cada práctica?
- ¿Qué hacer cuando las cookies no bastan (móvil, API pública, dominios cruzados)?
1. La regla práctica: cookies primero cuando el cliente es un navegador tuyo
Escenario ideal:
- Tienes un frontend web (SSR, SPA o híbrido) y un backend que tú operas.
- El usuario inicia sesión en tu producto (no es solo un cliente de terceros).
- Puedes poner frontend y API en el mismo sitio (
app.example.com) o detrás de un BFF.
En ese caso, el diseño más sólido suele ser:
Login → servidor crea sesión opaca
→ Set-Cookie: __Host-session=…; Secure; HttpOnly; SameSite=Lax|Strict
→ el JS nunca lee el secret de sesión
→ cada request del mismo sitio lleva la cookie automáticamente
→ logout = invalidar sesión en el servidor
Por qué es mejor que guardar un JWT en localStorage:
Cookie HttpOnly (sesión) | JWT en localStorage / sessionStorage | |
|---|---|---|
| ¿El JS de la página puede leer el secreto? | No | Sí |
| Impacto típico de XSS | El atacante puede actuar como el usuario en el navegador, pero no exfiltra fácilmente el ID de sesión para usarlo offline | El atacante lee el token, lo copia y lo usa desde otra máquina hasta que expire |
| Revocación | Invalidar la sesión en servidor (inmediata) | Hay que esperar exp, rotar claves o mantener denylist |
| Envío | El navegador lo adjunta solo | Hay que poner Authorization a mano |
OWASP y guías de apps en navegador desaconsejan guardar identificadores de sesión o tokens de larga vida en almacenamiento accesible por JavaScript precisamente por XSS.
Importante: HttpOnly no “cura” el XSS. Con XSS, el atacante aún puede disparar peticiones desde el navegador de la víctima (la cookie viaja sola). Lo que mitiga es el robo offline del secreto. Por eso sigues necesitando prevenir XSS (CSP, sanitización, dependencias) y, para cambios de estado, defensa CSRF.
2. Qué amenaza mitiga cada práctica
Tabla orientativa. «Mitiga» no significa «elimina al 100 %».
| Práctica | Amenaza que ataca | Qué ganas | Qué no resuelve sola |
|---|---|---|---|
Cookie Secure | Robo de sesión en tránsito (MitM / HTTP en claro) | La cookie no se manda por HTTP | Certificados mal configurados, downgrade si aún sirves HTTP |
Cookie HttpOnly | Exfiltración del ID/token vía JS (XSS → document.cookie / storage) | El script no puede leer la cookie | XSS que actúa en nombre del usuario; CSRF |
SameSite=Lax o Strict | CSRF clásico (otro sitio manda POST con tu cookie) | Defensa en profundidad contra muchos CSRF cross-site | CSRF same-site (subdominios), GET que muta estado, navegadores viejos |
| Token CSRF (o double-submit / header custom) | CSRF en mutaciones | Protección aunque SameSite falle o sea insuficiente | XSS same-origin (el script puede leer el token CSRF de la página) |
| Sesión opaca en servidor | Tokens robados de larga vida; claims obsoletos | Logout y revocación centrales | Necesitas almacén y escalado de sesiones |
Access token corto + refresh en cookie HttpOnly | Robo de access token de larga duración | Ventana de abuso pequeña; refresh no es legible por JS | Hay que rotar refresh y detectar reuso |
| PKCE + Authorization Code (OAuth) | Intercepción/inyección de códigos en clientes públicos | Flujo moderno para móvil/SPA con IdP | No sustituye autorización en tu API |
Validar iss / aud / exp / alg en JWT | Tokens falsos o mal usados entre APIs | Confías solo en el emisor y audiencia correctos | Un JWT válido robado sigue siendo válido hasta exp |
| Rotar ID de sesión al login | Fijación de sesión | El atacante no “ancla” un sid previo | XSS/CSRF posteriores |
| No guardar tokens en logs/URLs | Fugas en historial, proxies, APM | Menos superficie de filtración | Errores humanos y backups |
CSRF vs XSS (para no mezclarlos)
XSS: el atacante ejecuta JS en TU origen
→ con localStorage: puede ROBAR el JWT
→ con cookie HttpOnly: no lo lee, pero puede USAR la sesión en el browser
CSRF: el atacante engaña al browser para llamar a TU API desde OTRO origen
→ la cookie viaja sola → mutación no deseada
→ SameSite + token CSRF mitigan esto
→ Authorization: Bearer NO se envía solo → CSRF “clásico” baja
(pero perdiste la protección HttpOnly si el Bearer está en JS)
Por eso el trade-off real no es «cookies inseguras vs JWT seguros», sino:
- Cookies HttpOnly: mejor frente a robo por XSS; debes diseñar CSRF.
- Bearer en JS: peor frente a robo por XSS; el CSRF clásico pesa menos porque el header no se adjunta solo.
En una app web moderna con buen SameSite y CSRF en mutaciones, el primer camino suele ganar.
3. Cómo configurar la cookie (mínimo serio)
Set-Cookie: __Host-session=opaque-id; Path=/; Secure; HttpOnly; SameSite=Lax
| Atributo / prefijo | Por qué |
|---|---|
Secure | Solo HTTPS |
HttpOnly | JS no lee la cookie |
SameSite=Lax | Equilibrio UX/seguridad; Strict si puedes vivir sin cookie en links externos |
Path=/ + __Host- | Evita cookies ambiguas en subpaths/hosts; __Host- exige Secure y sin Domain |
Reglas operativas:
- Tras login o elevación de privilegios: rota el ID de sesión.
- Logout: borra cookie e invalida el registro en el almacén.
- Nunca uses
SameSite=NonesinSecure, y solo si realmente necesitas cross-site (casi siempre hay mejor diseño). SameSiteno reemplaza un token CSRF en operaciones sensibles: OWASP lo trata como defensa en profundidad.- Asegura que los
GETno cambien estado (si lo hacen,Laxno te salva).
4. Cuando las cookies no bastan (o no aplican)
A. SPA en app.com y API en api.com (cross-site)
El navegador trata la cookie de API como cross-site → necesitas SameSite=None; Secure o dejas de mandar cookies a la API.
Mejor salida: BFF (Backend for Frontend)
Navegador ←cookie same-site→ BFF (app.example.com)
BFF ←token/credencial→ API interna / IdP
El browser solo habla con el BFF. La sesión vive en cookie HttpSameSite. El BFF llama a la API con credenciales de servidor o tokens que nunca toca el JS.
B. App móvil nativa
No hay el mismo modelo de cookies first-party del browser web. Usa Authorization Code + PKCE, almacena refresh en almacenamiento seguro del SO (Keychain / Keystore), access tokens de vida corta. No copies el patrón localStorage del navegador.
C. API pública para terceros
Ahí OAuth 2.0 / OIDC con scopes y clientes registrados es el marco. Tu producto web propio puede seguir usando sesión+cookie; los integradores usan tokens con las reglas de RFC 9700 (PKCE, etc.).
D. Microservicios / machine-to-machine
Cookies de navegador no aplican. Credenciales de workload, mTLS o tokens cortos entre servicios. No reutilices el JWT de usuario de larga vida como auth interna sin límites.
E. «Necesito JWT porque soy stateless»
Stateless no es un requisito moral. Un almacén de sesiones (Redis) suele ser barato frente al costo de revocación y de tokens robados. Si aun así quieres JWT:
- Access token muy corto (minutos).
- Refresh en cookie
HttpOnly(si hay browser) o almacenamiento seguro (móvil). - Rotación de refresh y detección de reuso.
- Lista de revocación o versión de sesión en servidor para logout real.
- Nunca pongas el refresh en
localStorage.
Eso ya no es «JWT puro sin estado»: es un híbrido consciente.
5. Sesión opaca vs JWT: piezas distintas
Sesión: cookie = ID opaco → servidor tiene userId, roles, expiry
JWT: token = claims firmados → cualquiera con la clave verifica
Puedes combinarlos: sesión web en cookie, y hacia servicios internos un JWT de corta duración emitido por tu BFF. El error es tratar JWT como reemplazo automático de sesión y guardarlo donde el XSS lo lee.
6. OAuth 2.0 / OIDC (cuando hay IdP o terceros)
OAuth autoriza acceso delegado; OIDC añade identidad (id_token).
Según RFC 9700:
- Clientes públicos: Authorization Code + PKCE (obligatorio en la práctica).
- Evita Implicit y Resource Owner Password.
redirect_uricon match exacto; sin open redirects.
En una SPA con IdP, sigue preferible el BFF o el patrón de cookie HttpOnly para la sesión de tu app, en lugar de dejar access/refresh tokens largos en JS.
7. Ejemplo: sesión con cookie en Express
import express from "express";
import session from "express-session";
const app = express();
app.set("trust proxy", 1); // si terminas TLS en el proxy
app.use(
session({
name: "__Host-session",
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 1000 * 60 * 60 * 8,
},
}),
);
app.post("/login", async (req, res) => {
// verificar credenciales…
req.session.regenerate((err) => {
if (err) return res.status(500).end();
req.session.userId = "user_123";
res.status(204).end();
});
});
app.get("/me", (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ message: "Unauthenticated" });
}
return res.json({ userId: req.session.userId });
});
Usa almacén compartido en producción (no MemoryStore). Añade protección CSRF en POST/PUT/PATCH/DELETE.
8. Ejemplo: validar un JWT (API o servicio)
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
issuer: "https://auth.example.com",
audience: "my-api",
});
return payload;
}
Rechaza alg inesperados. Con OIDC, valida contra JWKS del emisor. Un token válido robado sigue siendo válido hasta exp: por eso la vida corta importa.
9. Errores frecuentes
- JWT de horas/días en
localStorage«porque la SPA lo necesita». - Creer que
SameSitesustituye por completo al token CSRF. SameSite=Nonesolo para no rediseñar dominios.- No rotar sesión al login (fijación).
- Logout que solo borra el storage del cliente y deja la sesión viva.
- Access token en query strings o logs.
- Confundir “ocultar el botón” con autorización sobre el recurso.
- Usar el mismo JWT de usuario para auth entre microservicios sin scopes ni audiencia.
10. Matriz de decisión
| Contexto | Empieza aquí | Mitigas sobre todo | Debes diseñar además |
|---|---|---|---|
| Web same-site (tú controlas front y API) | Sesión + cookie HttpOnly/Secure/SameSite | Robo de secreto por XSS, revocación | CSRF, almacén de sesiones, XSS |
| SPA + API en otro dominio | BFF + cookie same-site al BFF | Evitar Bearer en JS | AuthZ en API, trust del BFF |
| App móvil | Auth Code + PKCE + storage seguro | Intercepción de códigos | Refresh rotation, biometric/OS storage |
| API para terceros | OAuth/OIDC | Delegación sin compartir passwords | Scopes, auditorías, revocación |
| Servicios internos | mTLS / tokens de workload cortos | Credenciales de máquina | Rotación y least privilege |
Conclusión
Para una aplicación web que controlas, preferir cookies de sesión bien configuradas no es nostalgia: reduce el impacto del XSS sobre el secreto de autenticación y simplifica el logout. JWT y OAuth siguen siendo piezas útiles —entre servicios, con IdPs, en móvil—, pero no son excusa para dejar un Bearer de larga vida donde cualquier script de la página pueda leerlo.
Si puedes same-site + cookie HttpOnly, hazlo. Si no puedes, rediseña con BFF o tokens cortos + almacenamiento seguro; no “resuelvas” el cross-domain tirando el token a localStorage.
En esta serie
- Autenticación (esta nota)
- Observabilidad: logs, métricas y tracing
- SQL, query builders y ORMs
- Cómo probar una API
- Contenedores y Twelve-Factor
Lecturas relacionadas
- Herramienta: qué backend te conviene
- Herramienta: qué protocolo de API te conviene
- Optimizar APIs REST
- Cómo probar una API