Ein Login-Endpunkt liefert ein JWT zurück, und Sie haben drei Orte, um es abzulegen: ein Cookie, localStorage oder sessionStorage. Wählen Sie den falschen, und entweder trägt ein XSS-Bug das Token davon, oder das Token erreicht Ihre API nie. Alle drei speichern Strings im Browser. Sie unterscheiden sich darin, wer sie lesen kann, wie lange sie bestehen und ob der Server sie überhaupt zu sehen bekommt.

Worin sich localStorage, sessionStorage und Cookies unterscheiden

localStorage und sessionStorage sind dieselbe API, das Storage-Interface: setItem, getItem, removeItem, clear, String-Keys und String-Werte. Der Unterschied liegt in der Lebensdauer. localStorage überlebt das Schließen des Browsers und einen Neustart der Maschine und bleibt bestehen, bis etwas es löscht. Safari ist die Ausnahme: Mit aktivierter Tracking-Prevention löscht es skriptgeschriebenen Speicher für eine Seite, mit der der Nutzer seit sieben Tagen nicht interagiert hat. sessionStorage gehört einem einzelnen Tab und stirbt, wenn dieser Tab geschlossen wird. Geben Sie dieselbe URL in einem zweiten Tab ein, bekommen Sie ein frisches, leeres sessionStorage; nur “Tab duplizieren” kopiert den Inhalt mit.

Cookies sind älter und funktionieren anders. Der Server setzt eines mit einem Set-Cookie-Response-Header, und von da an hängt der Browser es automatisch an jede passende Anfrage im Cookie-Header an. Das schließt Bild- und Stylesheet-Anfragen ein, ebenso same-origin fetch-Aufrufe, bei denen Sie nie konfiguriert haben, irgendetwas zu senden. localStorage und sessionStorage verlassen den Browser nie, außer Ihr JavaScript liest sie aus und schickt sie irgendwohin.

Automatisch gesendet gegenüber nie gesendet: Diese eine Eigenschaft klärt die meisten “welches nehme ich”-Fragen.

Speicherlimits und Persistenz im Vergleich

CookieslocalStoragesessionStorage
An Server gesendetJa, bei jeder passenden AnfrageNeinNein
Kapazität~4KB pro Cookie~5-10MB pro Origin (browserabhängig)~5-10MB pro Origin
PersistenzDurch Expires/Max-Age festgelegt; Session-Cookie bei WeglassenBis explizit gelöschtBis der Tab schließt
ScopeDomain + Pfad (konfigurierbar)Origin (Schema + Host + Port)Origin, und nur der aktuelle Tab
Lesbar aus JSJa, außer bei HttpOnlyImmerImmer
APIString-Header, oder document.cookieStorage (synchron)Storage (synchron)

Die Kapazitätslücke ist der Grund, warum Warenkörbe und Einstellungs-Blobs nicht in Cookies landen. 4KB sind schnell aufgebraucht, sobald Sie JSON speichern, und jedes Byte wird bei jeder Anfrage erneut hochgeladen, egal ob der Endpunkt es braucht oder nicht. Eine Seite, die 40 statische Assets von derselben Domain lädt und ein 3KB-Cookie mitschleppt, schickt dieses Cookie 40 Mal.

So lesen und schreiben Sie jedes der drei in JavaScript

localStorage und sessionStorage nehmen dieselben Aufrufe entgegen:

1
2
3
4
5
6
7
8
// localStorage: überlebt das Schließen des Browsers
localStorage.setItem("theme", "dark");
localStorage.getItem("theme");    // "dark"
localStorage.removeItem("theme");

// sessionStorage: weg, wenn dieser Tab schließt
sessionStorage.setItem("checkout_step", "2");
sessionStorage.getItem("checkout_step"); // "2"

Beide speichern nur Strings. setItem("user", {name: "Dan"}) speichert den wörtlichen String "[object Object]". Erst serialisieren:

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

Aus JavaScript läuft ein Cookie über document.cookie:

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

Das sieht aus wie eine Zuweisung, die alles überschreibt. Ist es nicht. Jede Zuweisung setzt oder aktualisiert ein einzelnes Cookie, und document.cookie zurückzulesen liefert Ihnen jedes Cookie der Seite als einen einzigen, mit ; verbundenen String. Sie splitten selbst bei ; und dann beim ersten =, oder nutzen eine kleine Library, die das übernimmt.

Die meisten Cookies setzt stattdessen der Server, über die Response-Header. Das ist auch der einzige Weg, um an die Schutzmechanismen zu kommen, die bei irgendetwas Sensiblem zählen:

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 Tage, in ms
});

Dieser Aufruf erzeugt einen Response-Header wie:

1
Set-Cookie: session_id=a1b2c3...; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax
  • HttpOnly entfernt das Cookie vollständig aus document.cookie. JavaScript auf der Seite kann es nicht lesen, und das schließt das eingeschleuste Skript eines Angreifers ein. Bei einem Session-Token ist das der Hauptgrund, ein Cookie statt localStorage zu verwenden: Ein XSS-Bug, der beliebigen JS ausführt, kann ein HttpOnly-Cookie trotzdem nicht exfiltrieren.
  • Secure verhindert, dass der Browser das Cookie über reines HTTP sendet; es geht nur über HTTPS raus. Setzen Sie es in Produktion auf alles. Siehe was TLS unter HTTPS leistet, um einzuschätzen, was dieser Schutz wert ist.
  • SameSite steuert, ob das Cookie bei Cross-Site-Anfragen mitgeht. Strict sendet es nur bei Same-Site-Anfragen. Lax sendet es trotzdem bei Top-Level-GET-Navigation, sodass ein Link aus einer E-Mail Sie immer noch einloggt, hält es aber von Cross-Site-POSTs und fetch-Aufrufen zurück. Das blockiert die meisten Cross-Site-Request-Forgery-Angriffe. None sendet es überallhin und erfordert Secure, sonst verwirft der Browser das Cookie komplett. Chrome und Edge behandeln ein Cookie ohne SameSite als Lax; Firefox und Safari nicht, also setzen Sie es explizit.

localStorage und sessionStorage haben kein Äquivalent zu irgendeinem davon. Was Sie dort ablegen, ist für jedes Skript lesbar, das auf Ihrer Origin läuft: Ihren eigenen Code, eine kompromittierte npm-Abhängigkeit, die Payload eines Angreifers nach einer XSS-Lücke. Cookies sind nicht von Natur aus sicherer. Sie haben einen Opt-in-Modus, an den JavaScript nicht herankommt, und localStorage hat das nicht. Das ist das ganze Argument gegen ein Auth-Token in localStorage.

Warum Cookies bei Cross-Origin-fetch-Anfragen nicht mitgesendet werden

Eine Seite auf app.example.com ruft api.example.com mit fetch auf. Das von api.example.com gesetzte Cookie bleibt daheim, selbst mit der korrekten Domain auf dem Cookie, weil fetch standardmäßig nur Credentials an die gleiche Origin sendet. Sie müssen explizit opt-in gehen:

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

Der Server muss ebenfalls mitspielen. Sobald Credentials im Spiel sind, kann er nicht mehr mit dem Wildcard Access-Control-Allow-Origin: * antworten. Er muss die konkrete Origin spiegeln und Access-Control-Allow-Credentials: true ergänzen. Das ist die CORS-Verhandlung aus Was ist CORS und wie behebt man CORS-Fehler, und dort stecken die meisten “mein Cookie kommt nicht an”-Bugs.

So synchronisieren Sie localStorage über Tabs mit dem storage-Event

Wenn sich localStorage ändert, feuert der Browser ein storage-Event in jedem anderen Tab derselben Origin, nicht in dem Tab, der die Änderung ausgelöst hat. Damit können Sie einen Nutzer überall abmelden, sobald ein Tab localStorage.removeItem("token") aufruft:

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

sessionStorage feuert dieses Event nie über Tabs hinweg. Kein anderer Tab bekommt davon etwas mit.

Wann Sie Cookies, localStorage oder sessionStorage nicht verwenden sollten

Cookies sind falsch für alles über ein paar Kilobyte und für Daten, die der Server nie liest, etwa ein UI-Theme oder einen halb geschriebenen Kommentar. Sie würden das bei jeder Anfrage umsonst neu hochladen.

Bei localStorage liegt die Grenze woanders: Alles, was einen XSS-Bug überleben muss, gehört da nicht hin — Session-Tokens, API-Keys, alles, was Sie in einem Breach-Report ungern mit dem Satz “Angreifer hat console.log(localStorage) ausgeführt” lesen würden. Genauso wenig passen Daten hinein, auf die der Server reagiert: localStorage verlässt den Browser nie, außer Ihr Code schickt es weg, und sobald Sie diesen Code schreiben, haben Sie ein Cookie ohne HttpOnly, Secure oder SameSite nachgebaut.

sessionStorage passt auf eine andere Art von Problem: Zustand, der zu diesem Tab, für diesen Besuch gehört, nicht auf Daten, die zwischen Tabs geteilt werden oder nach Schließen und Wiederöffnen des Browsers noch da sein sollen. Der aktuelle Schritt eines mehrseitigen Checkouts ist der Klassiker, denn er soll nicht in einen zweiten Tab durchsickern, den der Nutzer zum Preisvergleich geöffnet hat.

Jenseits von ein paar Megabyte, oder sobald Sie Queries statt Key-Lookups brauchen, passt keines der drei. Nutzen Sie IndexedDB, bevor Sie versuchen, ein paar tausend JSON-Datensätze in localStorage zu zwängen und an die Quota stoßen.

Was Sie für Tokens, Warenkörbe und Einstellungen verwenden

DatenMechanismusWarum
Session-Token, Auth-ZustandHttpOnly + Secure CookieServer braucht es; JS darf es nicht lesen können
CSRF-TokenCookie (Double-Submit) oder Server-SessionMuss mit der Anfrage, die es schützt, hin- und herlaufen
Theme, Locale, UI-EinstellunglocalStorageKlein, nicht sensibel, soll bestehen bleiben
Entwurf eines mehrstufigen Formulars, Wizard-ZustandsessionStoragePro Tab, verzichtbar, kein Server-Roundtrip nötig
Warenkorb (nicht authentifiziert)localStorage, oder serverseitig an ein Session-Cookie gebundenHängt davon ab, ob er einen gelöschten Browser überleben muss
Große strukturierte Daten, Offline-CacheIndexedDBBraucht Kapazität und Queries, die keines der drei bietet

Wenn Ihr Server es vertrauen muss, legen Sie es in ein HttpOnly-, Secure-Cookie mit explizitem SameSite. Heben Sie sich localStorage für Daten auf, die Sie bedenkenlos jedem Skript auf Ihrer Seite geben würden.

Verwandte Artikel