Un endpoint de login te devuelve un JWT, y tienes tres lugares donde guardarlo: una cookie, localStorage o sessionStorage. Si eliges mal, un bug de XSS se lleva el token, o el token nunca llega a tu API. Las tres opciones guardan strings en el navegador. Se diferencian en quién puede leerlas, cuánto duran y si el servidor llega a verlas.

En qué se diferencian localStorage, sessionStorage y las cookies

localStorage y sessionStorage son la misma API, la interfaz Storage: setItem, getItem, removeItem, clear, claves string y valores string. La diferencia está en la duración. localStorage sobrevive a cerrar el navegador y reiniciar la máquina, y se queda hasta que algo lo borra. Safari es la excepción: con la prevención de rastreo activada, borra el almacenamiento escrito por script de un sitio con el que el usuario no ha interactuado en siete días. sessionStorage pertenece a una pestaña y muere cuando esa pestaña se cierra. Si escribes la misma URL en una segunda pestaña obtienes un sessionStorage nuevo y vacío; solo “Duplicar pestaña” lo copia.

Las cookies son más antiguas y funcionan distinto. El servidor fija una con el header de respuesta Set-Cookie, y desde ese momento el navegador la adjunta automáticamente a cada petición que coincida, en el header Cookie. Eso incluye peticiones de imágenes y hojas de estilo, y llamadas fetch same-origin que nunca configuraste para enviar nada. localStorage y sessionStorage nunca salen del navegador a menos que tu JavaScript las lea y las envíe a algún lado.

Enviado automáticamente frente a nunca enviado: esa sola propiedad resuelve la mayor parte de la decisión de cuál usar.

Límites de almacenamiento y persistencia, comparados

CookieslocalStoragesessionStorage
Enviado al servidorSí, en cada petición que coincideNoNo
Capacidad~4KB por cookie~5-10MB por origin (depende del navegador)~5-10MB por origin
PersistenciaDefinida por Expires/Max-Age; cookie de sesión si se omiteHasta que se borra explícitamenteHasta que se cierra la pestaña
AlcanceDominio + path (configurable)Origin (esquema + host + puerto)Origin, y solo la pestaña actual
Legible desde JSSí, a menos que tenga HttpOnlySiempreSiempre
APIHeader de texto, o document.cookieStorage (síncrona)Storage (síncrona)

La diferencia de capacidad explica por qué los carritos y los blobs de configuración no van en cookies. 4KB se agota rápido en cuanto guardas JSON, y cada byte se vuelve a subir en cada petición, la necesite el endpoint o no. Una página que carga 40 recursos estáticos del mismo dominio con una cookie de 3KB adjunta envía esa cookie 40 veces.

Cómo leer y escribir cada uno en JavaScript

localStorage y sessionStorage usan las mismas llamadas:

1
2
3
4
5
6
7
8
// localStorage: sobrevive a cerrar el navegador
localStorage.setItem("theme", "dark");
localStorage.getItem("theme");    // "dark"
localStorage.removeItem("theme");

// sessionStorage: desaparece cuando se cierra esta pestaña
sessionStorage.setItem("checkout_step", "2");
sessionStorage.getItem("checkout_step"); // "2"

Las dos guardan solo strings. setItem("user", {name: "Dan"}) guarda el string literal "[object Object]". Serializa primero:

1
2
localStorage.setItem("user", JSON.stringify({ name: "Dan", plan: "pro" }));
const user = JSON.parse(localStorage.getItem("user"));

Desde JavaScript, una cookie se maneja a través de document.cookie:

1
document.cookie = "theme=dark; path=/; max-age=604800";

Parece una asignación que sobrescribe todo. No lo es. Cada asignación fija o actualiza una sola cookie, y leer document.cookie de vuelta te da todas las cookies de la página como un único string unido por ; . Tienes que separarlo por ; y luego por el primer = tú mismo, o usar una biblioteca pequeña que lo haga.

La mayoría de las cookies las fija el servidor en los headers de respuesta. Esa es también la única forma de obtener las protecciones que importan para cualquier cosa sensible:

1
2
3
4
5
6
7
// Express
res.cookie("session_id", sessionToken, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 1000 * 60 * 60 * 24 * 7, // 7 días, en ms
});

Esa llamada produce un header de respuesta como:

1
Set-Cookie: session_id=a1b2c3...; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax
  • HttpOnly elimina la cookie de document.cookie por completo. El JavaScript de la página no puede leerla, y eso incluye el script inyectado por un atacante. Para un token de sesión, esta es la razón principal para usar una cookie en vez de localStorage: un bug de XSS que ejecuta JS arbitrario de todas formas no puede exfiltrar una cookie HttpOnly.
  • Secure impide que el navegador envíe la cookie por HTTP plano; solo sale por HTTPS. Actívala en todo en producción. Mira qué hace TLS bajo HTTPS para ver cuánto vale esa protección.
  • SameSite controla si la cookie sale en peticiones cross-site. Strict la envía solo en peticiones same-site. Lax todavía la envía en navegación GET de nivel superior, así que seguir un enlace desde un correo sigue iniciando tu sesión, pero la retiene en POSTs cross-site y llamadas fetch. Eso bloquea la mayoría del cross-site request forgery. None la envía a todas partes y exige Secure, o el navegador descarta la cookie directamente. Chrome y Edge tratan una cookie sin SameSite como Lax; Firefox y Safari no, así que fíjalo explícitamente.

localStorage y sessionStorage no tienen equivalente de nada de esto. Lo que pongas ahí es legible por cualquier script que corra en tu origin: tu propio código, una dependencia de npm comprometida, el payload de un atacante después de un agujero de XSS. Las cookies no son más seguras por naturaleza. Tienen un modo opt-in que el JavaScript no puede tocar, y localStorage no. Ese es todo el argumento en contra de guardar un token de autenticación en localStorage.

Por qué las cookies no se envían en peticiones fetch cross-origin

Una página en app.example.com llama a api.example.com con fetch. La cookie fijada por api.example.com se queda en casa, incluso con el dominio correcto en la cookie, porque fetch solo envía credenciales al mismo origin por defecto. Tienes que activarlo explícitamente:

1
2
3
fetch("https://api.example.com/profile", {
  credentials: "include",
});

El servidor también tiene que cooperar. En cuanto hay credenciales de por medio, no puede responder con el wildcard Access-Control-Allow-Origin: *. Tiene que repetir el origin específico y añadir Access-Control-Allow-Credentials: true. Esta es la negociación CORS que se cubre en Qué Es CORS y Cómo Arreglar los Errores CORS, y ahí es donde viven la mayoría de los bugs de “mi cookie no aparece”.

Cómo sincronizar localStorage entre pestañas con el evento storage

Cuando localStorage cambia, el navegador dispara un evento storage en cada otra pestaña del mismo origin, no en la pestaña que hizo el cambio. Eso te permite desconectar a un usuario en todas partes en el momento en que una pestaña llama a localStorage.removeItem("token"):

1
2
3
4
5
window.addEventListener("storage", (e) => {
  if (e.key === "token" && e.newValue === null) {
    window.location.href = "/login";
  }
});

sessionStorage nunca dispara este evento entre pestañas. Ninguna otra pestaña puede verlo.

Cuándo no usar cookies, localStorage o sessionStorage

Las cookies están mal para cualquier cosa de más de unos pocos kilobytes y para datos que el servidor nunca lee, como un tema de UI o un comentario a medio escribir. Estarías volviendo a subirlo en cada petición para nada.

localStorage está mal para cualquier cosa que deba sobrevivir a un bug de XSS: tokens de sesión, API keys, cualquier cosa que odiarías ver en un reporte de brecha que diga “el atacante ejecutó console.log(localStorage)”. También está mal para cualquier dato que el servidor necesite ver sin que tú tengas que programar el envío a mano. En cuanto escribes ese código ya reconstruiste una cookie sin HttpOnly, Secure ni SameSite.

sessionStorage está mal para cualquier cosa compartida entre pestañas, o que deba seguir ahí después de que el usuario cierre y reabra el navegador. Encaja en una sola forma de problema: estado que pertenece a esta pestaña, para esta visita. El paso actual de un checkout multi-página es el caso clásico, porque no debería filtrarse a una segunda pestaña que el usuario abrió para comparar precios.

Pasados unos pocos megabytes, o en cuanto necesitas consultas en lugar de búsquedas por clave, ninguna de las tres encaja. Usa IndexedDB antes de intentar meter unos miles de registros JSON en localStorage y chocar con la cuota.

Qué usar para tokens, carritos y preferencias

DatosMecanismoPor qué
Token de sesión, estado de autenticaciónCookie HttpOnly + SecureEl servidor la necesita; el JS no debe poder leerla
Token CSRFCookie (double-submit) o sesión en servidorNecesita viajar junto con la petición que protege
Tema, idioma, preferencia de UIlocalStoragePequeño, no sensible, debe persistir
Borrador de formulario multi-paso, estado de wizardsessionStoragePor pestaña, descartable, sin necesidad de ida y vuelta al servidor
Carrito de compras (sin autenticar)localStorage, o en servidor ligado a una cookie de sesiónDepende de si debe sobrevivir a un navegador limpiado
Datos estructurados grandes, caché offlineIndexedDBNecesita capacidad y consultas que ninguna de las tres ofrece

Si tu servidor tiene que confiar en ello, ponlo en una cookie HttpOnly, Secure con un SameSite explícito. Deja localStorage para datos que estarías dispuesto a entregarle a cualquier script de tu página.

Artículos relacionados