.NET Tutorial
Middle7 мин чтенияВопрос 3 из 8

JWT: структура, подпись, хранение, refresh токены, отзыв?

Структура

Текст
eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjUtMDkifQ.eyJzdWIiOiI0MiIsIm5hbWUiOiJBbm4iLCJyb2xlIjoibWFuYWdlciIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLnV6IiwiYXVkIjoib3JkZXJzLWFwaSIsImV4cCI6MTc1OTIyNjUwMH0.Kx3v...подпись...
└──────────────── header ──────────────────┘ └──────────────────────────────── payload ────────────────────────────────────────────┘ └ signature ┘
Header
{ "alg": "RS256", "typ": "JWT", "kid": "2025-09" }
Payload (claims)
{
  "iss": "https://auth.example.uz",          // кто выпустил
  "aud": "orders-api",                        // для кого предназначен
  "sub": "42",                                // субъект (id пользователя)
  "exp": 1759226500,                          // истекает (Unix time)
  "iat": 1759225600,                          // выпущен
  "nbf": 1759225600,                          // не раньше
  "jti": "7f3a9c2e-...",                      // уникальный id токена
  "scope": "orders.read orders.write",
  "role": "manager"
}

Проверка (API)

Выпуск (Identity Provider)

base64url(header)

header.payload

base64url(payload)

Подпись приватным ключом
RS256

Пересчитать подпись header.payload

Проверить публичным ключом
из JWKS по kid

Проверить exp, nbf, iss, aud

ClaimsPrincipal

Проверка (API)

Выпуск (Identity Provider)

base64url(header)

header.payload

base64url(payload)

Подпись приватным ключом
RS256

Пересчитать подпись header.payload

Проверить публичным ключом
из JWKS по kid

Проверить exp, nbf, iss, aud

ClaimsPrincipal

Как формируется и проверяется подпись

Если злоумышленник изменит payload (например, role: admin), подпись не совпадёт — токен отклоняется.

HS256 против RS256

HS256 (HMAC) RS256 / ES256
Ключ Один общий секрет Пара: приватный (подпись) + публичный (проверка)
Кто может выпускать токены Любой, кто может проверять Только владелец приватного ключа (IdP)
Распространение ключа Секрет нужно передать каждому сервису Публичный ключ открыт (JWKS endpoint)
Ротация Синхронная замена во всех сервисах kid + несколько ключей в JWKS
Когда Один сервис выпускает и проверяет IdP + много API, микросервисы

Проверка в ASP.NET Core

C#
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://auth.example.uz";          // → /.well-known/openid-configuration → JWKS (кэшируется)
        o.Audience = "orders-api";
        o.MapInboundClaims = false;                        // сохранить имена claims (sub, role) как есть
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidAlgorithms = [SecurityAlgorithms.RsaSha256],   // запрет подмены алгоритма
            ClockSkew = TimeSpan.FromSeconds(30),               // по умолчанию 5 минут — много
            NameClaimType = "sub",
            RoleClaimType = "role"
        };
    });

Access и refresh токены

APIIdentity ProviderКлиентaccess истёкесли кто-то снова предъявит R1 —это кража: отозвать всю цепочку R1, R2 и сессиюлогинaccess (15 мин) + refresh R1 (14дней)Bearer access200Bearer access (истёк)401refresh R1R1 помечениспользованнымновый access + новый refresh R2(ротация)
APIIdentity ProviderКлиентaccess истёкесли кто-то снова предъявит R1 —это кража: отозвать всю цепочку R1, R2 и сессиюлогинaccess (15 мин) + refresh R1 (14дней)Bearer access200Bearer access (истёк)401refresh R1R1 помечениспользованнымновый access + новый refresh R2(ротация)
Жизненный цикл токенов с ротацией refresh
Access token Refresh token
Срок жизни Минуты Дни / недели (со скользящим продлением)
Кому отправляется API (ресурс-серверам) Только IdP / серверу авторизации
Проверка Stateless (подпись, exp) По хранилищу на сервере
Формат Обычно JWT Обычно непрозрачная случайная строка
Отзыв Сложно (до exp) Легко: удалить из хранилища

Refresh token rotation: каждый refresh-токен одноразовый; повторное использование старого — признак кражи → отозвать всё семейство токенов.

Отзыв токенов

JWT проверяется без обращения к серверу — это его главная сила и главная слабость. Варианты:

Нужно немедленно отозвать доступ
(увольнение, кража, смена пароля)

Короткий срок access (5–15 мин)
+ отзыв refresh — доступ исчезнет сам

Denylist по jti в Redis
(хранить до exp токена)

Версия безопасности пользователя:
claim sec_ver сравнивается с БД/кэшем

Token introspection (RFC 7662):
API спрашивает IdP о каждом токене —
теряется stateless

Непрозрачные (reference) токены
вместо JWT

Нужно немедленно отозвать доступ
(увольнение, кража, смена пароля)

Короткий срок access (5–15 мин)
+ отзыв refresh — доступ исчезнет сам

Denylist по jti в Redis
(хранить до exp токена)

Версия безопасности пользователя:
claim sec_ver сравнивается с БД/кэшем

Token introspection (RFC 7662):
API спрашивает IdP о каждом токене —
теряется stateless

Непрозрачные (reference) токены
вместо JWT

Стратегии отзыва
Проверка denylist в событии JwtBearer
o.Events = new JwtBearerEvents
{
    OnTokenValidated = async ctx =>
    {
        var jti = ctx.Principal!.FindFirstValue(JwtRegisteredClaimNames.Jti);
        var denylist = ctx.HttpContext.RequestServices.GetRequiredService<ITokenDenylist>();
        if (jti is not null && await denylist.IsRevokedAsync(jti, ctx.HttpContext.RequestAborted))
            ctx.Fail("Токен отозван");
    }
};

Где хранить токены в браузере

Хранилище XSS CSRF Комментарий
localStorage / sessionStorage Уязвимо — любой внедрённый скрипт прочитает Не уязвимо Популярно, но рискованно
Память JS (переменная) Уязвимо в меньшей степени, теряется при перезагрузке Не уязвимо + refresh в HttpOnly cookie
HttpOnly cookie Не прочитать из JS Нужна защита (SameSite, antiforgery) Хороший вариант
BFF (токены на сервере, браузеру — сессионная cookie) Токены недоступны в браузере SameSite + antiforgery Рекомендуется для SPA (OAuth 2.0 for Browser-Based Apps)

Вопросы на засыпку

Можно ли хранить в JWT персональные данные?

Минимально: payload читается кем угодно, у кого есть токен (браузер, прокси, логи). Кладите идентификатор и необходимые для авторизации claims; остальное — через API или userinfo. Большие токены ещё и увеличивают каждый запрос.

Зачем kid в заголовке?

Key ID указывает, каким ключом из набора (JWKS) подписан токен. Это позволяет ротировать ключи: IdP публикует новый ключ заранее, начинает подписывать им, а старый оставляет в JWKS, пока не истекут выпущенные им токены.

Чем JWT отличается от сессионной cookie?

Сессия — случайный идентификатор, по которому сервер находит состояние у себя (легко отозвать, нужно хранилище). JWT — самодостаточный токен с данными и подписью (не нужно хранилище для проверки, трудно отозвать). Cookie — лишь транспорт: в cookie можно хранить и сессионный id, и JWT.

Почему ClockSkew по умолчанию 5 минут — проблема?

Токен со сроком 15 минут фактически живёт 20: библиотека допускает расхождение часов. Для коротких токенов уменьшайте ClockSkew до 30–60 секунд и синхронизируйте время серверов (NTP).

Итог

JWT — подписанный (не зашифрованный) набор claims, проверяемый без обращения к серверу. Используйте асимметричные алгоритмы с JWKS, строго проверяйте подпись, алгоритм, iss, aud и exp, выдавайте короткие access-токены и ротируемые refresh-токены, планируйте отзыв и не храните токены в localStorage — для SPA лучше BFF.