Хранение данных
Все клиентские хранилища (localStorage, sessionStorage, IndexedDB, cookies без флага HttpOnly) доступны любому JavaScript-коду на странице.
Правила
Cookies
✅ MUST: Cookies должны иметь флаги:
HttpOnly(защита от JavaScript)Secure(передача только по HTTPS)SameSite=StrictилиSameSite=Lax(защита от CSRF)
LocalStorage / IndexedDB
❌ FORBIDDEN: Хранить следующие данные в localStorage/sessionStorage/IndexedDB:
- Access tokens / Refresh tokens
- Пароли
- Персональные данные (PII): email, телефон, адрес
- Данные платежных карт
- API ключи
- Секретные ключи
✅ MUST: Данные из client storage считаются недоверенными и должны валидироваться (например, через zod или yup)
Что можно хранить
✅ MAY: Хранить в localStorage:
- UI-состояние (тема, язык, размер шрифта)
- Пользовательские настройки интерфейса
- Кеш непубличных данных (с валидацией)
- Состояние драфтов (с шифрованием для чувствительных данных)
Примеры
Запрещенные практики
// ❌ FORBIDDEN: Хранение токенов в localStorage
function login(token: string) {
localStorage.setItem('access_token', token); // Опасно!
}
// ❌ FORBIDDEN: Хранение PII в localStorage
function saveUserData(user: User) {
localStorage.setItem(
'user',
JSON.stringify({
id: user.id,
email: user.email, // PII - опасно!
phone: user.phone, // PII - опасно!
creditCard: user.creditCard, // Критически опасно!
})
);
}
// ❌ FORBIDDEN: Чтение без валидации
function getTheme() {
const theme = localStorage.getItem('theme');
return theme; // Может быть любое значение!
}
Правильный подход
// ✅ Правильно: Хранение только UI-состояния
function saveThemePreference(theme: 'light' | 'dark') {
localStorage.setItem('theme', theme);
}
// ✅ MUST: Валидация данных из localStorage
import { z } from 'zod';
const ThemeSchema = z.enum(['light', 'dark']);
function getThemePreference(): 'light' | 'dark' {
const stored = localStorage.getItem('theme');
const result = ThemeSchema.safeParse(stored);
return result.success ? result.data : 'light';
}
// ✅ Правильно: Токены в HttpOnly cookies (настраивается на сервере)
// HTTP Response Header:
// Set-Cookie: access_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
// Клиентский код не может прочитать HttpOnly cookie
// Токен автоматически отправляется с каждым запросом
Работа с настройками пользователя
// ✅ Типобезопасное хранилище с валидацией
import { z } from 'zod';
const UserPreferencesSchema = z.object({
theme: z.enum(['light', 'dark']),
language: z.enum(['en', 'uk', 'ru']),
fontSize: z.number().min(12).max(24),
notifications: z.boolean()
});
type UserPreferences = z.infer<typeof UserPreferencesSchema>;
const DEFAULT_PREFERENCES: UserPreferences = {
theme: 'light',
language: 'en',
fontSize: 16,
notifications: true
};
export function savePreferences(preferences: UserPreferences): void {
localStorage.setItem('preferences', JSON.stringify(preferences));
}
export function loadPreferences(): UserPreferences {
try {
const stored = localStorage.getItem('preferences');
if (!stored) return DEFAULT_PREFERENCES;
const parsed = JSON.parse(stored);
const result = UserPreferencesSchema.safeParse(parsed);
return result.success ? result.data : DEFAULT_PREFERENCES;
} catch (error) {
console.error('Failed to load preferences:', error);
return DEFAULT_PREFERENCES;
}
}
// Использование
function SettingsPage() {
const [preferences, setPreferences] = useState(loadPreferences);
const updateTheme = (theme: 'light' | 'dark') => {
const newPreferences = { ...preferences, theme };
setPreferences(newPreferences);
savePreferences(newPreferences);
};
return (
<div>
<button onClick={() => updateTheme('dark')}>Dark theme</button>
</div>
);
}
Работа с драфтами
// ✅ Безопасное хранение драфтов
import { z } from 'zod';
const ArticleDraftSchema = z.object({
title: z.string(),
content: z.string(),
tags: z.array(z.string()),
lastSaved: z.number(),
});
type ArticleDraft = z.infer<typeof ArticleDraftSchema>;
export function saveDraft(draft: ArticleDraft): void {
const data = {
...draft,
lastSaved: Date.now(),
};
localStorage.setItem('article_draft', JSON.stringify(data));
}
export function loadDraft(): ArticleDraft | null {
try {
const stored = localStorage.getItem('article_draft');
if (!stored) return null;
const parsed = JSON.parse(stored);
const result = ArticleDraftSchema.safeParse(parsed);
return result.success ? result.data : null;
} catch (error) {
console.error('Failed to load draft:', error);
return null;
}
}
export function clearDraft(): void {
localStorage.removeItem('article_draft');
}
Cookies (серверная настройка)
// Server-side (Next.js API Route или Server Action)
import { cookies } from 'next/headers';
export async function login(credentials: LoginCredentials) {
const { accessToken, refreshToken } = await authenticateUser(credentials);
// ✅ MUST: HttpOnly cookies для токенов
cookies().set('access_token', accessToken, {
httpOnly: true, // Защита от XSS
secure: true, // Только HTTPS
sameSite: 'strict', // Защита от CSRF
maxAge: 3600, // 1 час
path: '/',
});
cookies().set('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 604800, // 7 дней
path: '/api/auth', // Ограничен конкретным путем
});
}
export async function logout() {
// Удаление cookies
cookies().delete('access_token');
cookies().delete('refresh_token');
}
Таблица рекомендаций
| Тип данных | LocalStorage | HttpOnly Cookie | Серверная сессия |
|---|---|---|---|
| Access Token | ❌ FORBIDDEN | ✅ MUST | ✅ MUST |
| Refresh Token | ❌ FORBIDDEN | ✅ MUST | ✅ MUST |
| PII (email, телефон) | ❌ FORBIDDEN | ❌ FORBIDDEN | ✅ MUST |
| Данные карт | ❌ FORBIDDEN | ❌ FORBIDDEN | ❌ FORBIDDEN |
| API ключи | ❌ FORBIDDEN | ❌ FORBIDDEN | ✅ MUST |
| Тема (light/dark) | ✅ MAY | ✅ MAY | - |
| Язык интерфейса | ✅ MAY | ✅ MAY | ✅ MAY |
| Драфты статей | ✅ MAY | - | ✅ MAY |
| UI-настройки | ✅ MAY | - | - |