¿Dónde debería guardar un JWT? localStorage, cookies y el equilibrio entre XSS y CSRF
Dónde guardar un JWT en el navegador se suele plantear como si un sitio fuera sin más «más seguro». No es un ranking. Cada opción responde de forma distinta a dos preguntas —si el JavaScript puede leer el token y si el navegador lo adjunta solo— y esas dos preguntas son, en el fondo, XSS y CSRF. Este artículo recorre localStorage, cookies y memoria, los patrones que se han impuesto, y por qué frenar el XSS importa más que cualquier elección de almacenamiento.
Los dos primeros artículos de esta serie iban sobre el servidor: cómo leer un token y cómo verificarlo sin dejarte engañar. Este se mueve al navegador. En cuanto tu aplicación de página única (SPA) recibe un JWT, tiene que guardarlo en algún sitio, y la pregunta de dónde resulta ser uno de los temas más discutidos de la autenticación web — normalmente por el motivo equivocado.
Se discute como si la respuesta fuera un ranking, con un lugar de almacenamiento que es sencillamente «más seguro» que los demás. No lo es. Cada opción se define por cómo responde a dos preguntas independientes: ¿puede el JavaScript de tu página leer el token? y ¿lo adjunta el navegador a las peticiones de forma automática?. La primera va sobre cross-site scripting (XSS); la segunda, sobre cross-site request forgery (CSRF). Cada opción de almacenamiento las responde distinto, así que la respuesta correcta depende de a qué ataque estás más expuesto — y, por encima de todo, de si puedes mantener el XSS fuera de tu página. Ese último punto es el que la mayoría de estos debates entierra, así que llegaremos a él a propósito.
Dos atacantes distintos
Antes de comparar almacenamiento, conviene separar limpiamente las dos amenazas, porque se confunden constantemente.
El XSS es cuando un atacante consigue ejecutar su JavaScript dentro de tu origen — a través de una entrada de usuario sin escapar, un script de terceros comprometido o una dependencia vulnerable. Cuando eso ocurre, su código corre exactamente con los privilegios que tiene el tuyo. Puede leer cualquier cosa que tus scripts lean y llamar a cualquier endpoint que tu app llame. El XSS es ejecución de código en tu página.
El CSRF es distinto y, en cierto modo, más limitado: el propio sitio del atacante hace que el navegador de la víctima dispare una petición autenticada contra tu API. Funciona porque el navegador adjunta automáticamente credenciales ambientales — casi siempre cookies — a las peticiones hacia tu dominio, incluso cuando la petición sale de evil.example. El atacante nunca ve el token ni ve la respuesta; simplemente provoca que ocurra una acción usando credenciales que el navegador ofrece en su nombre.
Ten a mano esa distinción: el XSS lee y actúa desde dentro de tu página; el CSRF falsifica una petición desde fuera y depende de que el navegador lleve las credenciales consigo. Las opciones de almacenamiento se alinean casi por completo sobre estos dos ejes.
Las opciones, y qué expone cada una
localStorage y sessionStorage. Es la elección por defecto en la mayoría de los tutoriales de SPA, y tiene una fortaleza genuina y una debilidad fatal. Es legible por cualquier JavaScript de la página, así que una sola línea — localStorage.getItem('token') — entrega el token a un payload de XSS, que puede mandarlo al servidor del atacante para reproducirlo en cualquier sitio y momento, hasta que caduque. Esa es la debilidad fatal. La fortaleza genuina es su reflejo: como el valor solo lo adjunta a las peticiones tu propio código (como cabecera Authorization: Bearer), el navegador nunca lo envía por su cuenta, así que una petición cross-site no puede llevarlo. Dicho de otro modo, localStorage es inmune al CSRF y fatal ante el XSS. (sessionStorage solo difiere en la duración — por pestaña, se borra al cerrarla — no en su perfil de seguridad.)
Una cookie normal, sin httpOnly, usada para guardar el token. Como sitio donde tener la credencial, esto es lo peor de ambos mundos: el JavaScript puede leerla y el navegador la envía solo, así que heredas de golpe la exposición al XSS de localStorage y la exposición al CSRF de las cookies. (Las cookies sin httpOnly son perfectamente razonables para otras tareas — una preferencia de idioma, estado de interfaz no sensible, un token CSRF de double-submit — pero no para guardar el token en sí.)
Una cookie httpOnly. La marca httpOnly hace la cookie invisible al JavaScript, de modo que document.cookie no la ve y un payload de XSS no puede leer el token para exfiltrarlo. Es una propiedad real y valiosa. A cambio, el navegador envía la cookie automáticamente en las peticiones a su dominio — sujeta a su alcance de Domain, Path, Secure y SameSite —, lo que te mete de lleno en territorio CSRF y te obliga a añadir defensas contra él (más abajo). Pero aquí hay un matiz que muchos consejos pasan por alto, y merece su propio párrafo.
httpOnly frena el robo, no el abuso
La marca httpOnly impide que un payload de XSS lea el token. No impide que ese mismo payload lo use. Un atacante que ejecuta JavaScript en tu página puede sencillamente hacer peticiones autenticadas desde la propia página — llamar a tu endpoint de «cambiar correo», emitir una clave de API, transferir lo que tu app pueda transferir — y el navegador adjuntará la cookie httpOnly a cada una de esas peticiones exactamente igual que para el código legítimo. El atacante no puede robar el token para reproducirlo más tarde o desde otra máquina, pero sí puede actuar como el usuario, ahora mismo, mientras la página siga abierta.
Así que httpOnly ayuda de verdad: acota el radio de daño al frenar la exfiltración, lo que significa que no hay credencial robada de larga vida ni reproducción desde la infraestructura del atacante. Pero no neutraliza el XSS. Esta es la corrección clave a la creencia común de que «las cookies httpOnly te hacen seguro ante el XSS». Te hacen más difícil de robar, no inmune a que te suplanten mientras estás comprometido.
El almacenamiento en memoria — una simple variable de JavaScript o un cierre a nivel de módulo — completa el conjunto. Como nada se escribe en disco, no hay copia persistente que robar; cuando la pestaña se recarga o se cierra, el token desaparece. No se adjunta solo, así que es inmune al CSRF como localStorage, y es más estrecho que localStorage frente al robo en un aspecto: no hay copia guardada que exfiltrar y reproducir después. Es un beneficio real pero limitado — una variable en un cierre puede que ni siquiera sea directamente alcanzable por el código inyectado, pero un XSS en tu origen todavía puede llamar a endpoints autenticados, engancharse a tu ruta de peticiones o capturar un token nuevo durante el flujo de refresco. El almacenamiento en memoria frustra la reproducción offline; no impide que un XSS del mismo origen abuse de la sesión. El coste es de experiencia: el token se esfuma en cada recarga, así que necesitas una forma de obtener uno nuevo en silencio — que es justamente la maquinaria de refresh tokens del próximo artículo.
La verdad que el debate entierra: el XSS vence al almacenamiento
Alinéalas y emerge un patrón que replantea toda la pregunta. Si un atacante puede ejecutar JavaScript en tu origen, ningún lugar de almacenamiento del lado cliente protege del todo el token. En localStorage se roba sin más. En una cookie httpOnly no se puede leer, pero el atacante monta sobre ella para actuar como el usuario igualmente. En memoria puede que no haya copia directamente legible, pero el XSS todavía puede montar sobre la sesión — llamando a endpoints autenticados o capturando un token en el momento en que se refresca. No hay disposición del almacenamiento del navegador que resista a un atacante que ya ejecuta código en tu página.
De ahí sale la jerarquía honesta de controles, y es la contraria a cómo suele plantearse la pregunta. Prevenir el XSS es la defensa principal; la elección de almacenamiento solo decide cuánto peor se pone un XSS con éxito. Quien te diga «pon el token en X y el XSS no podrá hacerte daño» exagera — el almacenamiento es limitación de daños, no prevención. Por eso el trabajo que de verdad protege a tus usuarios vive en su mayor parte fuera de la decisión de almacenamiento: apóyate en el escapado automático de tu framework (React, Vue y Angular escapan por defecto) y trata cada dangerouslySetInnerHTML o v-html sobre datos no fiables como un pasivo; despliega una Content-Security-Policy estricta, que a la vez limita lo que puede hacer un script inyectado y restringe adónde podría exfiltrar un token robado; considera Trusted Types para cortar los sumideros basados en el DOM; fija con Subresource Integrity los recursos estáticos de terceros que puedas versionar y verificar (comprueba un fichero conocido, así que es un control puntual, no una solución general de cadena de suministro); y mantén las dependencias parcheadas, porque un solo paquete comprometido en tu build es un XSS con todos los privilegios de tu app. La elección de almacenamiento es una decisión real, pero es la segunda, no la primera.
Si usas cookies, trata el CSRF como es debido
Elegir una cookie httpOnly significa asumir el CSRF, así que vale la pena saber qué defiende de verdad en lugar de recurrir al folclore.
El atributo SameSite de las cookies es la primera línea moderna. SameSite=Lax — ahora el valor por defecto en los navegadores actuales — mantiene la cookie fuera de las subpeticiones cross-site y de las navegaciones de nivel superior inseguras — un POST de formulario cross-site que navega hacia tu app no la lleva —, mientras la sigue enviando en las navegaciones GET de nivel superior corrientes, con lo que los enlaces y logins normales funcionan. Es una mitigación fuerte, no una barrera incondicional: los navegadores han enviado excepciones de compatibilidad (en particular una breve ventana «Lax-allowing-unsafe» en la que una cookie recién puesta todavía se envía en un POST cross-site de nivel superior), así que trátala como algo que sube el listón, no que sella la puerta. Strict es más estricto, pero como retiene la cookie incluso cuando un usuario llega por un enlace cross-site, tu app simplemente parece deslogueada en esa primera navegación hasta que una petición del mismo sitio la reenvía. None no reabre tanto el CSRF como elimina por completo la capa SameSite — ahora exige Secure, y vuelves a depender de las defensas CSRF del lado del servidor.
SameSite es necesario, pero no una respuesta completa. No ofrece protección frente a un atacante del mismo sitio — un subdominio comprometido o controlado por el atacante es del mismo sitio que tu cookie —, tiene casos límite históricos y los clientes antiguos no lo respetan. Así que la defensa en profundidad sigue aplicando: un token CSRF de sincronizador o de double-submit, validar la cabecera Origin/Referer en las peticiones que cambian estado, o exigir una cabecera personalizada que un formulario cross-site no pueda poner (los navegadores no dejan que un simple formulario cross-origin añada cabeceras arbitrarias). Vale la pena nombrarlo por contraste: el enfoque de localStorage más Authorization: Bearer es inherentemente inmune al CSRF precisamente porque el navegador nunca adjunta esa cabecera por su cuenta. Esa inmunidad es la única ventaja de seguridad real del patrón de localStorage — pagada, como hemos visto, con su exposición al XSS.
Dos patrones comunes
Han surgido dos patrones como respuestas pragmáticas, y conviene entenderlos como pareja.
El primero es un access token de vida corta en memoria, junto a una cookie de refresco httpOnly, Secure, SameSite. El access token vive en una variable de JavaScript — no persistida, así que nada sobrevive a una recarga para ser robado después — y se envía como cabecera Bearer, así que es inmune al CSRF. Y, crucialmente, es de vida corta. El refresh token, la credencial de larga vida que de verdad necesitas proteger, vive en una cookie httpOnly acotada al endpoint de refresco, donde el JavaScript no puede leerla. Al cargar, y cada vez que el access token caduca, la app llama al endpoint de refresco para obtener uno nuevo. Esto coloca a propósito la credencial de larga vida donde el JavaScript no puede leerla y mantiene corta la credencial alcanzable, de modo que lo que un atacante puede exfiltrar en silencio y reproducir offline es, como mucho, un token al que le quedan minutos. Pero deja claro qué compra y qué no: un XSS que corre en tu origen todavía puede llamar él mismo al endpoint de refresco, leer el nuevo access token de la respuesta y mantener viva la sesión — y, entretanto, llamar directamente a tus APIs de negocio como el usuario. Las vidas cortas reducen el valor de un único token robado reproducido en otro sitio; no reducen un XSS vivo del mismo origen a unos minutos. La verdadera victoria del patrón es mantener el refresh token fuera del alcance del JavaScript, no volver inofensivo al XSS. Se apoya por completo en la maquinaria de ciclo de vida — vidas cortas y refresh tokens que rotan — que cubre el último artículo.
El segundo — más fuerte específicamente contra la exfiltración del bearer token y la reproducción offline — es el patrón Backend-for-Frontend (BFF), en el que el navegador no llega a tener el JWT. La SPA habla solo con su propio backend, autenticada por una cookie de sesión httpOnly corriente; ese backend guarda los tokens y llama a las APIs de abajo en nombre del usuario. Es lo que la guía OAuth actual para aplicaciones de navegador recomienda para sistemas de mayor seguridad, justo por la razón hacia la que ha ido este artículo: retira el bearer token del alcance del JavaScript por completo, así que no hay nada en la página que exfiltrar ni reproducir en otro sitio. Aun así no es una cura del XSS — un atacante que corre en tu origen puede impulsar acciones autenticadas a través del BFF usando la misma cookie de sesión httpOnly que el navegador adjunta para el código legítimo. Lo que el BFF elimina es el robo de tokens y la reproducción offline, no el abuso del mismo origen. El coste es arquitectónico — necesitas ese componente de backend, y reintroduce algo de estado de sesión en el servidor (y su propio manejo de CSRF para esa cookie) —, pero esquiva el debate del almacenamiento en lugar de intentar ganarlo.
Un recordatorio del primer artículo, y la herramienta que lo hace concreto
Es fácil pensar en localStorage como un escondite. No lo es. Un JWT firmado — un JWS, la clase que esta serie y el inspector manejan — tiene un payload codificado en Base64URL que es legible, no cifrado, como expuso el primer artículo. (Un JWE cifrado es la excepción, y una rara en la autenticación de navegador.) Así que el token que reposa en localStorage es una cadena claramente legible que se decodifica en claims claramente legibles. Pega uno en el inspector de JWT y estás viendo exactamente lo que ve un payload de XSS en el instante en que lee localStorage: el token entero, y cada claim de su interior, a la vista. (Si quieres desmontar un segmento a mano, el codificador/decodificador Base64 muestra lo mismo a un nivel más bajo.) Es también la razón por la que nunca pones nada sensible en el payload — el almacenamiento no lo oculta y el token tampoco.
Tres mitos que conviene jubilar
«localStorage está bien mientras sanee mis entradas.» El saneamiento reduce el XSS; rara vez lo elimina, y basta con un sumidero que se te escape o una dependencia comprometida. Apostar el token entero, y reproducible, a que tu app tenga cero XSS para siempre es una apuesta que más vale no hacer — que es todo el argumento para mantener la credencial de larga vida fuera del alcance del JavaScript.
«Las cookies httpOnly me hacen seguro ante el XSS.» Frenan que el token se lea y se exfiltre. No frenan que un XSS haga peticiones autenticadas desde dentro de tu página mientras el usuario está logueado. httpOnly limita el daño; no lo previene.
«Mete el JWT en una cookie y ya.» Si de todos modos vas a usar una cookie httpOnly con estado, vale la pena preguntar si necesitas un JWT siquiera. Un token autocontenido se gana su sitio cuando la verificación debe ser sin estado y distribuida entre servicios; para una única app web de primera parte que habla con su propio backend, una cookie de sesión opaca y corriente, respaldada por estado de sesión en el servidor, suele ser más simple y — como el próximo artículo hará palpable — mucho más fácil de revocar. Elegir un JWT debería ser una decisión sobre tu arquitectura de verificación, no un reflejo.
Dos apuntes rápidos sobre el alcance. Este debate es específico de los navegadores: las apps móviles nativas deberían usar el almacenamiento seguro de la plataforma (Keychain de iOS, Keystore de Android) en lugar de nada parecido a localStorage, y las apps de escritorio o Electron tienen sus propias salvedades. Y nada de esto sustituye a la seguridad del transporte — todo asume HTTPS en todas partes, sin el cual la pregunta del almacenamiento carece de sentido.
Adónde te deja esto
La disposición que sigue quedando arriba — un access token de vida corta junto a un refresh token bien guardado, o un BFF que mantiene el token fuera del navegador por completo — reduce lo fácil que es robar un token. Pero «reduce» es la palabra honesta: ante un XSS real, queda algo de exposición, y los tokens se filtran también por otros canales. El almacenamiento decide lo fácil que es robar un token; no puede decidir cuánto vale uno robado una vez que está fuera. Esa segunda pregunta — mantener corta la vida útil de cualquier token, rotar credenciales para que el robo sea detectable, y poder cerrar una sesión comprometida — es el ciclo de vida del token, y es el tema del último artículo: vidas cortas, rotación de refresh tokens y revocación.