Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → JWT и refresh-токены: безопасная схема для веба

JWT и refresh-токены: схема, которая не протекает

Где хранить access и refresh-токены, httpOnly-cookie против localStorage, ротация и reuse detection, отзыв JWT и рабочие компромиссы схемы.

Прямой ответ

Проблема схемы: access_token живёт минуты, но отозвать JWT нельзя (подписан — валиден до exp — сервер «развидеть» не может), а refresh_token живёт недели и крадётся ценнее всего — поэтому безопасная схема строится вокруг трёх решений: где живут токены (доступный JS контекст или нет), ротация refresh (каждое использование выдаёт новый — старый гаснет) и отзыв (серверная привязка, которую можно убить); рабочая схема для браузерного веба 2026 (консенсус OWASP/OAuth 2.1 — Best Current Practice для OAuth): access_token — короткий (5–15 минут), хранится в памяти JS (не localStorage — XSS читает всё — XSS-защита; вкладка закрыта — токена нет — «переживёт F5» — через silent refresh); refresh_token — httpOnly + Secure + SameSite=Lax cookie (JS не видит — XSS не крадёт — путь /auth/refresh — CORS/cookie-механика); ротация обязательна: каждый обмен refresh → новый refresh (старый инвалидируется в хранилище — Redis/БД — структуры Redis) + reuse detection: предъявлен старый (уже ротированный) refresh — тревога: токен украден и используется параллельно — инвалидируем всю семью (family id — все потомки одной сессии — атакующий и пользователь выбывают — пользователь перелогинится, атака сорвана); отзыв: logout/смена пароля/«выйти на всех устройствах» — удаление refresh-записей (access дотлеет 15 минут — приемлемо; «нужен мгновенный отзыв access» — вы в серверных сессиях — SSO-сессии — Redis-сессия — не JWT-иллюзия); алгоритм подписи — EdDSA/RS256 с ротацией ключей по kid (HS256 с секретом в каждом сервисе — секрет расползается — асимметрия: приватный у auth-сервиса, публичные — всем через JWKS — JWT-основы); claims — минимум (sub, exp, iat, jti, роли — НЕ «вся карточка пользователя» — токен шлётся в каждом запросе — раздувание + PII-утечка в логи — структурированные логи — Authorization-заголовок не логируем); alg: none/подмена алгоритма — валидатор фиксирует ожидаемый алгоритм (библиотека с явным allowlist — самописный jwt.decode без options — дыра из учебников — OWASP A07); главный честный вопрос: нужны ли вам JWT вообще — монолит с одной БД — серверные сессии (Redis) проще и отзываемы; JWT окупаются при нескольких сервисах/доменах (проверка подписи без похода в auth-сервис — микросервисы) — «JWT, потому что модно» — усложнение без выгоды.

Механика: ротация, семьи, reuse detection

Реализация refresh-хранилища (Redis-схема): запись {family_id, user_id, token_hash, used: bool, expires_at} — token храним хэшем (sha256 — утёк дамп Redis — токены не раскрыты — как пароли — хэширование-культура); обмен: предъявленный refresh → lookup по хэшу; не найден/истёк → 401 (перелогин); найден, used=true → reuse detected — инвалидация семьи (DEL family:* — все refresh + опционально kill серверных сессий) + алерт в Sentry/метрики (всплеск reuse — активная кража — алертинг); найден, used=false → пометить used, выпустить новый refresh (тот же family_id) + новый access; TTL записи = TTL refresh (30 дней — скользящий: активность продлевает семью, месяц не заходил — естественная смерть — «вечные refresh» — антипаттерн); абсолютный потолок семьи (re-auth через 90 дней независимо от активности — компромисс удобство/безопасность — банковский стандарт строже, B2B мягче); cookie-детали, которые ломают схему на практике: SameSite=Lax (CSRF на refresh-эндпоинт — Lax блокирует кросс-сайт POST — CSRF; None+Secure — только для кросс-доменных SPA — и тогда CSRF-токен обязателен — двойная защита), Path=/auth/ (cookie не улетает в каждый запрос — только в refresh-эндпоинт — меньше поверхность), partitioned-cookie (CHIPS) для встраиваемых сценариев; мобильные приложения — своя специфика (Keychain/Keystore — refresh в безопасном хранилище — не SharedPreferences/UserDefaults — ротация та же); silent refresh (SPA при старте: access в памяти пуст — скрытый запрос к /auth/refresh с cookie — access восстановлен без логина — UX «F5 не выбрасывает»); гонка refresh (10 вкладок одновременно меняют — одна успела, остальные получили 401 — фикс: очередь/мьютекс refresh в клиенте или grace-период старого токена 10 секунд на сервере — «стратегия ротации» без учёта гонок — flaky-логины — частый баг самописа).

Практический сценарий: миграция с «вечного JWT в localStorage»

Исходник: SaaS, auth «как в туториале 2019»: JWT на 30 дней в localStorage, refresh нет, отзыв — «смена секретного ключа (все сессии мрут)»; инцидент-триггер: XSS через npm-зависимость (полифилл с минера — 4 часа — все токены ушли на сторонний домен — зависимости/supply chain); «починили XSS» — но украденные 30-дневные токены остались валидны (отозвать нечем — секрет не сменишь — убьёшь всех) — неделя под риском; редизайн схемы (итерация, 2 спринта): (1) укоротили access до 10 минут (jti в claim — для будущего блок-листа — пока не понадобился — короткие токены отзывать не нужно — «дотлевание» приемлемо по SLO безопасности); (2) ввели refresh-флоу (httpOnly-cookie + ротация + reuse detection — Redis-семьи — схема из раздела выше; валидация — библиотека с явным alg-allowlist — alg:none-тест в негативных кейсах — тесты); (3) клиент: access — in-memory + silent refresh + мьютекс против гонок вкладок (реальный баг первых дней — «двойной F5 — вылет» — grace-период 10 с на сервере закрыл); (4) отзыв: «выйти везде» — kill семьи по user_id; смена пароля — то же + алерт-уведомление пользователю; (5) проверка атаки: стащили refresh через подставной прокси на стенде — предъявили параллельно — reuse detection сработал: семья мертва, алерт в Grafana за 2 минуты — «схема работает» — не на бумаге; (6) миграция пользователей — двойной период (старые 30-дневные токены валидны до естественной смерти, новые — по схеме; «форс-миграция за ночь» — не делали — стратегии деплоя — плавность); итоги: повторный XSS-инцидент (через год, другой вектор — реклама в iframe) — утёк access с 10-минутным окном, refresh в httpOnly не тронут — ущерб — минуты вместо месяцев; вывод в ADR: «localStorage для токенов запрещён; access ≤15 мин; refresh — httpOnly с ротацией; отзыв — семьи в Redis» — новые сервисы наследуют схему (структура — auth-модуль переиспользуем).

FAQ

Почему localStorage для токенов — плохо, если «все так делают»? localStorage читается любым JS на странице (XSS — одна уязвимость в зависимости/виджете = полный доступ к токенам — без единого запроса к серверу — «тихая» кража); httpOnly-cookie для JS невидимы (XSS не может их прочитать — может лишь использовать в запросах с этой страницы — ущерб сессионный, не «токен в руках атакующего навсегда»); «все так делают» — наследие SPA-туториалов (удобство > безопасность — OWASP-позиция однозначна); честный компромисс: access в памяти JS (украдут — 15 минут жизни) + refresh в httpOnly — стандартная связка; «но cookie — CSRF» — да, поэтому SameSite=Lax + CSRF-защита на state-changing эндпоинты — практика — две угрозы закрываются двумя механизмами, не одним.

Как отозвать JWT до истечения exp? Напрямую — никак (в этом смысл stateless-подписи — «отзыв JWT» — оксюморон); обходные пути: короткий exp (10–15 мин — «отзыв» = дотлевание — самый частый честный ответ); blocklist jti в Redis (проверка на каждый запрос — stateless-иллюзия с состоянием — окупается для «kill конкретной сессии» — TTL блоклиста = остаток жизни токена — компактно); смена signing-ключа (ядерная опция — мрут все сессии — «пользователь уволен, ключ скомпрометирован»); версия схемы в claim (auth_version у пользователя — инкремент — все старые токены невалидны при проверке версии — отзыв «по пользователю» с одним lookup — рабочий компромисс); вывод: если отзыв — центральное требование (финтех/админки) — серверные сессии честнее JWT-костылей — SSO-паттерн.

Сколько хранить refresh-токен и что такое «семья токенов»? Срок: 7–30 дней скользящих (активность продлевает) + абсолютный потолок 30–90 дней (re-auth — баланс UX/риск; «refresh на год» — украденный токен живёт год — неоправданно); семья (token family) — цепочка ротаций от одного логина (family_id наследуется при каждой ротации) — нужна для reuse detection: украденный и ротированный владельцем токен при предъявлении атакующим помечен used → вся семья инвалидируется (и копия атакующего, и легитимная цепочка — пользователь перелогинится — атакующий потеряет доступ — RFC 9700-рекомендация — sender-constrained в браузерах недостижим — семьи — браузерный аналог); реализация — строка на refresh в Redis с family_id — паттерны — стоимость — один lookup на обмен.

JWT против серверных сессий — что выбрать для нового проекта? Серверные сессии (Redis): мгновенный отзыв, состояние «здесь и сейчас», проще рассуждать о безопасности; цена — хранилище + lookup на запрос (Redis — доли мс — кэш) и shared-хранилище в горизонтальном масштабе (уже есть почти у всех); JWT: проверка без хранилища (подпись — микросекунды), кросс-доменность/микросервисы без общей сессии; цена — неотзываемость (костыли выше), размер в каждом запросе, PII-риски в claims; правило: монолит/один домен — сессии; много сервисов/доменов/партнёрские API — JWT (+короткий exp + refresh-схема); гибрид — норма: сессия внутри, JWT наружу (BFF — паттерн); спор «JWT или сессии» без контекста архитектуры — бессмыслен — контекст решает.

Источники: RFC 7519 (JWT), RFC 9700 (OAuth Security BCP), OWASP Authentication Cheat Sheet — ссылки в шапке.