Gdy autoryzacja JWT staje się zależna od stanu: argumenty za sesjami po stronie serwera w Node
Listy blokujące oraz mechanizmy odświeżania i przechowywania danych sprawiają, że autoryzacja JWT jest mimo wszystko zależna od stanu. Sesje w Express z bazą danych Postgres w Node.js są prostsze i nadal dobrze sprawdzają się w przypadku wykorzystania JWT.
Źródła: Forget JSON Web Tokens: Server-Side Sessions Cut Our Auth Bugs By 70%
Autoryzacja JWT bez stanu wygląda elegancko na tablicy, ale wiele zespołów odkrywa, że logowanie wyjście, przymusowe wylogowanie i resety haseł po cichu przywracają stan. Gdy do gry wchodzą lista blokad anulowań, magazyn tokenów odświeżania oraz niestandardowe middleware, warstwa autoryzacji staje się najbardziej kruchą częścią systemu. Ten artykuł omawia ten wzorzec awarii, pokazuje, jak konfiguracja sesji oparta na Postgresie w Express zastępuje go, oraz wyjaśnia nieliczne przypadki, w których JWT pozostaje lepszym narzędziem.
Znany wzorzec awarii
Załóżmy zespół, który jest dumny ze swojego rozwiązania JWT, dopóki kolejka wsparcia się nie wypełni. Użytkownicy są logowani wybiórczo, sprostowania haseł przestają działać, a jeden klient składa trzy zgłoszenia w ciągu jednego dnia, z których każde opisuje ten sam paradoks: jest on jednocześnie zalogowany i niezalogowany. Inżynierowie spędzają cztery godziny na rozpatrywaniu tego jednego zgłoszenia, dekodując jego token i sprawdzając datę jego wygaśnięcia – wszystko wydaje się poprawne.
Takie błędy zazwyczaj pojawiają się w szczelinach pomiędzy poszczególnymi mechanizmami, dodawanych w celu naprawy ograniczeń poprzednich.
Dlaczego „stateless” rzadko pozostaje stateless
Standardowe uzasadnienie dla JWT jest kuszące. Nie ma potrzeby przechowywania sesji ani wyszukiwania w bazie danych: wystarczy umieścić identyfikator użytkownika w treści wiadomości, podpisać ją i przekazać klientowi, pozwalając samemu tokenowi być źródłem prawdy.
Kluczowym problemem jest anulowanie tokenów. Podpisany token pozostaje ważny do momentu wygaśnięcia, a serwer nie posiada wbudowanego sposobu na jego wcześniejsze unieważnienie. Wylogowywanie użytkownika wszędzie, reagowanie na skradziony token lub unieważnianie dostępu po zmianie hasła wymaga listy blokad tokenów, która jest dokładnie tym rodzajem wspólnego stanu, który projekt miał na celu wyeliminowanie.
W opisanym powyżej scenariuszu zespół musi używać listy blokad tokenów w Redis dla anulowanych tokenów, oddzielnego magazynu dla tokenów odnowienia oraz specjalnej warstwy middleware, która łączy je ze sobą. System, który został przyjęty ze względu na brak stanu, stał się najbardziej zorientowanym na stan komponentem w architekturze.
Co daje kontrola na poziomie każdej prośby
Społeczna wersja middleware, która uruchamia się przy każdej prośbie, wygląda w ten sposób. Pobiera token nośny z nagłówka Authorization, sprawdza go w liście blokad Redis, a następnie weryfikuje podpis oraz zawartość tokena za pomocą jwt.verify.
// verify access token
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "No token" });
// check if revoked
const revoked = await redis.get(`revoked:${token}`);
if (revoked) return res.status(401).json({ error: "Token revoked" });
jwt.verify(token, process.env.SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: "Invalid token" });
req.user = decoded;
next();
});
}
W obecnej formie funkcja w ogóle nie mogłaby zostać uruchomiona: używa await bez deklaracji jako async. Większym problemem jest aspekt architektoniczny – każda prośba teraz wymaga połączenia sieciowego z Redis, przez co znika główna zaleta JWT, jaką jest uniknięcie konieczności wyszukiwania informacji. Dodatkowo ponosi się koszt obliczeniowy związany z weryfikacją tokena oraz osobną kontrolą terminu ważności i zawartości.
Błędy kryją się w przerwach pomiędzy tymi trzema sprawdzeniami:
- Token dostępu wygasa, a próba odnowienia przebiega bez żadnych komunikatów o błędzie.
Każdy z tych problemów wymaga naprawy na innej warstwie, a naprawa na jednej warstwie może łatwo zniszczyć założenia użyte w innej.
Zastąpienie trzech mechanizmów jednym magazynem sesji
Aльтernatywą są sesje po stronie serwera. Klient przechowuje tylko nieprzejrzysty identyfikator sesji w pliku cookie, a dane sesji znajdują się w bazie danych. W Express biblioteka express-session w połączeniu z magazynem connect-pg-simple przechowuje sesje w Postgresie:
const session = require("express-session");
const PgSession = require("connect-pg-simple")(session);
app.use(session({
store: new PgSession({ pool: db }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
}
}));
Kilka ustawień odpowiada za większość zadań związanych z bezpieczeństwem. Atrybut httpOnly chroni ciasteczko przed dostępem JavaScriptu na stronie, co utrudnia kradzież tokenów poprzez XSS. Atrybut secure gwarantuje, że dane są wysyłane wyłącznie przez HTTPS. Ustawienie resave: false zapobiega zapisywaniu niezmienionych sesji przy każdej prośbie, a saveUninitialized: false uniemożliwia wypełnianie bazy danych pustymi sesjami odwiedzających anonimowych. Wartość maxAge określa siedmiodniowy czas trwania sesji. Należy pamiętać, że narzędzie connect-pg-simple wymaga istnienia tabeli sesji, więc należy ją stworzyć w ramach migracji.
Dalsze operacje stają się wtedy niemal błahe:
- Logowanie ustawia wartość
req.session.userId. - Wylogowanie wywołuje metodę
req.session.destroy(). - Przymusowe wylogowanie usuwa wiersze użytkownika z tabeli sesji.
Anulowanie odbywa się teraz poprzez pojedyncze usunięcie, bez konieczności uwzględniania terminu wygaśnięcia listy blokad, co zapobiega rozbieżnościom, a każda prośba odczytuje tę samą autorytatywną informację. Warto dodać dwa kroki wzmacniania bezpieczeństwa: wywołać metodę req.session.regenerate() podczas logowania, aby zapobiec fiksacji sesji, oraz ustawić atrybut sameSite w pliku cookie (plus ochronę CSRF tam, gdzie jest to konieczne), ponieważ pliki cookie są wysyłane automatycznie przez przeglądarkę w sposób, w jaki to nie dotyczy nagłówków nośnych.
W opisanym tu scenariuszu migracja usunęła około 340 linijek logiki autoryzacji, a każda usunięta linia oznaczała mniej miejsc, w których błąd mógłby się ukryć.
Mierzenie zmian
Aby obiektywnie ocenić tę zmianę, zespół śledził każdy raport o błędzie związany z autoryzacją przez 90 dni przed migracją i przez 90 dni po niej. Otrzymane przez nich dane to:
Before (JWT):
- Auth bugs reported: 43
- Avg debug time per bug: 2.4 hours
- Force-logout capability: not possible
After (Sessions):
- Auth bugs reported: 13
- Avg debug time per bug: 0.6 hours
- Force-logout time: < 1 second (delete row)
Reduction: ~70%
Liczba zgłoszeń błędów spadła z 43 do 13, co stanowi około 70% redukcji, a średni czas debugowania na błąd zmniejszył się z 2,4 godziny do 0,6. Wylogowanie przymusowe przestało być niemożliwe i teraz polega na usunięciu wiersza w czasie krótszym niż sekunda. Są to dane jednego zespołu, a nie punkt odniesienia.
Zwykłym zarzutem jest wyszukiwanie w bazie danych przy każdej prośbie. W większości aplikacji odczyt klucza głównego z tabeli sesji jest tani w porównaniu z zapytaniami, które już wykonywane są przy typowej prośbie, więc prawdziwy wąskie gardło znajduje się niemal zawsze gdzie indziej.
Porównanie dwóch ścieżek żądań
Gdy przedstawić je obok siebie, różnica jest oczywista. W ścieżce JWT serwer dekoduje token, żąda listy blokad z Redis i weryfikuje termin ważności oraz zawartość tokena przed uruchomieniem obsługi ścieżki:
Client --> [Token in header] --> Server
|
Decode token (CPU)
|
Check Redis blocklist (network)
|
Validate expiry + claims
|
Route handler
W ścieżce sesji serwer odczytuje sesję z Postgresa, a następnie przekazuje ją do obsługi ścieżki:
Client --> [Session cookie] --> Server
|
Lookup session in Postgres
|
Route handler
Jedno sprawdzenie w jednym źródle prawdy oznacza, że gdy coś się zepsuje, istnieje dokładnie jedno miejsce do sprawdzenia.
Gdzie nadal pasują JWT
To wszystko nie czyni JWT złymi. Są one doskonałe do krótkotrwałych tokenów o określonym przeznaczeniu: linków do weryfikacji e-maili, procesów resetowania haseł oraz innych samodzielnych dowodów intencji, które wygasają w ciągu kilku minut i nigdy nie wymagają anulowania w trakcie użycia. Token weryfikacyjny, taki jak ten poniżej, zawiera identyfikator użytkownika i jego przeznaczenie oraz wygasa po 15 minutach:
// email verification — expires fast, never needs revocation
function makeVerifyToken(userId) {
return jwt.sign({ userId, purpose: "verify" }, process.env.SECRET, {
expiresIn: "15m"
});
}
Włączenie informacji o celu jest istotne, ponieważ zapobiega przyjęciu tokena weryfikacyjnego tam, gdzie oczekiwany jest inny typ tokena. Należy pamiętać, że sam JWT nie może gwarantować użycia go tylko raz; jeśli link do sprostowania musi działać wyłącznie jeden raz, należy zarejestrować jego użycie lub powiązać token z wartością, która zmienia się po użyciu, taką jak hasło w formie hashu.
Problemem nigdy nie były same specyfikacje. Problemem było ich rozszerzanie w celu objęcia nimi trwałych, odwoływalnych sesji użytkowników o długim czasie życia, do czego właśnie zostały zaprojektowane sesje.
Dlaczego zespoły trzymają się złożonych rozwiązań autoryzacji
Sesje wydają się przestarzałe, natomiast JWT są postrzegane jako nowoczesne, a tutoriali na temat JWT znacznie przewyższają liczbą te dotyczące sesji. To nierównowaga kształtuje to, co uważa się za poprawne rozwiązanie.
Doświadczeni inżynierowie często postępują w przeciwnym kierunku i wybierają nudne rozwiązania. Wystarczająco wielu incydentów produkcyjnych uczy ich, że nudne systemy rzadko powiadamiają kogokolwiek w nocy, a sesje po stronie serwera sprawdzały się niezawodnie od dziesięcioleci, długo przed pojawieniem się Redis.
Wybór podejścia
- Rozpoczynasz nową aplikację internetową? Wybierz sesje po stronie serwera: plik cookie
httpOnly,secure,sameSite, sesje przechowywane w Postgres, regeneracja sesji przy logowaniu i jej usuwanie po wylogowaniu. Aby uzyskać szersze porównanie, zapoznaj się z naszym przewodnikiem na temat wyboru między sesjami a JWT. - Korzystasz z rozwiązania opartego na JWT, które działa poprawnie? Nie zmieniaj nic; migracja tylko dla samej migracji zwiększa ryzyko.
Najlepszą decyzją inżynieryjną jest często ta, która polega na usunięciu kodu. Mniej linii oznacza mniej miejsc na błędy, a mniej warstw ułatwia szybsze ich znalezienie.