Endpoint logowania odsyła JWT, a Ty masz trzy miejsca, w których możesz go trzymać: cookie, localStorage albo sessionStorage. Wybierzesz źle i albo błąd XSS wyniesie token na zewnątrz, albo token nigdy nie dotrze do Twojego API. Wszystkie trzy mechanizmy przechowują stringi w przeglądarce. Różnią się tym, kto może je odczytać, jak długo żyją i czy serwer w ogóle je widzi.
Czym różnią się localStorage, sessionStorage i cookies
localStorage i sessionStorage to ten sam interfejs, Storage: setItem, getItem, removeItem, clear, klucze i wartości w postaci stringów. Różnica leży w czasie życia. localStorage przetrwa zamknięcie przeglądarki i restart komputera, i zostaje tam, dopóki coś go nie wyczyści. Safari jest wyjątkiem: z włączonym tracking prevention usuwa dane zapisane przez skrypt dla strony, z którą użytkownik nie miał kontaktu od siedmiu dni. sessionStorage należy do jednej karty i umiera, gdy ta karta się zamyka. Wpisz ten sam adres w drugiej karcie i dostaniesz nowy, pusty sessionStorage — tylko opcja “Duplikuj kartę” kopiuje go dalej.
Cookies są starsze i działają inaczej. Serwer ustawia je przez header Set-Cookie w odpowiedzi, a od tej chwili każdy pasujący request niesie je automatycznie jako header Cookie. To obejmuje też requesty o obrazki i arkusze stylów oraz wywołania fetch w ramach tego samego origin, których nigdy nie konfigurowałeś do wysyłania czegokolwiek. localStorage i sessionStorage nigdy nie opuszczają przeglądarki, jeśli Twój JavaScript nie odczyta ich i nie wyśle gdzieś dalej.
Wysyłane automatycznie kontra nigdy nie wysyłane — ta jedna właściwość rozstrzyga większość pytań “które wybrać”.
Limity pamięci i trwałość danych w porównaniu
| Cookies | localStorage | sessionStorage | |
|---|---|---|---|
| Wysyłane do serwera | Tak, przy każdym pasującym request | Nie | Nie |
| Pojemność | ~4KB na cookie | ~5-10MB na origin (zależnie od przeglądarki) | ~5-10MB na origin |
| Trwałość | Ustalana przez Expires/Max-Age; bez nich to cookie sesyjne | Do wyraźnego wyczyszczenia | Do zamknięcia karty |
| Zakres | Domena + ścieżka (konfigurowalne) | Origin (schemat + host + port) | Origin, i tylko bieżąca karta |
| Odczyt z JS | Tak, o ile nie ma HttpOnly | Zawsze | Zawsze |
| API | String w header albo document.cookie | Storage (synchroniczne) | Storage (synchroniczne) |
Różnica w pojemności to powód, dla którego koszyki zakupowe i bloby z ustawieniami nie trafiają do cookies. 4KB kończy się szybko, gdy zaczniesz zapisywać JSON, a każdy bajt jest ponownie wysyłany przy każdym request, niezależnie od tego, czy endpoint go potrzebuje. Strona, która ładuje 40 statycznych zasobów z tej samej domeny z 3KB cookie podczepionym do request, wysyła to cookie 40 razy.
Jak odczytywać i zapisywać każdy z nich w JavaScript
localStorage i sessionStorage używają tych samych wywołań:
| |
Oba przechowują tylko stringi. setItem("user", {name: "Dan"}) zapisze literalny string "[object Object]". Najpierw zserializuj:
| |
Z JavaScriptu cookie przechodzi przez document.cookie:
| |
Wygląda jak przypisanie, które nadpisuje wszystko. Nie jest. Każde przypisanie ustawia albo aktualizuje jedno cookie, a odczytanie document.cookie zwraca wszystkie cookies danej strony jako jeden string połączony ; . Dzielisz go samodzielnie po ; , a potem po pierwszym =, albo używasz małej biblioteki, która robi to za Ciebie.
Większość cookies ustawia zamiast tego serwer, przez header odpowiedzi. To też jedyny sposób, by uzyskać ochrony istotne dla wszystkiego, co jest wrażliwe:
| |
To wywołanie tworzy header odpowiedzi w stylu:
| |
HttpOnly, Secure i SameSite: co czyni cookie bezpiecznym
HttpOnlyusuwa cookie zdocument.cookiecałkowicie. JavaScript na stronie nie może go odczytać, i dotyczy to również wstrzykniętego skryptu atakującego. To główny powód, by token sesji trzymać w cookie, a nie wlocalStorage: błąd XSS, który wykonuje dowolny JS, nadal nie wyciągnie cookie zHttpOnly.Secureblokuje wysyłanie cookie przez czysty HTTP; wychodzi tylko przez HTTPS. Ustawiaj je na wszystkim w produkcji. Zobacz artykuł Co to Jest TLS i Jak Działa Szyfrowanie HTTPS, żeby ocenić wartość tej ochrony.SameSitekontroluje, czy cookie leci razem z cross-site request.Strictwysyła je tylko gdy request pochodzi z tej samej strony.Laxnadal wysyła je przy nawigacji GET na najwyższym poziomie, więc kliknięcie linku z e-maila wciąż Cię loguje, ale wstrzymuje cookie przy cross-site POST i wywołaniachfetch. To blokuje większość ataków cross-site request forgery.Nonewysyła cookie wszędzie i wymagaSecure, inaczej przeglądarka je po prostu odrzuci. Chrome i Edge traktują cookie bezSameSitejakoLax; Firefox i Safari nie, więc ustawiaj to jawnie.
localStorage i sessionStorage nie mają niczego podobnego do tych mechanizmów. Wszystko, co tam umieścisz, jest odczytywalne przez każdy skrypt działający na Twoim origin: Twój własny kod, zainfekowaną zależność z npm, payload atakującego po dziurze XSS. Cookies nie są bezpieczniejsze z natury. Mają opt-in tryb, do którego JavaScript nie ma dostępu, a localStorage go nie ma. To cały argument przeciw temu, by trzymać token uwierzytelniający w localStorage.
Dlaczego cookies nie są wysyłane przy fetch cross-origin
Strona na app.example.com wywołuje api.example.com przez fetch. Cookie ustawione przez api.example.com zostaje w domu, nawet z poprawną domeną na cookie, bo fetch domyślnie wysyła credentials tylko do tego samego origin. Musisz się na to zgodzić explicite:
| |
Serwer też musi współpracować. Gdy w grę wchodzą credentials, nie może odpowiadać wildcardem Access-Control-Allow-Origin: *. Musi odesłać konkretny origin i dodać Access-Control-Allow-Credentials: true. To negocjacja CORS opisana w artykule Czym Jest CORS i Jak Naprawić Błędy CORS, i to tam żyje większość bugów typu “moje cookie się nie pojawia”.
Jak synchronizować localStorage między kartami przez storage event
Gdy localStorage się zmienia, przeglądarka wywołuje event storage w każdej innej karcie na tym samym origin, nie w karcie, która wprowadziła zmianę. To pozwala wylogować użytkownika wszędzie w chwili, gdy jedna karta wywołuje localStorage.removeItem("token"):
| |
sessionStorage nigdy nie wywołuje tego eventu między kartami. Żadna inna karta go nie zobaczy.
Kiedy nie używać cookies, localStorage ani sessionStorage
Cookies są złym wyborem dla wszystkiego powyżej kilku kilobajtów i dla danych, których serwer nigdy nie czyta, jak temat UI albo niedokończony komentarz. Wysyłałbyś to na nowo przy każdym request bez żadnego powodu.
localStorage jest złym wyborem dla wszystkiego, co musi przetrwać błąd XSS — token sesji, klucze API, cokolwiek, czego nie chciałbyś zobaczyć w raporcie z wycieku z adnotacją “atakujący wykonał console.log(localStorage)”. Jest też złym wyborem dla danych, na których serwer coś robi. Nigdy nie opuszcza przeglądarki, jeśli Twój kod ich nie wyśle, a w chwili, gdy napiszesz taki kod, odtworzyłeś cookie bez HttpOnly, Secure czy SameSite.
sessionStorage jest złym wyborem dla wszystkiego, co ma być współdzielone między kartami albo ma przetrwać zamknięcie i ponowne otwarcie przeglądarki. Pasuje do jednego typu problemu: stanu, który należy do tej karty, dla tej wizyty. Bieżący krok wieloetapowego checkoutu to klasyczny przypadek, bo nie powinien wyciekać do drugiej karty, którą użytkownik otworzył, by porównać ceny.
Powyżej kilku megabajtów, albo gdy potrzebujesz zapytań zamiast wyszukiwania po kluczu, żaden z tych trzech mechanizmów nie pasuje. Użyj IndexedDB, zanim spróbujesz wcisnąć kilka tysięcy rekordów JSON do localStorage i trafisz na limit.
Który mechanizm wybrać: token, koszyk czy preferencje
| Dane | Mechanizm | Dlaczego |
|---|---|---|
| Token sesji, stan uwierzytelnienia | Cookie HttpOnly + Secure | Serwer go potrzebuje; JS nie może go odczytać |
| Token CSRF | Cookie (double-submit) albo sesja po stronie serwera | Musi wrócić razem z request, który chroni |
| Temat, język, preferencja UI | localStorage | Małe, niewrażliwe, powinno przetrwać |
| Szkic formularza wieloetapowego, stan wizarda | sessionStorage | Per karta, jednorazowe, bez potrzeby round-tripu do serwera |
| Koszyk zakupowy (niezalogowany) | localStorage albo po stronie serwera powiązany z cookie sesji | Zależy, czy musi przetrwać wyczyszczenie przeglądarki |
| Duże dane strukturalne, cache offline | IndexedDB | Potrzebuje pojemności i zapytań, których żaden z trzech nie daje |
Jeśli serwer musi temu ufać, umieść to w cookie HttpOnly, Secure, z jawnie ustawionym SameSite. localStorage zostaw dla danych, które spokojnie powierzyłbyś każdemu skryptowi na swojej stronie.