JWT-токены

JWT-токены: как API узнаёт пользователя без сессий на сервере.

Суть: JWT (JSON Web Token) — это подписанный токен, который сервер выдаёт при входе, а клиент потом присылает в каждом запросе. Сервер проверяет подпись и доверяет данным внутри — не храня сессию у себя. Это удобно для stateless-API.

В мире API популярна авторизация по токенам: после логина клиент получает JWT и кладёт его в заголовок Authorization: Bearer токен каждого запроса. Сервер по подписи понимает, что токен подлинный и не подделан.

Структура JWT

Токен состоит из трёх частей через точку: Header (алгоритм), Payload (claims: id, роль, срок) и Signature (подпись). Подпись считается секретным ключом сервера — подделать payload без ключа нельзя.

header.payload.signature
  |       |         |
  |       |         +-- подпись секретом сервера (нельзя подделать)
  |       +-- claims: { "sub": "42", "role": "Admin", "exp": ... }
  +-- { "alg": "HS256", "typ": "JWT" }

Подключение в ASP.NET Core

builder.Services
  .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddJwtBearer(options =>
  {
      options.TokenValidationParameters = new()
      {
          ValidateIssuer = true,
          ValidateAudience = true,
          ValidateLifetime = true,       // срок действия
          ValidateIssuerSigningKey = true,
          ValidIssuer = jwt.Issuer,
          IssuerSigningKey = new SymmetricSecurityKey(keyBytes)
      };
  });

Как работает под капотом

При логине сервер собирает claims, кодирует header и payload в Base64Url, считает подпись секретным ключом и склеивает токен. Клиент хранит его и шлёт в заголовке. На каждом запросе AddJwtBearer разбирает токен, пересчитывает подпись тем же ключом и сравнивает: совпало — токен подлинный; проверяет срок (exp), издателя, аудиторию. Если всё ок — claims из payload попадают в HttpContext.User. Сервер не хранит сессий: вся нужная информация — в самом токене. Проверим идею подписи на Python.

# Идея JWT-подписи: payload + секрет -> подпись, проверка пересчётом
import hashlib

SECRET = "super-secret-key"

def sign(payload):
    return hashlib.sha256((payload + SECRET).encode()).hexdigest()

def make_token(payload):
    return payload + "." + sign(payload)

def verify(token):
    payload, signature = token.rsplit(".", 1)
    return sign(payload) == signature   # пересчитываем тем же секретом

tok = make_token('{"sub":"42","role":"Admin"}')
print("Токен:", tok)
print("Валиден:", verify(tok))

# Подделка payload без секрета -> подпись не сойдётся
forged = '{"sub":"42","role":"Hacker"}.' + tok.rsplit(".", 1)[1]
print("Подделка валидна:", verify(forged))

Попробуй сам ▶ — настоящий токен проходит проверку, а подделанный payload (Admin заменён на Hacker) ломает подпись. Ровно так JWT защищает от подмены.

Частые ошибки

  • Хранить секретный ключ в коде/репозитории. Утечка ключа равна возможности выпускать любые токены. Ключ — в секретах.
  • Не проверять срок (exp). Бессрочные токены опасны; всегда включайте ValidateLifetime.
  • Класть в payload секреты. Payload не зашифрован, а лишь подписан — он читается любым, кто получит токен.

Best practices

  • Делайте access-токены короткоживущими, обновляйте через refresh-токены.
  • Передавайте JWT только по HTTPS — иначе токен можно перехватить.
  • Храните ключ в надёжном месте (секреты окружения, vault), а не в appsettings.json.

Подпись против шифрования и срок жизни

Частое заблуждение — что JWT «зашифрован». На самом деле стандартный JWT подписан, но не зашифрован: header и payload закодированы Base64Url (это кодировка, а не шифр) и читаются кем угодно, у кого есть токен. Подпись лишь гарантирует целостность — что содержимое не подменили, ведь пересчитать корректную подпись без секретного ключа сервера нельзя. Отсюда железное правило: в payload кладут только то, что не страшно показать (id, роли, срок), но никогда — пароли и секреты.

Подпись бывает симметричной (HS256, один секрет и для выпуска, и для проверки) и асимметричной (RS256, приватный ключ выпускает, публичный проверяет — удобно, когда токен проверяют разные сервисы). В обоих случаях проверка сводится к пересчёту подписи и сравнению — ровно это смоделировала запускаемая врезка выше: подмена роли в payload ломает подпись, и токен отвергается.

Access, refresh и stateless-цена

Stateless-природа JWT — одновременно достоинство и сложность. Достоинство: сервер не хранит сессий, легко масштабируется горизонтально, любой инстанс проверит токен сам. Сложность: выданный токен нельзя «отозвать» простым удалением сессии — он валиден до истечения exp. Поэтому access-токены делают короткоживущими (минуты), а для продления используют refresh-токены — долгоживущие, хранимые на сервере и отзываемые. Клиент по истечении access-токена меняет refresh на новую пару. Так совмещают удобство stateless-проверки и контроль над доступом.

Безопасность JWT держится на нескольких правилах, и пренебречь нельзя ни одним. Секретный ключ хранят в секретах окружения, а не в коде и не в appsettings под git — утечка ключа означает, что атакующий выпустит любой токен с любой ролью. Всегда включают проверку срока (ValidateLifetime), издателя и аудитории. И передают токен исключительно по HTTPS: по открытому каналу его перехватят и используют как есть — никакая подпись от перехвата не спасает, ведь это валидный токен.

Итог: JWT переносит личность в подписанном токене, позволяя API быть stateless. Дальше — как навешивать защиту на эндпоинты и строить политики.

Проверьте себя
1. Почему сервер может доверять данным внутри JWT, не храня сессию?
AТокен зашифрован целиком
BСервер пересчитывает подпись своим секретным ключом и сверяет — подделать payload без ключа нельзя
CJWT хранится в базе сервера
DБраузер гарантирует подлинность
2. Что НЕЛЬЗЯ класть в payload JWT?
AИдентификатор пользователя
BСекреты и пароли — payload лишь подписан, но не зашифрован и читается любым
CРоль пользователя
DСрок действия