El JWT No Debería Tocar el Navegador: el Patrón BFF
El sitio donde no debes guardar un token
Casi todas las SPAs empiezan igual: el usuario hace login, la API devuelve un JWT, y el frontend lo guarda en localStorage para mandarlo en cada petición. Funciona en la demo, funciona en producción, y funciona hasta el día en que alguien encuentra un XSS. Entonces deja de funcionar de la peor manera posible.
El problema de raíz: un token accesible desde JavaScript es un token robable desde JavaScript. Y en una aplicación web moderna, con decenas de dependencias de terceros, asumir que nunca ejecutarás código que no controlas es optimismo, no seguridad.
La buena noticia es que hay un patrón maduro que resuelve esto sin renunciar a una SPA moderna: el Backend-for-Frontend (BFF). La idea en una frase: el token vive en el servidor del frontend; el navegador solo recibe una cookie que no puede leer.
Por qué localStorage es el sitio equivocado
localStorage (y sessionStorage, y cualquier variable de JS) es legible por cualquier script que se ejecute en la página. Eso incluye:
- Una dependencia comprometida en tu cadena de suministro (el ataque de moda).
- Un snippet de analítica o de ads de un tercero.
- Cualquier XSS reflejado o almacenado que se cuele por una validación floja.
Si el atacante puede ejecutar localStorage.getItem('token'), se lleva tu sesión entera, normalmente sin dejar rastro. Las cookies marcadas como httpOnly, en cambio, no son accesibles desde JavaScript por diseño: ni document.cookie las ve. Un XSS sigue siendo grave, pero ya no es un robo de sesión silencioso y trivial.
Si un token de sesión es legible por JavaScript, asume que un XSS lo robará. La mitigación no es “no tener XSS” (imposible de garantizar), es que el token no esté donde el XSS puede llegar. Eso significa cookies httpOnly, y eso significa que el navegador no puede manejar el token directamente — necesitas un intermediario en el servidor.
El patrón BFF: el backend del frontend guarda el secreto
Backend-for-Frontend es exactamente lo que suena: una capa de servidor que pertenece al frontend y media entre el navegador y tu API. En el stack actual lo tienes casi gratis: los route handlers de Next.js, un servidor de Remix, o cualquier proceso que sirva tu SPA pueden hacer de BFF.
El reparto de responsabilidades queda así:
- El navegador habla solo con el BFF, y solo con cookies. Nunca ve un JWT.
- El BFF guarda los tokens, los adjunta a las llamadas salientes, y los refresca cuando caducan.
- La API recibe un
Authorization: Bearer …normal y corriente, sin saber ni importarle que detrás hay un BFF.
Cómo fluye una petición
Sigamos una llamada a /api/perfil desde el navegador:
1. Navegador ──fetch('/api/proxy/perfil')──► BFF
(la cookie httpOnly viaja sola, el JS no la toca)
2. BFF lee el token de la cookie, valida la sesión, y reenvía:
──GET https://api.interna/perfil
Authorization: Bearer <access-token>──► API
3. API responde 200 ──► BFF ──► Navegador
El navegador nunca construye la cabecera Authorization; ni siquiera tiene con qué. El BFF es quien la inyecta, del lado del servidor, donde el token está a salvo. Un proxy genérico cubre la mayoría de rutas:
// Route handler que hace de proxy: navegador -> BFF -> API
export async function proxy(req: Request, path: string) {
const accessToken = await getAccessTokenFromSession(req); // del lado servidor
const upstream = await fetch(`${API_BASE}/${path}`, {
method: req.method,
headers: {
...forwardableHeaders(req),
Authorization: `Bearer ${accessToken}`, // se inyecta aquí, nunca en el cliente
},
body: req.body,
});
return new Response(upstream.body, { status: upstream.status });
}
Dos tokens, dos sitios
Un esquema robusto usa dos tokens con vidas distintas, y cada uno vive donde le corresponde:
- Access token — de vida corta (minutos). Se usa para cada llamada a la API. Vive en memoria del servidor o, como mucho, en una cookie de sesión cifrada del lado del BFF. Nunca en el navegador en claro.
- Refresh token — de vida larga (días o semanas). Sirve para obtener nuevos access tokens. Vive en una cookie
httpOnly,Secure,SameSite=Strict, idealmente con el prefijo__Host-.
Una cookie con prefijo __Host- solo es válida si se sirvió por HTTPS, sin atributo Domain y con Path=/. El navegador rechaza cualquier cookie con ese nombre que no cumpla las tres condiciones, lo que cierra la puerta a ataques de fijación y a subdominios maliciosos que intenten sobrescribirla. Es una línea de defensa gratis: solo cuesta el nombre.
El refresh sin estampida
Cuando el access token caduca, la API responde 401. La reacción correcta es transparente: refrescas el token y reintentas la petición original una vez. Pero hay una trampa de concurrencia: si la página dispara cinco llamadas a la vez y todas reciben 401, cinco refrescos simultáneos compiten, y algunos invalidan los tokens de los otros. Resultado: una tormenta de errores y, a veces, al usuario expulsado.
La solución es el single-flight: un único refresco en vuelo a la vez. La primera petición que recibe un 401 arranca el refresco; las demás esperan a ese mismo refresco en lugar de lanzar el suyo. Cuando termina, todas reintentan con el token nuevo.
let refreshing: Promise<void> | null = null;
async function refreshOnce(): Promise<void> {
// Si ya hay un refresco en marcha, súmate a él en vez de lanzar otro.
if (!refreshing) {
refreshing = doRefresh().finally(() => { refreshing = null; });
}
return refreshing;
}
Una sola línea de control de concurrencia te ahorra una clase entera de bugs intermitentes que solo aparecen bajo carga.
El callback de OIDC sin token en la URL
Si usas login federado (OIDC/SSO), hay una tentación clásica: que el proveedor redirija de vuelta con el token en la URL (#access_token=…). No lo hagas. Lo que va en la URL acaba en el historial del navegador, en los logs del servidor, en el Referer de la siguiente petición.
El flujo correcto (Authorization Code): el proveedor redirige con un código de un solo uso, el BFF canjea ese código por los tokens del lado del servidor, y responde plantando las cookies httpOnly. El navegador nunca ve un token en ninguna URL. El código caduca en segundos y solo se puede canjear una vez.
¿Y el CSRF?
Mover la sesión a cookies reabre una pregunta que localStorage no tenía: el Cross-Site Request Forgery. La defensa principal ya la tienes con SameSite=Strict (o Lax), que impide que la cookie viaje en peticiones iniciadas desde otros sitios. Para operaciones sensibles, añade una segunda capa —un token anti-CSRF de doble envío o la cabecera Origin verificada en el BFF—. No es mucho trabajo, y es el peaje justo por no tener tokens robables en el navegador.
Migrar sin exponer
Si vienes de un framework con render en servidor y sesiones de toda la vida (cookies de servidor) y migras a una SPA, la lección es no perder lo que ya tenías bien. Una SPA moderna no obliga a meter el token en el navegador; obliga a poner un BFF delante. El usuario gana una interfaz reactiva, y tú conservas el modelo de sesión seguro de siempre: el secreto en el servidor, una cookie opaca en el cliente.
Preguntas frecuentes
¿Qué es el patrón Backend-for-Frontend (BFF)?
Es una capa de servidor que pertenece al frontend y media entre el navegador y la API. Guarda los tokens del lado del servidor y los inyecta en las llamadas salientes, de modo que el navegador se autentica con una cookie httpOnly opaca y nunca maneja el JWT directamente.
¿Por qué es inseguro guardar el JWT en localStorage?
Porque localStorage es accesible desde cualquier JavaScript que se ejecute en la página, incluidas dependencias de terceros comprometidas y ataques XSS. Si el atacante puede leer el token, se lleva la sesión completa. Una cookie httpOnly, en cambio, no es legible desde JavaScript por diseño.
¿Dónde se guardan el access token y el refresh token con un BFF?
El access token (vida corta) vive en memoria o en una sesión cifrada del lado del servidor; se usa para llamar a la API. El refresh token (vida larga) vive en una cookie httpOnly, Secure, SameSite=Strict, idealmente con prefijo __Host-. Ninguno de los dos llega al navegador en claro.
¿El patrón BFF necesita protección contra CSRF?
Sí. Al usar cookies para la sesión, debes mitigar el CSRF. La primera línea es SameSite=Strict (o Lax), que evita que la cookie viaje en peticiones de otros sitios. Para operaciones sensibles conviene añadir un token anti-CSRF de doble envío o verificar la cabecera Origin en el BFF.
Conclusión
El patrón BFF no es una técnica exótica; es la aplicación a las SPAs de algo que la web con sesiones de servidor llevaba décadas haciendo bien: el secreto se queda en el servidor y el navegador solo lleva una referencia opaca. Lo que cambió fue que, en algún momento, decidimos que las SPAs “necesitaban” el token en el cliente. No lo necesitan.
Pon un BFF delante, guarda el access token en memoria, el refresh en una cookie httpOnly con prefijo __Host-, resuelve el refresco con single-flight y canjea los códigos de OIDC del lado del servidor. El día que aparezca un XSS —y aparecerá—, tu sesión seguirá siendo tuya.