La forma «clásica» de hacer login en una SPA —guardar el access_token de OAuth en el navegador— tiene un agujero de seguridad estructural: cualquier XSS puede robar ese token. El patrón Backend For Frontend (BFF) resuelve esto de raíz sacando el token del navegador. El browser deja de manejar tokens; solo tiene una cookie de sesión que no puede leer. Este es el camino recomendado hoy para apps de navegador.
El problema: el token vive en el navegador
En una SPA que implementa OAuth2 como public client (Authorization Code + PKCE desde el propio JavaScript), los tokens terminan almacenados en el navegador: localStorage, sessionStorage o memoria. Da igual dónde: si el código de tu página puede leerlo, el código de un atacante también.
Esto abre tres frentes de ataque concretos:
- Robo de token vía XSS: una sola vulnerabilidad de Cross-Site Scripting (en tu código o en una dependencia npm comprometida) permite leer
localStoragey exfiltrar elaccess_tokeny, peor aún, elrefresh_token. Con eso el atacante se hace pasar por el usuario. - Endpoint del proveedor de identidad expuesto: el navegador habla directo con el Authorization Server, así que su URL,
client_id, scopes y parámetros quedan a la vista de cualquiera que abra las DevTools. - Endpoints del backend expuestos: la SPA llama directamente a tu API y microservicios, revelando su topología, rutas y contratos a un atacante que solo tiene que mirar el tráfico de red.
🚫 Guardar tokens en el navegador es indefendible ante XSS
localStorage es legible por JS; incluso mantener el token solo en memoria se puede exfiltrar si el atacante ya ejecuta código en tu página. La única defensa real es que el token no esté nunca en el navegador.La solución: el BFF se queda con los tokens
La idea del BFF aplicada a seguridad es simple: colocas un backend propio entre la SPA y todo lo demás (proveedor de identidad, APIs, microservicios). Ese backend es el cliente OAuth confidencial. Realiza el flujo Authorization Code, recibe y guarda los tokens del lado del servidor, y con el navegador solo intercambia una cookie de sesión HttpOnly.
Qué cambia, punto por punto:
- El
access_tokeny elrefresh_tokenviven solo en el servidor (en la sesión del BFF). El JavaScript del navegador nunca los ve, así que un XSS no tiene nada que robar. - El navegador autentica con una cookie HttpOnly + Secure + SameSite. Al ser
HttpOnly, el JavaScript no puede leerla; el navegador la envía sola en cada request al BFF. - El navegador nunca habla con el Identity Provider ni con los microservicios. No conoce sus URLs, ni el
client_secret, ni la topología interna. Todo eso queda oculto detrás del BFF. - Las llamadas del BFF a las APIs internas son server-to-server, en tu red privada, con el token que solo el servidor conoce.
Cómo se ve en la práctica
1. Login: el BFF hace el flujo OAuth
El navegador solo pide GET /bff/login. El BFF inicia Authorization Code + PKCE contra el IdP, recibe el código en su propio redirect_uri, lo canjea por tokens y los guarda en la sesión del servidor. Al usuario le devuelve únicamente la cookie de sesión.
// El BFF es un confidential client: guarda el token del lado servidor
app.get("/bff/auth/callback", async (req, res) => {
const tokens = await oidcClient.exchange(req.query.code, {
code_verifier: req.session.pkceVerifier,
});
// El token NUNCA se envía al navegador
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.cookie("sid", req.session.id, {
httpOnly: true, // el JS del navegador no puede leerla
secure: true, // solo por HTTPS
sameSite: "lax", // mitiga CSRF
});
res.redirect("/");
});2. Peticiones: el BFF actúa como proxy autenticado
Cuando la SPA necesita datos, llama a su propio BFF (/bff/api/...) con la cookie. El BFF valida la sesión, recupera el token del servidor y reenvía la llamada a la API interna. El navegador nunca ve el Bearer.
app.get("/bff/api/orders", requireSession, async (req, res) => {
const token = req.session.accessToken; // vive solo aquí
const r = await fetch("https://internal-api/orders", {
headers: { Authorization: `Bearer ${token}` },
});
res.json(await r.json());
});✅ Beneficio combinado con la cookie HttpOnly
HttpOnly, ni siquiera un XSS puede robarla para usarla fuera del navegador de forma trivial. Y como es SameSite, reduces la superficie de CSRF. El BFF además puede rotar y revocar el refresh_token sin que el cliente se entere.Qué vulnerabilidades cierras
- Robo de token por XSS: no hay token en el navegador que robar.
- Exfiltración del refresh_token: reside en el servidor; su compromiso exigiría vulnerar el backend, no el browser.
- Reconocimiento de tu infraestructura: el IdP y los microservicios quedan ocultos; el atacante solo ve el BFF.
- Fuga de
client_secret: al ser el BFF un cliente confidencial, el secreto vive en el servidor, no en un bundle JS público.
⚠️ El BFF no es magia: sigue endureciendo el resto
SameSite + token anti-CSRF donde aplique), sanear entradas para prevenir XSS igualmente, aplicar CSP estricta y asegurar la sesión del servidor. Defensa en profundidad.En resumen
El patrón Backend For Frontend, aplicado a seguridad, cambia el modelo peligroso de «SPA como cliente OAuth público con el token en el navegador» por «BFF como cliente confidencial que guarda el token en el servidor y expone solo una cookie de sesión». El navegador deja de ser el eslabón débil: no maneja tokens, no conoce tu proveedor de identidad ni tus microservicios. Si construyes una SPA con OAuth2 hoy, este es el camino recomendado.