Модели сессий: opaque vs JWT, один сервис против многих

С чего начинается авторизация: серверные opaque-сессии против stateless JWT, stateful vs stateless, отзыв и ревокация, access и refresh, где хранить токены на клиенте — и когда достаточно сессии в Redis, а когда нужен выделенный auth

Авторизация почти всегда сводится к одному вопросу: как сервер понимает, что запрос пришёл от уже вошедшего пользователя. Ответов два семейства — хранить состояние сессии на сервере и выдавать клиенту непрозрачный (opaque) идентификатор, либо упаковать состояние в подписанный токен (JWT) и не хранить его на сервере вообще. Выбор между ними определяет почти всё остальное: как устроен отзыв, что происходит при нескольких сервисах, где хранить токен на клиенте и как обновлять его.

Это первая, обзорная статья серии «Авторизация: токены, сессии, refresh». Дальше мы разберём оба подхода на реальном опыте — простой (один сервис, opaque-сессии в Redis) и сложный (много сервисов, JWT + refresh). Здесь — система координат.

Ретрофутуристская схема-развилка «Полдень. XXI век»: слева OPAQUE — глухая тёмная карточка с номером 4287 и картотека сессий на сервере, отзыв мгновенный (выдернул карточку); справа JWT — прозрачная карточка с полями iss/aud/exp/roles и штемпелем подписи, проверяется публичным ключом, отзыв только по TTL; внизу выбор архитектуры: один сервис → opaque-сессия в Redis, много сервисов → выделенный auth + JWT

В статье

Что такое сессия и что мы на самом деле выбираем

Сначала развяжем два слова, которые часто путают. Аутентификация — сервер убедился, кто перед ним (проверил код из письма, пароль, подпись passkey). Авторизация — решение, что этому «кому-то» позволено. Логин — это аутентификация; сессия — то, что избавляет от повторной аутентификации на каждом следующем запросе. HTTP не помнит клиента между запросами, поэтому после успешного входа сервер выдаёт клиенту токен сессии, и дальше клиент предъявляет его вместо того, чтобы каждый раз вводить код заново.

Токен сессии — это предъявительский секрет (bearer token): кто им владеет, тот и «вошёл». Отсюда два жёстких требования, независимо от модели:

  • Энтропия. Токен должен быть непредсказуем — минимум 128 бит из криптографического генератора. В стенде opaque-идентификатор — это token.OpaqueHex(16) (16 байт из crypto/rand → 32 hex-символа); refresh — 32 байта. Угадать или перебрать такой токен нельзя.
  • Ограниченный срок (TTL). Утёкший токен не должен работать вечно. У сессий стенда TTL — от 5 минут у одноразового кода до 168 часов у refresh.

А вот дальше начинается развилка, ради которой и написана эта серия. Есть ровно два ответа на вопрос «где живёт состояние сессии»: на сервере (клиенту дают непрозрачный указатель — opaque-токен) или в самом токене (подписанный JWT, сервер не хранит ничего). Этот выбор тянет за собой всё остальное — отзыв, латентность, поведение при нескольких сервисах, способ хранения на клиенте. Разберём обе модели, а в конце соберём карту, по которой выбирать.

Opaque-токен: состояние на сервере

Opaque («непрозрачный») токен — это просто случайная строка без внутренней структуры. Всё состояние сессии сервер держит у себя, а клиенту отдаёт лишь ключ к этой записи. В стенде (security/auth/single-service) сессия — это HASH в Redis:

s:{sid}       HASH  { uid, cat (created), lat (last-active) }   TTL 168h
su:{userID}   SET   { sid, sid, … }   — индекс живых сессий пользователя

Проверка сессии = одно чтение HASH по ключу. Никакой криптографии: токен ничего не «доказывает» сам по себе, он лишь указывает на запись, которой сервер доверяет, потому что сам её создал.

Что это даёт:

  • Мгновенный и полный отзыв. Удалил запись — сессии больше нет, следующий же запрос получает отказ. В стенде DELETE /auth/sessions/{sid} синхронно удаляет HASH s:{sid} и ссылку на него в индексе su:{userID}; logout всех устройств — это пройтись по индексу su: и удалить каждую сессию s:{sid}, а затем очистить сам индекс (удалить один только su: недостаточно — живые s:{sid} остались бы рабочими; точную реализацию разбирает вторая статья). Отзыв — не «пометить на будущее», а «прямо сейчас».
  • Полный контроль и наблюдаемость. Список активных сессий пользователя — бесплатное следствие модели (прочитать su:). Сервер всегда знает, кто и с каких устройств вошёл.
  • Маленький токен. На клиенте лежит короткая строка, а не пакет claims.

Чем платим:

  • Каждый проверяющий должен сходить в хранилище. Пока сервис один — это дёшево (один Redis рядом). Но если сервисов много и все они на каждый запрос ходят в общий Redis за сессией, хранилище становится узким местом и точкой отказа. Именно эта цена и толкает к следующей модели.

JWT: состояние в токене

JWT (JSON Web Token) переворачивает схему: состояние кладётся внутрь токена, а сервер не хранит его вовсе. Токен — это три base64url-части через точку: заголовок, полезная нагрузка (claims) и подпись.

header.  { "alg": "HS256", "typ": "JWT" }
payload. { "aid": 42, "nick": "orion", "roles": ["user","admin"],
           "iss": "auth", "exp": 1750000000, "iat": 1749999100 }
signature. HMAC-SHA256(header.payload, secret)

Ключевое слово — подпись. Кто угодно может прочитать payload (это не шифрование, а кодирование!), но изменить его нельзя: подпись не сойдётся. Поэтому любой сервис, знающий ключ, проверяет токен локально — пересчитал подпись, сверил exp/iss/aud, и всё, ходить никуда не надо. В стенде (security/auth/multi-service) это jwtclaims.Parse: проверка метода подписи (защита от подмены алгоритма), затем exp и iss.

Что это даёт:

  • Stateless-валидация без похода в auth. Это и есть весь смысл. В стенде сервис-потребитель проверяет токен на каждый запрос локально и ни разу не обращается к auth-сервису — счётчик /debug/auth-calls после серии запросов показывает 0. На микро-бенче локальная проверка подписи (~4.7 мкс) обходит эмулированный поход в отдельный auth-сервис (~1.5 мс) примерно в 322 раза — подробный разбор в третьей статье.

Чем платим:

  • Отзыв — больное место. Подписанный токен валиден до exp, где бы он ни был. «Удалить» его на сервере нельзя — сервер ничего не хранит. Значит, украденный access-токен работает до истечения, и это фундаментальная цена stateless-подхода. Способ смягчения — короткий TTL (минуты) плюс отдельный механизм отзыва на уровне refresh (см. ниже).
  • Размер. JWT с claims — сотни байт против десятков у opaque; он едет в каждом запросе.
  • Утечка данных в claims. Payload читается кем угодно, кто перехватил токен. В claims кладут идентификаторы и роли — но не персональные данные и не секреты.

Отдельная развилка внутри JWT — чем подписывать. HS256 (симметричный, общий секрет) прост, но секрет знают все сервисы, и любой из них может не только проверять, но и выпускать токены. RS256 (асимметричный, пара ключей) выпускает только auth своим приватным ключом, а сервисы проверяют публичным — секрет не размазан по системе. Механику RS256 с публикацией ключей через JWKS разбирает шестая статья про OAuth2/OIDC.

Access и refresh: зачем два токена

Короткий TTL спасает от долгоживущей кражи access-токена, но пользователя нельзя гонять на повторный вход каждые пять минут. Отсюда — пара токенов с разделением ответственности:

Access Refresh
Живёт минуты (в стенде — 15 мин) дни (в стенде — 168 ч)
Что делает предъявляется на каждый запрос к API обменивается на новый access, когда тот истёк
Модель обычно JWT (проверяется локально, часто) обычно opaque на сервере (проверяется редко)
Где хранится на клиенте httpOnly cookie или память вкладки httpOnly cookie, узкий Path (напр. /auth)

Логика такая: access короткоживущий и stateless — им пользуются часто, и цена его кражи ограничена минутами. Refresh долгоживущий, но предъявляется редко (только для обновления) и — важный момент — почти всегда opaque и хранится на сервере, даже в «полностью stateless» системах. Именно refresh возвращает контроль: его запись на сервере можно удалить, и это настоящий отзыв. Глобальный logout — это удаление всех refresh-сессий аккаунта; отзыв доступа — удаление конкретной записи.

В стенде refresh-сессия — снова HASH в Redis (rs:{refresh}) с индексом по аккаунту (rsu:{accountID}). То есть «stateless JWT-система» на деле стоит на очень даже stateful фундаменте refresh-сессий — и это норма, а не противоречие. Клиентская сторона обновления (гонки нескольких вкладок за один refresh) настолько богата граблями, что ей посвящена отдельная, пятая статья.

Где хранить токен на клиенте

Где браузер держит токен — не деталь, а решение о безопасности. Три варианта, и у каждого свой класс атак:

Хранилище Защита от XSS-кражи CSRF Переживает перезагрузку
httpOnly + Secure + SameSite cookie да — JS не читает cookie нужна защита (SameSite, CSRF-токен) да
Память вкладки (переменная JS) да — нечего красть после закрытия не отправляется автоматически нет (теряется при F5)
localStorage нет — любой XSS читает токен не отправляется автоматически да

Практический вывод, которого держится и стенд:

  • Refresh-токен — только в httpOnly cookie, Secure, SameSite, с узким Path. Его нельзя выкрасть через XSS (JS не имеет к нему доступа), а живёт он долго — поэтому защита критична. Цена — CSRF: раз cookie шлётся автоматически, нужен SameSite=Strict/Lax и, при необходимости, CSRF-токен.
  • Access-токен — либо тоже httpOnly cookie (тогда всё симметрично, но CSRF-нюанс остаётся), либо память вкладки (безопаснее против XSS-кражи, но теряется при перезагрузке — восстанавливается тихим refresh на старте).
  • localStorage для токенов — не надо. Удобно ровно до первого XSS, который вычитывает оттуда токен одной строкой. Единственный «плюс» — переживает перезагрузку — перекрывается связкой httpOnly refresh + тихий refresh.

Карта выбора: один сервис против многих

Свернём всё в одну таблицу — это и есть система координат для остальных статей серии:

Критерий Opaque-сессии (состояние на сервере) JWT + refresh (состояние в токене)
Отзыв мгновенный (удалил запись) access — только по TTL; настоящий отзыв на уровне refresh
Латентность проверки чтение из общего хранилища на каждый запрос локальная проверка подписи, без сети
Сколько сервисов один (общий Redis рядом) много (не хотим общий Redis в горячем пути)
Сложность низкая — одно middleware, одно хранилище выше — auth-сервис, ключи, refresh-ротация, гонки клиента
Список сессий бесплатно (индекс в хранилище) нужен отдельный индекс refresh-сессий
Размер токена маленький больше (claims в каждом запросе)

Правило, к которому всё сводится: не переусложнять в простом и не потеряться в сложном.

  • Один бэкенд — берите opaque-сессии в Redis. Мгновенный отзыв, простой код, список сессий из коробки. JWT здесь ничего не улучшит, а сложности добавит. Это вторая статья.
  • Много сервисов, разные клиенты, масштаб — выделенный auth-сервис, JWT для локальной валидации плюс opaque refresh в Redis. Вы сознательно берёте на себя ключи, ротацию и клиентские гонки — ради того, чтобы сервисы не ходили в общее хранилище на каждый запрос. Это третья статья.

Между этими полюсами нет «правильного по умолчанию» — есть соответствие модели вашей архитектуре. Ошибка в обе стороны стоит одинаково: JWT-инфраструктура вокруг одного сервиса — это лишняя сложность на ровном месте; общий Redis-сессий на десяток сервисов — это узкое место, которое всплывёт под нагрузкой.

Куда дальше по серии

Дальше — по этой карте, с живым кодом в стенде (digital-cookbook → security/auth):

Ядро — основная линия «от простого к сложному»:

  • Ст.2 — простой путь: opaque-сессии в Redis, вход по email-коду, мгновенный отзыв.
  • Ст.3 — сложный путь: выделенный auth, JWT + refresh, локальная валидация.
  • Ст.4 — вход и регистрация: email-код, OAuth, OTP, Telegram — как разные методы сходятся к одной сессии.
  • Ст.5 — refresh на клиенте: устройства и вкладки, гонки за refresh — финал ядра.

Углубления — отдельные большие темы поверх ядра:

  • Ст.6 — OAuth2/OIDC вглубь: authorization code + PKCE, client credentials, device flow, JWKS.
  • Ст.7 — MFA и TOTP: второй фактор, recovery-коды, step-up.
  • Ст.8 — Passkeys и WebAuthn: passwordless без фишинга.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии