Wszystkie wpisy

„Ciastka czy tokeny?" - to pytanie często pojawia się przy rozpoczynaniu nowego projektu, w dyskusji o architekturze API albo podczas code review. Problem polega na tym, że samo pytanie jest źle postawione. Ciastka i tokeny to nie są dwie alternatywne metody uwierzytelniania, między którymi trzeba wybrać. To dwa różne pojęcia, ulokowane na innych poziomach abstrakcji.

Ciastko to mechanizm przeglądarki służący do przechowywania i transportu małych porcji danych między klientem a serwerem. Token to format reprezentacji informacji uwierzytelniającej. Można mieć token w ciastku. W codziennej rozmowie zwrot „uwierzytelnianie ciastkowe" w opozycji do „tokenowego" jednak coś znaczy - bo dwa najpopularniejsze schematy webowe (sesja w ciastku vs Bearer token w nagłówku Authorization) faktycznie się różnią. W tym artykule pokażę obie warstwy: techniczną (czym dokładnie są ciastka i tokeny) i praktyczną (które schematy uwierzytelniania kiedy stosować).

Czym dokładnie jest ciastko

Ciastko (cookie) to fragment danych ustawiany przez serwer (lub przez JavaScript), który przeglądarka zapisuje i automatycznie dolepia do każdego kolejnego żądania kierowanego do tej samej domeny.

Mechanika jest prosta. Serwer w odpowiedzi wysyła nagłówek Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax. Przeglądarka zapisuje tę wartość i przy każdym następnym żądaniu do tej samej domeny dokleja nagłówek Cookie: session=abc123. Deweloper aplikacji nie musi nic robić po stronie klienta - dzieje się to automatycznie, na poziomie zachowania przeglądarki.

Ciastka mają atrybuty kontrolujące, jak się zachowują:

HttpOnly - ciastko niedostępne dla JavaScriptu. To kluczowy atrybut z perspektywy bezpieczeństwa: skrypt na stronie nie może go odczytać ani zmodyfikować, nawet jeśli ktoś wstrzyknie złośliwy kod.

Secure - przeglądarka wyśle ciastko wyłącznie przez HTTPS. W praktyce w 2026 to ustawienie domyślne wszędzie, gdzie nie ma uzasadnienia inaczej.

SameSite (Strict / Lax / None) - czy ciastko ma być wysyłane przy żądaniach inicjowanych z innej domeny. Od 2020 większość przeglądarek przyjmuje Lax jako wartość domyślną, co rozwiązuje większość klasycznych ataków CSRF na poziomie samej platformy.

Domain i Path - zasięg ciastka. Pozwalają ograniczyć je np. tylko do api.example.com albo do /admin.

Max-Age / Expires - czas życia ciastka. Bez tego ciastko jest „sesyjne" - znika po zamknięciu przeglądarki.

To, co najważniejsze: ciastko jest „głupie". Wartość w nim zapisana to po prostu ciąg znaków. O tym, co ten ciąg znaczy - decyduje serwer.

Czym dokładnie jest token

Token to format danych uwierzytelniających - struktura, w której zakodowano informacje o tym, kim użytkownik jest i co może. Najpopularniejszy format to JWT (JSON Web Token), ale używa się też PASETO, opaque tokens i firmowych formatów własnych.

JWT składa się z trzech części oddzielonych kropkami: nagłówek.payload.podpis. Nagłówek mówi, jakim algorytmem token został podpisany (np. HS256, RS256, EdDSA) i jakiego jest typu. Payload zawiera tzw. claimy - identyfikator użytkownika, jego role, czas wygaśnięcia tokena, wystawcę. Podpis pozwala serwerowi zweryfikować, że zawartość nie została zmodyfikowana po wystawieniu.

Drugi format wart wymienienia to PASETO (Platform-Agnostic SEcurity TOkens). Powstał w 2018 roku jako odpowiedź na słabości JWT - przede wszystkim na problem z negocjacją algorytmów w nagłówku. Dwa najgłośniejsze przykłady tej klasy ataków: atak „alg: none", w którym atakujący ustawia w nagłówku JWT algorytm none, czyli „bez podpisu" - źle skonfigurowana biblioteka po stronie serwera akceptuje token bez weryfikacji i wpuszcza atakującego z dowolnymi claimami. Drugi - algorithm confusion - polega na tym, że serwer skonfigurowany do weryfikacji tokenów RSA (algorytm asymetryczny z parą klucz prywatny / publiczny) przyjmuje od atakującego token z nagłówkiem zmienionym na HS256 (algorytm symetryczny z jednym sekretem) i jako sekret próbuje użyć swojego publicznego klucza RSA. Atakujący zna ten klucz (jest publiczny), więc podpisuje token sam i jest wpuszczony.

Do tego dochodzi szersza krytyka standardu JWT: pozwala mieszać silne i słabe szyfry w jednej rodzinie, a wybór algorytmu spoczywa na implementacji biblioteki. PASETO rozwiązuje to wersjonowaniem: każda wersja (v3, v4) ma jeden sztywno przypisany algorytm i nie można go podmienić w nagłówku. Operacje są tylko dwie - local (szyfrowanie symetryczne, np. XChaCha20-Poly1305) i public (podpis asymetryczny, np. Ed25519). W praktyce daje to mniejszą powierzchnię błędów implementacyjnych i nowocześniejszą kryptografię od razu, kosztem znacznie mniejszej liczby gotowych bibliotek niż JWT i słabszego wsparcia w narzędziach (IdP, serwisy chmurowe, gateway'e). Jeśli wybierasz format tokena pod nowy projekt i nie jesteś zmuszony do JWT przez ekosystem - PASETO warto rozważyć.

Trzeci popularny wariant to opaque tokens - czyli zwykłe, losowe ciągi znaków bez żadnej struktury. Serwer wystawia taki ciąg, zapisuje gdzieś u siebie powiązanie token → kontekst użytkownika, a przy każdym żądaniu wyszukuje go w bazie. Nie ma claimów, nie ma podpisu, niczego nie da się z tokena odczytać bez serwera. To wymusza stan po stronie serwera (jak sesja), ale jest też najprostsze do unieważnienia - wystarczy skasować rekord.

Kluczowa rzecz: token to dane. Token sam w sobie nie mówi, w jaki sposób trafia z klienta na serwer. Ten transport to osobna decyzja. Token można:

- Wysłać w nagłówku Authorization: Bearer eyJhbG... (najczęstszy wariant w API).
- Zapisać w localStorage przeglądarki i ręcznie doklejać do żądań.
- Trzymać w pamięci JS (zmienna, gubiona przy odświeżeniu strony).
- Zapisać w ciastku.

Tak, można mieć JWT/PASETO w ciastku - to częsta i sensowna praktyka, łącząca cechy obu mechanizmów. O szczegółach za moment.

Co naprawdę porównujemy w sporze „cookies vs tokens"

Skoro wiemy już, że to nie są kategorie tego samego rzędu, to o czym dyskutujemy w sporze „cookies vs tokens"? W praktyce chodzi o dwa typowe schematy implementacyjne:

Schemat A: Sesja serwerowa identyfikowana przez ciastko

Po zalogowaniu serwer tworzy rekord sesji w bazie albo w Redisie. Identyfikator sesji (losowy ciąg) trafia do ciastka HttpOnly + Secure. Każde kolejne żądanie wysyła to ciastko, serwer szuka sesji po identyfikatorze i odtwarza kontekst. Wylogowanie to po prostu usunięcie rekordu sesji - od tego momentu identyfikator jest bezwartościowy.

Schemat B: Bearer token (najczęściej JWT lub PASETO) w nagłówku Authorization

Po zalogowaniu serwer generuje token (JWT albo PASETO) podpisany swoim kluczem prywatnym lub zsymetryzowanym sekretem. Klient zapisuje go gdzieś u siebie (localStorage, pamięć, ciastko) i przy każdym żądaniu wysyła Authorization: Bearer <token>. Serwer weryfikuje podpis i odczytuje claimy bez zaglądania do bazy - jest bezstanowy (stateless). Wylogowanie to „klient zapomina token" - na serwerze nie ma czego usuwać, chyba że prowadzimy listę unieważnień.

Te schematy faktycznie się różnią - sposobem działania, profilem bezpieczeństwa, skalowalnością, UX. Ale ich opozycja to nie „ciastka vs tokeny" w sensie technologii. To opozycja „stan sesji po stronie serwera" vs „bezstanowe tokeny z podpisem".

Mapa schematów uwierzytelniania

Schemat Stan Transport Dostęp dla JS
Sesja w ciastku Po stronie serwera (Redis / baza) Ciastko HttpOnly Brak (HttpOnly)
JWT/PASETO w nagłówku Authorization Bezstanowy (cały kontekst w tokenie) Authorization header Tak (zwykle localStorage / pamięć)
JWT/PASETO w ciastku HttpOnly Bezstanowy Ciastko HttpOnly Brak (HttpOnly)
Opaque token w nagłówku Po stronie serwera (wyszukanie po tokenie) Authorization header Tak (jeśli klient sam go obsługuje)
Klucz API (Bearer) Po stronie serwera (wyszukanie, długi czas życia) Authorization header N/A (poza przeglądarką)

Patrząc na tę tabelę widać, że „cookies vs tokens" miesza co najmniej trzy niezależne osie: gdzie trzymamy stan, jak transportujemy dane uwierzytelniające (credentials) i czy JS ma do nich dostęp. Decyzja architekturalna to ustalenie pozycji na każdej z tych osi z osobna.

Przykład: aplikacja jednostronicowa i API na backendzie

Pokażę to na scenariuszu, który widujemy w praktyce regularnie. Zespół buduje aplikację jednostronicową (single page application / SPA) opartą o React, Vue albo Angular, która komunikuje się z REST API. Padają pytania: gdzie trzymać token, jak go wysyłać, jak się zabezpieczyć przed XSS i CSRF.

Opcja 1: localStorage + nagłówek Authorization. Token jest w localStorage.setItem('token', ...), a przechwytywacz (interceptor) w kliencie HTTP (axios, fetch wrapper) dokleja go do każdego żądania. Działa od ręki, serwer pozostaje bezstanowy. Wada: każdy fragment JS na stronie ma dostęp do tokena. Jeśli przez XSS ktoś wstrzyknie skrypt - z biblioteki npm, z reklamy, z zewnętrznego widgetu - skrypt wyciąga token i wysyła do siebie. Plus problem z odświeżaniem tokena - po odświeżeniu strony JS musi go odczytać zanim cokolwiek wystartuje.

Opcja 2: Ciastko HttpOnly z identyfikatorem sesji, sesja w Redisie. Klasyczny model. JS nie ma dostępu do ciastka, więc XSS nie ukradnie sesji w warstwie odczytu. Wymaga utrzymywania stanu po stronie serwera (Redis, baza, sesje przypięte do instancji / sticky sessions). CSRF trzeba zaadresować jawnie - przez SameSite=Lax/Strict i ewentualnie CSRF token dla operacji mutujących.

Opcja 3: Ciastko HttpOnly z JWT/PASETO. Łączy zalety - token jest bezstanowy (serwer nie musi pamiętać sesji), ale nie jest dostępny dla JS. Wymaga rozwiązania problemu CSRF (analogicznie jak w opcji 2) i przemyślenia, jak wystawiać i odświeżać tokeny po stronie serwera.

W realnych projektach widujemy mix wszystkich trzech, czasem w jednej aplikacji - inny mechanizm dla web frontu, inny dla mobilnego, inny dla komunikacji service-to-service.

Bezpieczeństwo - CSRF, XSS i kradzież w tle

Cała dyskusja „cookies vs tokens" sprowadza się najczęściej do trzech zagadnień, które bywają mylone: CSRF, XSS i kradzież danych uwierzytelniających przez złośliwe oprogramowanie.

CSRF (Cross-Site Request Forgery)

Atakujący przygotowuje stronę, która z poziomu przeglądarki ofiary wysyła żądanie do twojej aplikacji. Jeśli aplikacja uwierzytelnia przez ciastko, przeglądarka automatycznie dolepi je do żądania - ciastka są wysyłane do swojej domeny niezależnie od tego, kto inicjuje żądanie. Serwer widzi prawidłowe dane uwierzytelniające i wykonuje operację.

Bearer tokeny w nagłówku Authorization są naturalnie odporne na ten atak. Przeglądarka nie dolepia ich automatycznie - JS aplikacji musi je samodzielnie wstawić. Atakujący na obcej domenie nie ma do nich dostępu (cross-origin policy).

Nowoczesne ciastka z atrybutem SameSite=Lax (domyślne w większości przeglądarek od 2020) blokują wysłanie ciastka z innej domeny w większości scenariuszy ataku. To redukuje praktyczne zagrożenie CSRF do specyficznych przypadków (formularze GET, top-level navigation w wyjątkowych konfiguracjach). Dla operacji wrażliwych (zmiana hasła, transfer pieniędzy) i tak zalecamy dodatkowy CSRF token w formularzu / nagłówku.

XSS (Cross-Site Scripting)

Atakujący wstrzykuje JS w stronę. Skrypt działa z pełnymi uprawnieniami strony - może czytać wszystko, do czego JS ma dostęp w aktualnej domenie.

Tokeny w localStorage - skradzione natychmiast. Skrypt odczytuje localStorage.getItem('token'), wysyła wartość na serwer atakującego, atakujący loguje się jako ofiara z innego urządzenia.

Ciastka HttpOnly - niedostępne dla JS. Skrypt może wprawdzie zmusić przeglądarkę do wysłania żądań w imieniu ofiary (bo aplikacja jest w tej samej domenie), ale nie wyciągnie samego ciastka i nie użyje go gdzie indziej. To ważna różnica: atakujący może coś zrobić w trakcie wizyty ofiary na stronie, ale traci dostęp w momencie, gdy ofiara zamknie kartę.

Z perspektywy XSS HttpOnly ciastka są zatem silnie zalecane. Tokeny w localStorage są zawsze podatne - jedyną obroną jest po prostu nie wpuścić XSS-u, co w praktyce wymaga rygorystycznego CSP, audytu zależności i dyscypliny renderowania.

Kradzież w tle (infostealery)

Złośliwe oprogramowanie działające na komputerze użytkownika - kategoria infostealer - ma dostęp do plików przeglądarki, niezależnie czy to Windows, macOS czy Linux. Wyciąga w jednej sesji wszystko: ciastka, localStorage, zapamiętane hasła z menedżera w przeglądarce, tokeny aplikacji desktopowych.

Tu różnica między ciastkiem a tokenem nie ratuje - atakujący dostaje jedno i drugie. Obroną jest po pierwsze szybkie unieważnienie po stronie serwera (identyfikator sesji w ciastku możemy usunąć z Redisa, JWT/PASETO trzeba trzymać na liście unieważnień), po drugie wiązanie sesji z parametrami środowiska (urządzenie, IP, odcisk przeglądarki / fingerprint) z różnym skutkiem. Jeśli czytałeś nasz artykuł o 2FA - to dokładnie ten scenariusz, w którym drugi czynnik nie pomaga, bo atak omija etap logowania.

Porównanie profili bezpieczeństwa

Zagrożenie Sesja w ciastku HttpOnly JWT/PASETO w localStorage JWT/PASETO w ciastku HttpOnly
CSRF Wymaga obrony (SameSite + CSRF token) Naturalnie odporny Wymaga obrony
XSS (kradzież danych uwierzytelniających) Odporna w warstwie odczytu Podatny Odporny w warstwie odczytu
Wylogowanie (natychmiastowość) Natychmiastowe (usuwamy sesję) Wymaga listy unieważnień Wymaga listy unieważnień
Infostealer na maszynie ofiary Podatna Podatny Podatny
Skalowalność (wiele instancji) Wymaga współdzielonego magazynu (Redis) Bezstanowy - łatwa Bezstanowy - łatwa

Kiedy co wybrać

Nie ma jednej uniwersalnej odpowiedzi. Wybór zależy od typu aplikacji, modelu zagrożeń i wymagań operacyjnych.

Klasyczna aplikacja webowa (renderowana po stronie serwera, jeden backend, jedna domena). Sesja w ciastku HttpOnly + Secure + SameSite=Lax, sesja po stronie serwera (Redis albo wprost w bazie). Sprawdzona kombinacja, łatwa do unieważnienia, prosta w utrzymaniu. CSRF zaadresowany przez SameSite + dodatkowy token dla operacji wrażliwych.

Aplikacja jednostronicowa + REST API na tej samej domenie (np. app.example.com i api.example.com z example.com jako wspólnym ETLD+1). Najczęściej JWT/PASETO w ciastku HttpOnly z CSRF tokenem, albo sesja w Redisie - decyzja zależy od skali i tego, jak ma działać wylogowanie. Dla większości startupów i średnich aplikacji wystarcza sesja.

Aplikacja jednostronicowa + API na innej domenie (np. frontend.com i api.frontend-corp.com). Ciastka cross-domain są upierdliwe i wymagają SameSite=None + Secure. Tu często łatwiej iść w bearer token, akceptując kompromisy bezpieczeństwa (i nakładając im rygorystyczny CSP, krótki czas życia, tokeny odświeżania).

Aplikacja mobilna, desktopowa lub konsolowa (CLI) korzystająca z API. Bearer token w nagłówku Authorization. Ciastka są niewygodne w środowiskach bez przeglądarki. Token trzymamy w bezpiecznym magazynie systemu - Keychain na macOS i iOS, Credential Manager na Windows, libsecret/GNOME Keyring na Linux, Keystore na Androidzie.

Mikroserwisy i komunikacja między usługami. JWT/PASETO albo tokeny dostępu OAuth2. Często mTLS dla wewnętrznych granic - certyfikat klienta jako dodatkowa warstwa. Ciastka tu praktycznie nie występują, bo nie ma użytkownika w przeglądarce.

Publiczne API dla integratorów. Klucze API (długi czas życia) albo OAuth2 z tokenami dostępu i tokenami odświeżania. Ciastek nie używa się przy integracji programistycznej.

Architektura w trzech wymiarach

Cała dyskusja sprowadza się do trzech niezależnych decyzji. Warto je rozdzielić w głowie, bo wtedy wybór staje się systematyczny zamiast intuicyjnego.

Wymiar 1 - stan. Czy serwer pamięta sesję, czy nie?

Sesja serwerowa: stan żyje na serwerze, klient ma tylko wskaźnik (identyfikator sesji). Wylogowanie jest natychmiastowe - kasujemy rekord i ten sam identyfikator staje się bezwartościowy. Cena: musimy mieć współdzielony magazyn sesji, a każde żądanie wymaga jednego wyszukania w nim.

Bezstanowe tokeny (JWT/PASETO): cały kontekst zakodowany w tokenie, podpisany. Serwer weryfikuje podpis i ufa zawartości. Skalowalność świetna (każda instancja sprawdza token niezależnie), ale wylogowanie wymaga listy unieważnień - bo wystawionego tokena nie można odebrać.

Wymiar 2 - dostęp. Czy JavaScript ma dostęp do danych uwierzytelniających?

Ciastko HttpOnly: niedostępne dla JS. Tylko przeglądarka i serwer widzą zawartość. Odporne na XSS w warstwie odczytu, ale wymaga obrony przed CSRF.

localStorage / sessionStorage: dostępne dla każdego skryptu w domenie. Wygodne (łatwy odczyt po odświeżeniu strony), ale każdy XSS to natychmiastowa kradzież.

W pamięci JS (zmienna w aplikacji): dostępne tylko dla bieżącej karty, gubione przy odświeżeniu. Najbezpieczniejsze, ale wymaga osobnego mechanizmu odzyskiwania sesji po odświeżeniu (np. cichego odświeżenia tokena przez ciastko HttpOnly).

Wymiar 3 - transport. Jak dane uwierzytelniające trafiają na serwer?

Ciastko: automatyczny dolot przy każdym żądaniu do domeny (zachowanie przeglądarki). Wygodne, ale właśnie ta automatyczność leży u podstawy ataków CSRF.

Nagłówek Authorization: ręczne wstawianie przez kod aplikacji. Atakujący na obcej domenie nie może go wstawić, ale po stronie aplikacji wymaga przechwytywacza (interceptora) w kliencie HTTP.

Wybór technologii to ustalenie pozycji na tych trzech osiach. Klasyczna „sesja w ciastku" to: stan po stronie serwera, brak dostępu dla JS, automatyczny transport. Klasyczny „JWT/PASETO w localStorage" to: brak stanu, pełen dostęp dla JS, ręczny transport. „Cookies vs tokens" miesza wymiary - bo cookie odpowiada na pytanie o transport, a JWT czy PASETO na pytanie o format danych.

Podsumowanie

Ciastko to mechanizm transportu, token to format danych. To kategorie różnego rzędu i można mieć token w ciastku - to nawet często sensowna konfiguracja. Realny wybór architekturalny dotyczy trzech osi: gdzie trzyma się stan, czy JavaScript ma dostęp do danych uwierzytelniających i jak są one transportowane.

Z perspektywy bezpieczeństwa: ciastka HttpOnly chronią przed kradzieżą przy XSS, ale wymagają obrony przed CSRF. Tokeny w localStorage są naturalnie odporne na CSRF, ale każda luka XSS to natychmiastowa kradzież. Żadna z tych metod nie chroni przed infostealerem działającym na maszynie ofiary - tam pomaga dopiero szybkie unieważnienie po stronie serwera i monitoring podejrzanych sesji.

W praktyce: dla aplikacji webowej z przeglądarką prawie zawsze chcemy ciastka HttpOnly + SameSite + krótki czas życia tokena/sesji. Dla klientów mobilnych i komunikacji między usługami - bearer token w nagłówku Authorization. Dla publicznych API - klucze albo OAuth2.

Projektujesz API i nie wiesz, czy iść w sesje czy w JWT/PASETO? Audytujesz aktualną konfigurację uwierzytelniania w aplikacji webowej? Zastanawiasz się, jak bezpiecznie przechowywać tokeny w aplikacji mobilnej albo desktopowej? Odezwij się do nas - pomożemy zaprojektować mechanizm uwierzytelniania dopasowany do twojej architektury, modelu zagrożeń i wymagań operacyjnych.