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
| Cookies | localStorage | sessionStorage | |
|---|---|---|---|
| An Server gesendet | Ja, bei jeder passenden Anfrage | Nein | Nein |
| Kapazität | ~4KB pro Cookie | ~5-10MB pro Origin (browserabhängig) | ~5-10MB pro Origin |
| Persistenz | Durch Expires/Max-Age festgelegt; Session-Cookie bei Weglassen | Bis explizit gelöscht | Bis der Tab schließt |
| Scope | Domain + Pfad (konfigurierbar) | Origin (Schema + Host + Port) | Origin, und nur der aktuelle Tab |
| Lesbar aus JS | Ja, außer bei HttpOnly | Immer | Immer |
| API | String-Header, oder document.cookie | Storage (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:
| |
Beide speichern nur Strings. setItem("user", {name: "Dan"}) speichert den wörtlichen String "[object Object]". Erst serialisieren:
| |
Aus JavaScript läuft ein Cookie über document.cookie:
| |
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:
| |
Dieser Aufruf erzeugt einen Response-Header wie:
| |
HttpOnly, Secure und SameSite: Was ein Cookie sicher macht
HttpOnlyentfernt das Cookie vollständig ausdocument.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 stattlocalStoragezu verwenden: Ein XSS-Bug, der beliebigen JS ausführt, kann einHttpOnly-Cookie trotzdem nicht exfiltrieren.Secureverhindert, 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.SameSitesteuert, ob das Cookie bei Cross-Site-Anfragen mitgeht.Strictsendet es nur bei Same-Site-Anfragen.Laxsendet 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 undfetch-Aufrufen zurück. Das blockiert die meisten Cross-Site-Request-Forgery-Angriffe.Nonesendet es überallhin und erfordertSecure, sonst verwirft der Browser das Cookie komplett. Chrome und Edge behandeln ein Cookie ohneSameSitealsLax; 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:
| |
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:
| |
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
| Daten | Mechanismus | Warum |
|---|---|---|
| Session-Token, Auth-Zustand | HttpOnly + Secure Cookie | Server braucht es; JS darf es nicht lesen können |
| CSRF-Token | Cookie (Double-Submit) oder Server-Session | Muss mit der Anfrage, die es schützt, hin- und herlaufen |
| Theme, Locale, UI-Einstellung | localStorage | Klein, nicht sensibel, soll bestehen bleiben |
| Entwurf eines mehrstufigen Formulars, Wizard-Zustand | sessionStorage | Pro Tab, verzichtbar, kein Server-Roundtrip nötig |
| Warenkorb (nicht authentifiziert) | localStorage, oder serverseitig an ein Session-Cookie gebunden | Hängt davon ab, ob er einen gelöschten Browser überleben muss |
| Große strukturierte Daten, Offline-Cache | IndexedDB | Braucht 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.