Dwa modele zagrożeń w autentykacji aplikacji React
Zanim zaczniesz wybierać, zrozum przed czym się bronisz.
— atakujący wstrzykuje swój JavaScript na Twoją stronę (np. przez niezabezpieczone pole formularza, przesłane treści użytkowników albo zewnętrzny skrypt, który został skompromitowany). Gdy to się uda, może odczytać wszystko, co JavaScript widzi, czyli localStorage, sessionStorage, zmienne w pamięci oraz tokeny.
, czyli atak, w którym atakujący zmusza przeglądarkę użytkownika do wysłania żądania do Twojej aplikacji wraz z jego ciasteczkami. Użytkownik jest zalogowany, atakujący ma stronę, która robi <img src="https://yourapp.com/api/transfer?to=attacker&amount=1000">. Jeśli Twoja aplikacja ufa cookie automatycznie wysyłanemu przez przeglądarkę: cyk, przelew został wykonany.
XSS dotyczy Twojej strony, czyli kod wykonuje się w kontekście Twojego originu. CSRF dotyczy obcej strony, która wykorzystuje fakt, że użytkownik jest zalogowany do Ciebie. To dwa różne fronty i nie ochronisz ich jednym zabezpieczeniem.
httpOnly cookie nie pozwala JavaScriptowi odczytać tokenu, ale nie eliminuje skutków XSS: złośliwy kod nadal może wywołać punkty końcowe jako zalogowany użytkownik i odczytać dane dostępne stronie. Cookie wysyłane automatycznie wymaga też ochrony przed CSRF.
localStorage jest podatne na XSS, ale nie na CSRF (atakujący musiałby mieć kontrolę nad Twoim origin).
Gdzie trzymać token w aplikacji React?
1. Token w localStorage
Zalety
- Prostota.
- Token przetrwa odświeżenie strony.
- Działa w każdej przeglądarce.
Wady
XSS = koniec gry. Każdy skrypt na Twojej stronie może odczytać token i wysłać do atakującego i to nawet jeśli dzisiaj masz bezpieczny kod. Wystarczy jedna skompromitowana zależność, np złośliwa aktualizacja popularnej paczki npm. Aktualnie to klasyczny wektor ataku na łańcuch dostaw.
Tokeny przetrwają nawet po wylogowaniu, jeśli zapomnisz jawnie ich usunąć.
Podsumowując, unikaj dla tokenów z długim TTL. Krótki access token w pamięci przeglądarki bywa akceptowalny tylko w prostych SPA bez własnego zaplecza i przy świadomie zaakceptowanym ryzyku XSS. Jeśli masz własny serwer, lepszym punktem wyjścia jest access token w pamięci procesu przeglądarki oraz refresh token w httpOnly cookie.
2. Token w sessionStorage
Praktycznie identyczne jak localStorage pod kątem XSS, ale znika przy zamknięciu karty. Ogranicza trwałość tokenu, nie jest jednak ochroną przed kodem wykonanym w tej samej karcie.
3. Token w httpOnly cookie
Zalety:
JavaScript nie ma dostępu. XSS nie wykradnie tokenu.
Automatyczne wysyłanie w żądaniach (nie musisz ręcznie dodawać nagłówka).
Wady:
CSRF. Przeglądarka automatycznie wysyła cookie, a atakujący z innej strony może wymusić akcję jako zalogowany użytkownik.
Wymaga serwera pod kontrolą. SPA i API na różnych originach mogą działać przez CORS z
credentials, ale wymagają precyzyjnej konfiguracji originów i bywają ograniczane przez politykę ciasteczek zewnętrznych. Prostszym wariantem jest wspólna witryna albo BFF.Problemy z SSR w Next.js, czyli cookie jest stringiem na serwerze, trzeba parsować i przekazywać.
Ochrona przed CSRF:
- SameSite=Strict (lub świadomie dobrane
Lax), czyli bardzo ważna warstwa ochrony, ale też nie do końca, bo nie zastępuje walidacji dla punktów końcowych zmieniających stan, aLaxma wyjątki dla części nawigacji. - CSRF token albo kontrola originu, czyli dla mutacji serwer weryfikuje token przesłany w niestandardowym nagłówku lub poprawny
Origin/Referer; warto też odrzucać żądania cross-site przezSec-Fetch-Site. Pamiętaj, by nie używaćGETdo operacji zmieniających dane.
Werdykt: Rekomendowane dla refresh tokenów i dla aplikacji z własnym serwerem. Wymaga poprawnej ochrony CSRF oraz ochrony aplikacji przed XSS.
4. Token w pamięci w zmiennej JavaScript
Zalety
XSS jest trudniejsze. Atakujący musi wykonać kod w momencie, gdy zmienna istnieje, nie może po prostu odczytać trwałej pamięci przeglądarki.
Token znika przy odświeżeniu strony, więc okno ataku jest mniejsze.
Wady
Odświeżenie strony = wylogowanie. Użytkownik musi się ponownie logować przy każdym odświeżeniu.
Problem wielu kart. Każda karta ma swoją kopię, a logowanie w jednej karcie nie dotyczy drugiej.
Podsumowujac, dobry dla access tokenów w kombinacji z refresh tokenem w httpOnly cookie. Zobacz wzorzec, który jest niżej.
Access token i refresh token: zalecany wzorzec logowania
To częsty, rozsądny wzorzec dla SPA z własnym API, ale nie jedyny właściwy. Jeżeli przeglądarka nie musi widzieć access tokenu, prostsza sesja serwerowa lub BFF z cookie zmniejsza ilość kodu po stronie klienta.
- Access token. Krótki TTL (5–15 min), trzymany w pamięci procesu przeglądarki.
- Refresh token. Długi TTL (7-30 dni), trzymany w httpOnly cookie.
- Ciche odświeżenie tokenu (silent refresh), gdy access token wygaśnie, klient wysyła żądanie do punktu końcowego w formacie
https://api.example.com/refresh, serwer sprawdza refresh token z cookie i zwraca nowy access token.
Warto ograniczyć zasięg refresh cookie przez Path=/refresh (albo pełną ścieżkę punktu końcowego), aby przeglądarka nie wysyłała go do pozostałych punktów końcowych. To dodatkowa warstwa ochrony, a nie granica bezpieczeństwa: nie zastępuje HttpOnly, walidacji CSRF ani poprawnej konfiguracji logów po stronie serwera.
Implementacja access token i refresh token w React
Zatrzymaj się przy refreshPromise, bo to najczęściej pomijany element tego wzorca. Panel może wysłać pięć żądań naraz, wszystkie dostaną 401 i bez single-flight każde uruchomi własne odświeżenie tokenu. Przy rotacji pierwsze odświeżenie unieważnia stary token, a następne wygląda dla serwera jak ponowne użycie zużytego tokenu i może zamknąć rodzinę sesji.
Sama współdzielona obietnica wystarcza tylko w jednej karcie. Refresh cookie jest wspólny dla wszystkich kart, dlatego przykład używa też navigator.locks: serializuje odświeżenia między kartami tego samego originu. Dla przeglądarek bez Web Locks trzeba zapewnić odpowiednik po stronie architektury, np. sesję BFF bez access tokenu w JS, zamiast udawać, że modułowa zmienna rozwiązuje problem globalnie.
Punkt końcowy /refresh i punkty końcowe logowania powinny zwracać Cache-Control: no-store. Serwer ma atomowo rotować token, przechowywać po stronie bazy jego bezpieczny skrót oraz wykrywać użycie tokenu, który został już zastąpiony.
Pierwsze ładowanie i hydration po odświeżeniu aplikacji
Po przeładowaniu strony access token znika, więc przy montowaniu aplikacji próbujemy odświeżyć sesję:
Jeśli użytkownik miał aktywną sesję (refresh token w cookie nadal ważny), dostaje nowy access token i jest zalogowany. Jeśli nie, zobaczy publiczną treść jako osoba anonimowa.
Automatyczne ponowienie w apiFetch dotyczy wyłącznie metod bezpiecznych. Dla POST, PUT, PATCH i DELETE serwer najpierw musi zweryfikować uprawnienia, a klient powinien używać klucza idempotencji tam, gdzie podwójne wykonanie byłoby kosztowne, np. przy płatności lub wysłaniu formularza.
Integracja autentykacji z TanStack Query
apiFetch obsługuje odświeżanie tokenu automatycznie, więc TanStack Query nie musi znać logiki autentykacji. To czysty podział odpowiedzialności. Szczegóły TanStack Query opisuję w osobnym artykule.
Synchronizacja logowania między kartami przeglądarki
Problem: użytkownik ma otwartych 5 kart. Loguje się w jednej, a pozostałe cztery nie wiedzą, że użytkownik jest teraz zalogowany. Wylogowuje się w jednej, a cztery inne wciąż myślą, że ma dostęp.
Rozwiązanie: BroadcastChannel API.
Obsługa zdarzeń jest już w AuthProvider z poprzedniego przykładu. Po zalogowaniu wywołaj broadcastLogin() dopiero po zapisaniu refresh cookie przez serwer. Pozostałe karty dostają jedynie sygnał, a następnie pobierają własny access token przez kontrolowane odświeżenie. Dzięki temu token nie krąży w BroadcastChannel, a blokada z przykładu wyżej nie pozwala kartom użyć jednocześnie tego samego refresh tokenu.
Wylogowanie w aplikacji React krok po kroku
Wylogowanie ma do wykonania kilka rzeczy, bo musi:
- Usunąć access token z pamięci.
- Powiedzieć serwerowi, żeby unieważnił refresh token.
- Wysłać sygnał wylogowania do innych kart.
- Przekierować do
/loginalbo strony głównej.
Blok try/finally nie jest tu ozdobą: bez niego nieudane żądanie do /logout (chwilowy brak sieci, błąd serwera) przerwałoby funkcję i użytkownik, który świadomie kliknął „Wyloguj", zostałby zalogowany dalej. Z punktu widzenia bezpieczeństwa to gorszy scenariusz niż osierocony refresh token po stronie serwera. Sam serwer powinien przy wylogowaniu usuwać refresh token z bazy, bo inaczej skradziony token działa nadal, mimo że użytkownik „się wylogował".
Limity prób i ochrona przed brute force
Kilka zasad, które stosuję niezależnie od frameworka:
- Limit prób na punkcie logowania. Licz równocześnie próby dla konta i adresu IP, stosuj progresywne opóźnienie, a odpowiedzi zachowuj identyczne dla istniejącego i nieistniejącego konta. Sam limit dla adresu IP łatwo ominąć albo wykorzystać do blokady użytkowników za wspólnym NAT-em.
- Hasła i reset hasła. Zapisuj wyłącznie skrót hasła (preferuj Argon2id), token resetu generuj jednorazowo, z krótkim TTL i przechowuj jego skrót. Nie ujawniaj, czy e-mail istnieje w bazie.
- Siła hasła. Minimum 12 znaków i sprawdzanie przez
@zxcvbn-ts/corezamiast ręcznych reguł, a dla kont z danymi wrażliwymi dodaj MFA lub passkeys oraz wymagaj ponownej autentykacji przed krytyczną zmianą. - Dziennik logowań. Kiedy użytkownik się zalogował, z jakiego IP i jakiej przeglądarki. Jeśli ta informacja jest dostępna w interfejsie, użytkownik widzi podejrzane sesje.
- Wszystkie sesje / wylogowanie ze wszystkich urządzeń — użytkownik może unieważnić wszystkie refresh tokeny.
Frameworki i biblioteki do autentykacji w React
Jeśli nie chcesz implementować tego wszystkiego ręcznie:
Auth.js (dawne NextAuth), czyli popularne rozwiązanie dla Next.js, OAuth, linków magicznych i logowania hasłem. Możesz śmiało korzystać z jego mechanizmów sesji i ochrony tras, ale nie zakładaj, że automatycznie rozwiązuje rotację OAuth refresh tokenów. Oficjalna dokumentacja wymaga własnej implementacji i opisuje wyścig przy równoległych odświeżeniach.
Supabase Auth, czyli część platformy Supabase. Wygodne, ale wiąże Cię z całym ekosystemem Supabase (autentykację trudno potem wyjąć osobno).
Clerk. Komercyjny dostawca z bardzo dobrym doświadczeniem developerskim, posiada gotowe komponenty logowania, rejestracji i profilu. To dobry wybór pod szybkie MVP.
Auth0. Opcja korporacyjna, która jest najdroższa i najbogatsza w funkcje (SSO, SAML, zaawansowane polityki).
Dla większości projektów StriveLab używam Auth.js, bo jest darmowe, open-source i bardzo elastyczne.
Typowe błędy przy JWT, sesjach i OAuth
W audytach kodu widuję regularnie te:
1. JWT z długim TTL w localStorage. 30-dniowy token w localStorage oznacza, że po ataku XSS użytkownik może mieć ukradziony dostęp na miesiąc. Użyj access tokenu z krótkim TTL i refresh tokenu w httpOnly cookie.
2. Brak rotacji refresh tokenów. Przy każdym odświeżeniu serwer powinien wydawać nowy refresh token i unieważniać stary atomowo. Użycie już zastąpionego tokenu powinno unieważnić rodzinę sesji i zostać zapisane w audycie.
3. Sekrety w kodzie klienta. apiKey wpisany na stałe w kodzie React trafia do paczki JavaScriptu i każdy go odczyta. Wszystkie sekrety trzymaj po stronie serwera.
4. Traktowanie localStorage jako źródła prawdy. Użytkownik może zmodyfikować localStorage w DevTools. Tokeny muszą być weryfikowane po stronie serwera przy każdym żądaniu. Klient tylko przenosi token, serwer decyduje, czy użytkownik ma dostęp.
5. Brak CSRF przy httpOnly cookies. SameSite jest warstwą ochrony, nie pełnym protokołem autoryzacji żądania. Dla mutacji opartych na cookie dodaj token CSRF lub rygorystyczną kontrolę Origin oraz Sec-Fetch-Site.
6. Niedokładna walidacja JWT. Serwer musi przypinać akceptowane algorytmy i weryfikować podpis, iss, aud, exp oraz nbf. Nie ufaj danym z ładunku tylko dlatego, że token da się zdekodować.
7. OAuth traktowany jak zwykłe przekierowanie. Dla logowania przez Google czy Apple używaj Authorization Code Flow z PKCE, waliduj state i w OIDC nonce, a redirect URI porównuj dokładnie z zarejestrowaną wartością. Sekret klienta i wymianę kodu trzymaj po stronie serwera.
Lista kontrolna produkcyjna autentykacji po stronie klienta
Zanim nazwiesz autentykację „gotową", sprawdzam te rzeczy:
Refresh token jest w
httpOnly,Secure,SameSite=LaxalboSameSite=Strictcookie.- Access token żyje krótko: zwykle 5-15 minut, nie 7 dni.
Refresh token rotuje się przy każdym odświeżeniu, a stary token jest unieważniany.
Serwer wykrywa ponowne użycie starego refresh tokenu i unieważnia całą rodzinę sesji.
Klient odświeża token w trybie single-flight — równoległe 401 nie odpalają kilku odświeżeń naraz.
Mutacje zmieniające dane mają ochronę CSRF obok samego cookie.
Wylogowanie usuwa sesję po stronie serwera, nie ogranicza się do wyczyszczenia stanu klienta.
Wylogowanie w wielu kartach działa przez
BroadcastChannelalbo zdarzenie pamięci przeglądarki.Punkty końcowe logowania, odświeżania tokenu i resetu hasła mają limity prób oraz dziennik audytowy.
Odpowiedzi logowania i odświeżania tokenu mają
Cache-Control: no-store, a tokeny nie trafiają do logów, URL-i ani narzędzi analitycznych.OAuth używa Authorization Code Flow z PKCE oraz poprawnej walidacji
state,noncei redirect URI.Hasła są haszowane przez Argon2id, reset hasła ma jednorazowy token i krótki TTL, a konta wrażliwe oferują MFA lub passkeys.
