decision-jwt-refresh-storage.md 4.4 KB

Decision Record: JWT Refresh Token Storage

Дата: 2026-06-11 Статус: Accepted (требует реализации)

Контекст

Текущая реализация в backend/internal/services/auth.go валидирует refresh токены только по подписи JWT, не сохраняя их в БД. Таблица refresh_tokens создана в миграции 000002_create_users.up.sql но не используется.

Проблема

Риск Последствие
Утечка refresh токена Полный доступ к аккаунту на 30 дней
Нет logout / revoke Пользователь не может завершить сессию на других устройствах
Нет ротации Replay атаки, кража токена не обнаруживается
Смена пароля не инвалидирует сессии Скомпрометированный аккаунт остаётся доступным

Решение

Хранить хеш refresh токена в таблице refresh_tokens с ротацией при каждом использовании.

Схема таблицы (уже существует)

CREATE TABLE refresh_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash VARCHAR(255) NOT NULL,      -- SHA-256(token)
    expires_at TIMESTAMPTZ NOT NULL,        -- now() + 30 days
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at TIMESTAMPTZ                  -- для явного отзыва
);

CREATE UNIQUE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);

Алгоритм

Login / Register:

  1. Генерируем cryptographically secure random token (32 bytes → base64)
  2. Сохраняем в БД: INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, SHA256($2), $3)
  3. Отдаём пользователю: access_token (JWT, 15min) + refresh_token (plain, в HttpOnly cookie)

Refresh:

  1. Читаем refresh token из HttpOnly cookie
  2. SELECT * FROM refresh_tokens WHERE token_hash = SHA256($1) AND expires_at > now() AND revoked_at IS NULL
  3. Если не найден → 401, возможно токен скомпрометирован → revoke all user tokens (security event)
  4. DELETE старый токен (ротация)
  5. Создаём новый refresh token → сохраняем в БД → ставим новую HttpOnly cookie
  6. Генерируем новый access token → возвращаем в теле ответа

Logout / Password Change / Ban:

  1. UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL

Cleanup (cron job):

  1. DELETE FROM refresh_tokens WHERE expires_at < now() - interval '1 day'

Альтернативы (отклонены)

Вариант Почему отклонен
JWT Blacklist в Redis Нужно хранить до истечения (30 дней), нет ротации, сложнее очистка
Короткий refresh (1 день) без БД Плохой UX — частый релогин, не решает проблему утечки
Access token в куке, refresh в localStorage localStorage уязвим к XSS, HttpOnly cookie безопаснее

Последствия

Положительные:

  • Полный контроль сессий
  • Безопасный logout, смена пароля, бан
  • Детекция кражи токена (reuse detection)
  • Соответствие OWASP Auth Cheat Sheet

Отрицательные:

  • +1 запрос к БД при каждом refresh (микросекунды, кэшируется)
  • Нужно мигрировать существующие токены (одноразовый скрипт)
  • Сложнее горизонтальное масширование (требует shared БД — уже есть)

Связанные заметки

  • [[backend-auth-security]]
  • [[frontend-api-client]] — HttpOnly cookie для refresh
  • [[architecture-overview]]

Теги

#decision-record #security #jwt #auth #architecture