Перейти к основному содержимому

Хранение данных (Client-side Storage)

Все клиентские хранилища (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');
}

Таблица рекомендаций

Тип данныхLocalStorageHttpOnly 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--

📌 Ключевые моменты:

  • Client storage доступен любому JS-коду на странице
  • FORBIDDEN: Хранение токенов и PII в localStorage
  • MUST: Токены только в HttpOnly cookies
  • MUST: Валидация всех данных из storage (через zod)
  • MUST: Cookies с флагами: HttpOnly, Secure, SameSite