El JWT No Hauria de Tocar el Navegador: el Patró BFF
El lloc on no has de desar un token
Gairebé totes les SPAs comencen igual: l’usuari fa login, l’API retorna un JWT, i el frontend el desa a localStorage per enviar-lo en cada petició. Funciona a la demo, funciona a producció, i funciona fins al dia en què algú troba un XSS. Llavors deixa de funcionar de la pitjor manera possible.
El problema d’arrel: un token accessible des de JavaScript és un token robable des de JavaScript. I en una aplicació web moderna, amb desenes de dependències de tercers, assumir que mai no executaràs codi que no controles és optimisme, no seguretat.
La bona notícia és que hi ha un patró madur que ho resol sense renunciar a una SPA moderna: el Backend-for-Frontend (BFF). La idea en una frase: el token viu al servidor del frontend; el navegador només rep una cookie que no pot llegir.
Per què localStorage és el lloc equivocat
localStorage (i sessionStorage, i qualsevol variable de JS) és llegible per qualsevol script que s’executi a la pàgina. Això inclou:
- Una dependència compromesa a la teva cadena de subministrament (l’atac de moda).
- Un fragment d’analítica o d’anuncis d’un tercer.
- Qualsevol XSS reflectit o emmagatzemat que s’esmunyi per una validació fluixa.
Si l’atacant pot executar localStorage.getItem('token'), s’emporta la teva sessió sencera, normalment sense deixar rastre. Les cookies marcades com a httpOnly, en canvi, no són accessibles des de JavaScript per disseny: ni document.cookie les veu. Un XSS continua sent greu, però ja no és un robatori de sessió silenciós i trivial.
Si un token de sessió és llegible per JavaScript, assumeix que un XSS el robarà. La mitigació no és “no tenir XSS” (impossible de garantir), és que el token no estigui on l’XSS pot arribar. Això significa cookies httpOnly, i això significa que el navegador no pot gestionar el token directament — necessites un intermediari al servidor.
El patró BFF: el backend del frontend desa el secret
Backend-for-Frontend és exactament el que sona: una capa de servidor que pertany al frontend i fa de mitjancera entre el navegador i la teva API. A l’stack actual el tens gairebé de franc: els route handlers de Next.js, un servidor de Remix, o qualsevol procés que serveixi la teva SPA poden fer de BFF.
El repartiment de responsabilitats queda així:
- El navegador parla només amb el BFF, i només amb cookies. Mai no veu un JWT.
- El BFF desa els tokens, els adjunta a les crides sortints, i els refresca quan caduquen.
- L’API rep un
Authorization: Bearer …normal i corrent, sense saber ni importar-li que al darrere hi ha un BFF.
Com flueix una petició
Seguim una crida a /api/perfil des del navegador:
1. Navegador ──fetch('/api/proxy/perfil')──► BFF
(la cookie httpOnly viatja sola, el JS no la toca)
2. El BFF llegeix el token de la cookie, valida la sessió, i reenvia:
──GET https://api.interna/perfil
Authorization: Bearer <access-token>──► API
3. L'API respon 200 ──► BFF ──► Navegador
El navegador mai no construeix la capçalera Authorization; ni tan sols té amb què. El BFF és qui la injecta, des del costat del servidor, on el token està fora de perill. Un proxy genèric cobreix la majoria de rutes:
// Route handler que fa de proxy: navegador -> BFF -> API
export async function proxy(req: Request, path: string) {
const accessToken = await getAccessTokenFromSession(req); // del costat servidor
const upstream = await fetch(`${API_BASE}/${path}`, {
method: req.method,
headers: {
...forwardableHeaders(req),
Authorization: `Bearer ${accessToken}`, // s'injecta aquí, mai al client
},
body: req.body,
});
return new Response(upstream.body, { status: upstream.status });
}
Dos tokens, dos llocs
Un esquema robust fa servir dos tokens amb vides diferents, i cadascun viu on li correspon:
- Access token — de vida curta (minuts). S’utilitza per a cada crida a l’API. Viu a la memòria del servidor o, com a màxim, en una cookie de sessió xifrada del costat del BFF. Mai al navegador en clar.
- Refresh token — de vida llarga (dies o setmanes). Serveix per obtenir nous access tokens. Viu en una cookie
httpOnly,Secure,SameSite=Strict, idealment amb el prefix__Host-.
Una cookie amb prefix __Host- només és vàlida si s’ha servit per HTTPS, sense atribut Domain i amb Path=/. El navegador rebutja qualsevol cookie amb aquest nom que no compleixi les tres condicions, cosa que tanca la porta a atacs de fixació i a subdominis maliciosos que intentin sobreescriure-la. És una línia de defensa de franc: només costa el nom.
El refresc sense estampida
Quan l’access token caduca, l’API respon 401. La reacció correcta és transparent: refresques el token i reintentes la petició original un cop. Però hi ha un parany de concurrència: si la pàgina dispara cinc crides alhora i totes reben 401, cinc refrescos simultanis competeixen, i alguns invaliden els tokens dels altres. Resultat: una tempesta d’errors i, de vegades, l’usuari expulsat.
La solució és el single-flight: un únic refresc en vol cada cop. La primera petició que rep un 401 arrenca el refresc; les altres esperen aquest mateix refresc en lloc de llançar el seu. Quan acaba, totes reintenten amb el token nou.
let refreshing: Promise<void> | null = null;
async function refreshOnce(): Promise<void> {
// Si ja hi ha un refresc en marxa, suma-t'hi en comptes de llançar-ne un altre.
if (!refreshing) {
refreshing = doRefresh().finally(() => { refreshing = null; });
}
return refreshing;
}
Una sola línia de control de concurrència t’estalvia una classe sencera de bugs intermitents que només apareixen sota càrrega.
El callback d’OIDC sense token a l’URL
Si fas servir login federat (OIDC/SSO), hi ha una temptació clàssica: que el proveïdor redirigeixi de tornada amb el token a l’URL (#access_token=…). No ho facis. El que va a l’URL acaba a l’historial del navegador, als logs del servidor, al Referer de la petició següent.
El flux correcte (Authorization Code): el proveïdor redirigeix amb un codi d’un sol ús, el BFF bescanvia aquest codi pels tokens del costat del servidor, i respon plantant les cookies httpOnly. El navegador mai no veu un token a cap URL. El codi caduca en segons i només es pot bescanviar un cop.
I el CSRF?
Moure la sessió a cookies reobre una pregunta que localStorage no tenia: el Cross-Site Request Forgery. La defensa principal ja la tens amb SameSite=Strict (o Lax), que impedeix que la cookie viatgi en peticions iniciades des d’altres llocs. Per a operacions sensibles, afegeix una segona capa —un token anti-CSRF de doble enviament o la capçalera Origin verificada al BFF—. No és gaire feina, i és el peatge just per no tenir tokens robables al navegador.
Migrar sense exposar
Si véns d’un framework amb render al servidor i sessions de tota la vida (cookies de servidor) i migres a una SPA, la lliçó és no perdre el que ja tenies bé. Una SPA moderna no obliga a posar el token al navegador; obliga a posar un BFF al davant. L’usuari guanya una interfície reactiva, i tu conserves el model de sessió segur de sempre: el secret al servidor, una cookie opaca al client.
Preguntes freqüents
Què és el patró Backend-for-Frontend (BFF)?
És una capa de servidor que pertany al frontend i fa de mitjancera entre el navegador i l'API. Desa els tokens del costat del servidor i els injecta a les crides sortints, de manera que el navegador s'autentica amb una cookie httpOnly opaca i mai no gestiona el JWT directament.
Per què és insegur desar el JWT a localStorage?
Perquè localStorage és accessible des de qualsevol JavaScript que s'executi a la pàgina, incloses dependències de tercers compromeses i atacs XSS. Si l'atacant pot llegir el token, s'emporta la sessió completa. Una cookie httpOnly, en canvi, no és llegible des de JavaScript per disseny.
On es desen l'access token i el refresh token amb un BFF?
L'access token (vida curta) viu a la memòria o en una sessió xifrada del costat del servidor; s'utilitza per cridar l'API. El refresh token (vida llarga) viu en una cookie httpOnly, Secure, SameSite=Strict, idealment amb prefix __Host-. Cap dels dos no arriba al navegador en clar.
El patró BFF necessita protecció contra CSRF?
Sí. En fer servir cookies per a la sessió, has de mitigar el CSRF. La primera línia és SameSite=Strict (o Lax), que evita que la cookie viatgi en peticions d'altres llocs. Per a operacions sensibles convé afegir un token anti-CSRF de doble enviament o verificar la capçalera Origin al BFF.
Conclusió
El patró BFF no és una tècnica exòtica; és l’aplicació a les SPAs d’una cosa que la web amb sessions de servidor feia bé des de feia dècades: el secret es queda al servidor i el navegador només porta una referència opaca. El que va canviar va ser que, en algun moment, vam decidir que les SPAs “necessitaven” el token al client. No el necessiten.
Posa un BFF al davant, desa l’access token a la memòria, el refresh en una cookie httpOnly amb prefix __Host-, resol el refresc amb single-flight i bescanvia els codis d’OIDC del costat del servidor. El dia que aparegui un XSS —i apareixerà—, la teva sessió continuarà sent teva.