Авторизация почти всегда сводится к одному вопросу: как сервер понимает, что запрос пришёл от уже вошедшего пользователя. Ответов два семейства — хранить состояние сессии на сервере и выдавать клиенту непрозрачный (opaque) идентификатор, либо упаковать состояние в подписанный токен (JWT) и не хранить его на сервере вообще. Выбор между ними определяет почти всё остальное: как устроен отзыв, что происходит при нескольких сервисах, где хранить токен на клиенте и как обновлять его.
Это первая, обзорная статья серии «Авторизация: токены, сессии, refresh». Дальше мы разберём оба подхода на реальном опыте — простой (один сервис, opaque-сессии в Redis) и сложный (много сервисов, JWT + refresh). Здесь — система координат.
В статье
- Что такое сессия и что мы на самом деле выбираем
- Opaque-токен: состояние на сервере
- JWT: состояние в токене
- Access и refresh: зачем два токена
- Где хранить токен на клиенте
- Карта выбора: один сервис против многих
- Куда дальше по серии
Что такое сессия и что мы на самом деле выбираем
Сначала развяжем два слова, которые часто путают. Аутентификация — сервер убедился, кто перед ним (проверил код из письма, пароль, подпись 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 без фишинга.
Комментарии