Модуль: Backend · Уровень: Middle+/Senior
TL;DR#
- JWT — это НЕ шифрование, а подпись. Формат
header.payload.signature, каждая часть —base64url. Payload читается кем угодно (base64url -d). Никогда не кладите в payload секреты (пароли, PAN-карты, токены доступа к другим системам). - Подпись гарантирует целостность и аутентичность, а не конфиденциальность. Для конфиденциальности нужен JWE (encrypted), а не JWS (signed) — и в 99% случаев это не то, что вам нужно.
- HS256 (HMAC) — симметричный, один секрет для подписи и проверки. RS256/ES256 (RSA/ECDSA) — асимметричный: приватным подписывают (auth-server), публичным проверяют (resource-servers). Микросервисы → почти всегда асимметрика + JWKS.
- Валидация обязана: проверять подпись,
exp/nbf/iat,iss,aud, и whitelisting алгоритма (защита отalg=noneи algorithm confusion). - JWT stateless ⇒ отозвать нельзя без внешнего состояния. Решения: короткий TTL access-токена + refresh-токен с ротацией и reuse-detection, либо denylist (Redis) / token version в БД.
- Хранение на клиенте:
httpOnly+Secure+SameSitecookie, а неlocalStorage(XSS читает localStorage тривиально).
Теория#
1. Структура#
JWT (а точнее — самая частая его форма, JWS Compact Serialization) состоит из трёх частей, разделённых точками:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MTg0MDAwMDB9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
└──────────── header ────────────────┘ └──────────── payload ──────────┘ └──────────── signature ──────────────┘Каждая часть — это base64url (НЕ обычный base64: +→-, /→_, padding = убран). Это кодирование, а не шифрование. Любой может декодировать header и payload:
echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | base64 -d
# {"alg":"HS256","typ":"JWT"}Подпись считается над строкой base64url(header) + "." + base64url(payload):
signature = HMAC-SHA256( base64url(header) + "." + base64url(payload), secret ) # для HS256
signature = RSASSA-PKCS1-v1_5( SHA256(...), privateKey ) # для RS256Важное следствие: подпись покрывает header и payload как сырые base64url-строки. Поэтому при проверке нельзя ре-сериализовать JSON и подписывать заново — нужно брать ровно те байты, что пришли.
Header#
| Поле | Назначение |
|---|---|
alg | Алгоритм подписи (HS256, RS256, ES256, none, …) |
typ | Тип токена, обычно JWT. Для защиты от подмены контекста используют at+jwt (access token, RFC 9068) |
kid | Key ID — какой ключ использовать для проверки (важно при ротации ключей и JWKS) |
{ "alg": "RS256", "typ": "JWT", "kid": "2024-q1-key" }Payload (claims)#
Registered claims (RFC 7519, стандартизированы):
| Claim | Полное имя | Смысл |
|---|---|---|
iss | issuer | Кто выпустил токен |
sub | subject | Кому принадлежит (обычно user id) |
aud | audience | Для кого предназначен (какой сервис/API должен принимать) |
exp | expiration time | Unix timestamp, после которого токен невалиден |
nbf | not before | Не валиден ДО этого времени |
iat | issued at | Когда выпущен |
jti | JWT ID | Уникальный id токена (для denylist / защиты от replay) |
Public claims — зарегистрированные в IANA или с collision-resistant именами (URI), например https://example.com/roles.
Private claims — произвольные, по договорённости сторон (user_id, tenant, scope, role).
{
"iss": "https://auth.example.com",
"sub": "user-42",
"aud": "https://api.example.com",
"exp": 1718400000,
"iat": 1718396400,
"nbf": 1718396400,
"jti": "f1d2...",
"scope": "read:orders write:orders",
"role": "admin"
}Размер имеет значение: токен ходит в каждом запросе (заголовок
Authorization: Bearer ...или cookie). Не пихайте в claims весь профиль пользователя — есть лимиты на размер заголовков (часто 8 KB на nginx/прокси).
2. Алгоритмы: HMAC vs RSA/ECDSA#
| HS256 (HMAC) | RS256 (RSA) | ES256 (ECDSA) | |
|---|---|---|---|
| Тип | Симметричный | Асимметричный | Асимметричный |
| Ключ подписи / проверки | Один и тот же секрет | Приватный / Публичный | Приватный / Публичный |
| Кто может проверить | Только обладатель секрета | Любой с публичным ключом | Любой с публичным ключом |
| Размер подписи | 32 байта | ~256 байт (для 2048-бит) | ~64 байта |
| Скорость подписи | Очень быстро | Медленно | Быстро |
| Когда использовать | Монолит, один сервис подписывает и проверяет | Микросервисы, OIDC, сторонние верификаторы | То же, что RS256, но компактнее/быстрее |
Ключевая идея асимметрики: auth-сервер держит приватный ключ и подписывает. Resource-серверы (десятки микросервисов) держат только публичный ключ и проверяют. Утечка публичного ключа не позволяет подделать токен. С HS256 каждый проверяющий сервис должен знать секрет — а значит может и подделывать токены, и утечка любого из них компрометирует всю систему.
JWKS endpoint и kid#
В асимметричной модели публичные ключи отдаются через JWKS (JSON Web Key Set) — стандартный endpoint (/.well-known/jwks.json):
{
"keys": [
{
"kty": "RSA",
"kid": "2024-q1-key",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQSuu...",
"e": "AQAB"
}
]
}Resource-сервер: читает kid из header токена → находит соответствующий ключ в JWKS → проверяет подпись. Это позволяет ротировать ключи без даунтайма: новый ключ публикуется в JWKS заранее, старый держится до истечения всех выданных токенов. В Go обычно используют кэширующий JWKS-клиент (github.com/MicahParks/keyfunc), чтобы не дёргать endpoint на каждый запрос.
3. Валидация (Go, golang-jwt/jwt v5)#
Полная корректная валидация HS256:
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type AppClaims struct {
Role string `json:"role"`
jwt.RegisteredClaims
}
func ParseAndValidate(tokenStr string, secret []byte) (*AppClaims, error) {
claims := &AppClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims,
func(t *jwt.Token) (interface{}, error) {
// КРИТИЧНО: whitelisting алгоритма.
// Без этой проверки возможна algorithm confusion и alg=none.
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
},
// Дополнительная защита: разрешённые методы на уровне парсера.
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithIssuer("https://auth.example.com"), // проверка iss
jwt.WithAudience("https://api.example.com"), // проверка aud
jwt.WithExpirationRequired(), // exp обязателен
jwt.WithLeeway(30*time.Second), // допуск на рассинхрон часов
)
if err != nil {
switch {
case errors.Is(err, jwt.ErrTokenExpired):
return nil, errors.New("token expired")
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
return nil, errors.New("bad signature")
default:
return nil, err
}
}
if !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}Что именно валидируется (порядок логический):
- Парсинг и формат — три части, валидный base64url, валидный JSON.
- Алгоритм — соответствует ожидаемому (whitelist). Делать ДО проверки подписи.
- Подпись — верна для данного ключа/алгоритма.
- Временны́е claims —
exp(не истёк),nbf(уже наступил),iat(не из будущего). СWithLeewayдля часов. iss— токен от ожидаемого издателя.aud— токен предназначен этому сервису. Пропускaud= токен для сервиса A примут на сервисе B.
golang-jwt/jwtv5 проверяетexp/nbf/iatавтоматически, если они присутствуют. Ноiss/audНЕ проверяются, если вы не передалиWithIssuer/WithAudience. ИexpНЕ обязателен по умолчанию — нуженWithExpirationRequired().
Для RS256 keyfunc возвращает *rsa.PublicKey, а whitelist — *jwt.SigningMethodRSA и WithValidMethods([]string{"RS256"}).
4. Expiration и время жизни#
- Access token — короткий TTL (5–15 минут). Используется в каждом запросе. Stateless: проверяется по подписи без обращения к БД.
- Refresh token — длинный TTL (дни/недели). Используется только для получения нового access-токена. Хранится надёжно (httpOnly cookie / secure storage), желательно с серверным состоянием (можно отозвать).
- Sliding sessions — при каждом использовании refresh-токена выдаётся новая пара, продлевая «скользящее окно». Сессия живёт, пока пользователь активен, но умирает после периода бездействия.
Зачем короткий access TTL: JWT нельзя отозвать. Короткий TTL ограничивает окно эксплуатации украденного токена. Это компромисс stateless-подхода.
5. Refresh tokens#
Поток:
1. Login → выдать access (15 мин) + refresh (30 дней, хранится в БД)
2. Access протух → клиент шлёт refresh на /token/refresh
3. Сервер валидирует refresh → выдаёт НОВУЮ пару (ротация)
4. Старый refresh инвалидируетсяRefresh token rotation + reuse detection — современный стандарт (OAuth 2.0 BCP):
- Каждый refresh-токен одноразовый. При использовании он помечается «использован» и выдаётся новый.
- Если приходит уже использованный refresh-токен — это сигнал кражи (либо вор, либо легитимный клиент используют один токен). Реакция: отозвать всю цепочку токенов этого пользователя/сессии (token family) и потребовать повторный логин.
// Псевдо-логика reuse detection
func Refresh(ctx context.Context, presented string) (TokenPair, error) {
rt, err := store.GetRefresh(ctx, hash(presented))
if err != nil {
return TokenPair{}, ErrUnknownToken
}
if rt.Used {
// Токен уже использовался → reuse detected → компрометация цепочки.
store.RevokeFamily(ctx, rt.FamilyID)
return TokenPair{}, ErrTokenReuse
}
store.MarkUsed(ctx, rt.ID)
newPair := issuePair(rt.UserID, rt.FamilyID)
store.SaveRefresh(ctx, hash(newPair.Refresh), rt.FamilyID)
return newPair, nil
}Хранение refresh-токенов на сервере: храните хэш (SHA-256), а не сам токен — как пароли. При утечке БД злоумышленник не получит рабочие токены.
6. Уязвимости#
alg=none#
Спецификация JWS определяет alg: "none" (unsecured JWT — без подписи). Если библиотека или код принимает none, злоумышленник убирает подпись и подставляет любой payload:
{"alg":"none","typ":"JWT"}.{"sub":"admin"}.Защита: никогда не доверяйте alg из header. Явно задавайте ожидаемый алгоритм (WithValidMethods, проверка типа t.Method). golang-jwt v5 не поддерживает none без явного jwt.UnsafeAllowNoneSignatureType — но всё равно ставьте whitelist.
Algorithm confusion (RS256 → HS256)#
Классическая атака. Сервис ожидает RS256 и при верификации использует RSA-публичный ключ. Если код выбирает алгоритм по alg из токена, атакующий:
- Меняет
algнаHS256. - Подписывает токен по HMAC, используя публичный RSA-ключ как HMAC-секрет (публичный ключ известен — он публичный!).
- Сервер видит
HS256, берёт «ключ для проверки» (тот же публичный ключ) как HMAC-секрет → подпись сходится.
// УЯЗВИМО: ключ зависит от alg из токена
func keyfunc(t *jwt.Token) (interface{}, error) {
return publicKey, nil // вернётся и для HS256, и для RS256 — дыра
}
// БЕЗОПАСНО: жёстко фиксируем семейство алгоритмов
func keyfunc(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, errors.New("unexpected alg")
}
return publicKey, nil
}Кража токена (XSS / MITM)#
- XSS → если токен в
localStorage/sessionStorage, любой инъектированный JS его прочитает (localStorage.getItem('token')). Bearer-токен в JS-доступном хранилище = главная цель XSS. - MITM → всегда HTTPS, флаг
Secureна cookie. - Митигейшн последствий кражи: короткий TTL, refresh rotation с reuse detection, привязка к контексту (token binding / DPoP, fingerprint), denylist.
Отсутствие проверки exp#
Если не валидировать exp (или не требовать его наличия), токен живёт вечно. В go-jwt — добавляйте WithExpirationRequired().
Weak secret (brute force HS256)#
HS256 безопасен ровно настолько, насколько силён секрет. Короткий/словарный секрет подбирается офлайн (есть готовые wordlists и hashcat mode 16500 для JWT). Секрет должен быть высокоэнтропийным (≥256 бит случайных байт), не словом. Поэтому для публично доступных токенов часто предпочитают асимметрику.
Отсутствие aud#
Без проверки aud токен, выпущенный для сервиса A, примет сервис B → cross-service token replay / повышение привилегий.
7. Revocation (отзыв)#
Фундаментальная проблема: JWT stateless — сервер не хранит состояние сессии, проверка идёт только по подписи. Подписанный непротухший токен валиден всегда. Отозвать его «из коробки» нельзя. Варианты:
| Подход | Как работает | Минусы |
|---|---|---|
| Короткий TTL | Токен сам протухнет за минуты | Окно уязвимости остаётся; не мгновенно |
| Denylist / blocklist | jti отозванных токенов в Redis с TTL = остаток жизни токена | Каждый запрос → обращение в Redis (частичная потеря stateless) |
| Token version в БД | В claims кладут token_version; logout/смена пароля инкрементит версию в БД; при валидации сравнивают | Поход в БД на каждый запрос (или кэш) |
| Allowlist (whitelist) сессий | Хранят активные сессии, по сути возврат к session-store | Полностью stateful |
Практичный гибрид: короткий TTL access-токенов (минимизирует нужду в denylist) + stateful refresh-токены (их отзываем мгновенно, ротация + reuse detection) + denylist в Redis только для экстренного отзыва (бан пользователя, утечка).
// Denylist по jti в Redis
func IsRevoked(ctx context.Context, rdb *redis.Client, jti string) (bool, error) {
n, err := rdb.Exists(ctx, "denylist:"+jti).Result()
return n > 0, err
}
// При отзыве: TTL = exp - now, чтобы запись не висела вечно
func Revoke(ctx context.Context, rdb *redis.Client, jti string, exp time.Time) error {
return rdb.Set(ctx, "denylist:"+jti, "1", time.Until(exp)).Err()
}8. Где хранить на клиенте#
localStorage | httpOnly cookie | |
|---|---|---|
| Доступ из JS | Да (уязвимо к XSS) | Нет (JS не читает) |
| Авто-отправка | Нет (ручной заголовок) | Да (браузер сам шлёт) |
| CSRF | Не подвержен (заголовок ставится явно) | Подвержен → нужен SameSite + CSRF-токен |
| Рекомендация | Избегать для токенов | Предпочтительно |
Рекомендуемая конфигурация cookie:
Set-Cookie: refresh=...; HttpOnly; Secure; SameSite=Strict; Path=/auth; Max-Age=2592000HttpOnly— недоступна JS (защита от XSS-кражи).Secure— только по HTTPS.SameSite=Strict/Lax— защита от CSRF.Path=/auth— refresh-cookie шлётся только на endpoint обновления.
Частый паттерн: refresh-токен в httpOnly cookie, access-токен в памяти JS (переменная, не localStorage) — он короткоживущий и не переживёт перезагрузку, что и нужно.
Подводные камни / gotchas#
- Payload читаем всеми. Не кладите PII/секреты. JWT подписан, но не зашифрован.
algиз header — это ввод от атакующего. Никогда не выбирайте ключ/алгоритм проверки на основеalgбез whitelist.exp/aud/issне проверяются «сами собой». В golang-jwt нужно явно включитьWithExpirationRequired,WithAudience,WithIssuer. ДефолтноеParseWithClaimsпроверит подпись и (если присутствуют) временны́е claims, но пропустит отсутствующийexp.- Рассинхрон часов между сервисами → ложные «token not yet valid» (
nbf/iat) и «expired». ИспользуйтеWithLeeway(30–60 с), но не больше. - Logout с чистым JWT ничего не отзывает. «Удалить токен на клиенте» — это не отзыв; украденная копия продолжает работать до
exp. - HS256 в микросервисах = секрет у всех проверяющих = любой сервис может подделывать токены и компрометирует всю систему при утечке. Используйте асимметрику.
- Хранение refresh в БД в открытом виде — при утечке БД все токены рабочие. Храните хэш.
kidтоже ввод от атакующего — не используйте его напрямую как путь к файлу/ключу (path traversal, SQL-инъекция в lookup ключа).- Большие токены (роли, permissions в claims) могут превысить лимиты заголовков прокси (8 KB) и раздувают каждый запрос.
- Сравнение подписей должно быть constant-time (библиотеки делают это; не катайте свою проверку HMAC через
==).
Вопросы на собеседовании#
В: JWT — это шифрование или подпись? Можно ли класть в payload пароль?
О: Стандартный JWT (JWS) — это подпись, а не шифрование. Header и payload закодированы base64url и читаются любым (base64url -d). Подпись гарантирует целостность и подлинность, но не конфиденциальность. Пароли/секреты класть нельзя. Для конфиденциальности существует JWE (шифрованный JWT), но он нужен редко.
В: В чём разница HS256 и RS256, когда что выбирать? О: HS256 — HMAC, симметричный: один секрет и для подписи, и для проверки. RS256 — RSA, асимметричный: приватным подписывают, публичным проверяют. HS256 подходит для монолита, где один сервис и подписывает, и проверяет. RS256/ES256 — для микросервисов и OIDC: auth-сервер держит приватный ключ, resource-серверы — только публичный (через JWKS), не могут подделывать токены. С HS256 любой проверяющий знает секрет и потенциально может подделать токен.
В: Что такое атака alg=none и как от неё защититься?
О: Спецификация позволяет alg: "none" — токен без подписи. Если код принимает такой токен, атакующий подставляет произвольный payload с пустой подписью и проходит проверку. Защита — whitelist алгоритмов: явно проверять, что alg соответствует ожидаемому семейству (в golang-jwt — WithValidMethods и проверка типа t.Method), и никогда не доверять alg из header.
В: Объясните algorithm confusion RS256→HS256.
О: Сервис ожидает RS256 и при проверке использует RSA-публичный ключ. Если выбор алгоритма зависит от alg в токене, атакующий меняет alg на HS256 и подписывает токен HMAC’ом, используя публичный ключ как HMAC-секрет (он публично известен). Сервер берёт тот же публичный ключ как HMAC-секрет — подпись сходится. Защита — жёстко фиксировать допустимый алгоритм и не выбирать ключ на основе alg из токена.
В: Можно ли отозвать JWT? Как реализовать logout?
О: Чистый JWT stateless — отозвать нельзя, токен валиден до exp. Варианты: короткий TTL access-токена; denylist jti в Redis с TTL = остаток жизни; token_version в claims, сравниваемая с БД; stateful refresh-токены, которые отзываются мгновенно. Logout обычно = отзыв refresh-токена + (при необходимости) добавление access jti в denylist. Удаление токена только на клиенте отзывом не является.
В: Зачем нужны refresh-токены и что такое их ротация с reuse detection? О: Access-токен короткоживущий (минуты) ради безопасности, refresh — долгоживущий, для получения новых access без повторного логина. Ротация: каждый refresh одноразовый, при использовании выдаётся новый, старый инвалидируется. Reuse detection: если приходит уже использованный refresh — это признак кражи, и вся цепочка токенов (token family) этого пользователя отзывается с требованием перелогина.
В: Где хранить токены на клиенте и почему не в localStorage?
О: localStorage доступен любому JS, поэтому при XSS токен тривиально крадётся. Лучше httpOnly + Secure + SameSite cookie: JS её не читает (защита от XSS-кражи), браузер шлёт автоматически. Минус cookie — CSRF, решается SameSite и/или CSRF-токеном. Частый паттерн: refresh в httpOnly cookie, короткоживущий access — в памяти JS.
В: Какие claims обязательно проверять при валидации и почему?
О: Подпись + алгоритм (whitelist), exp (не истёк, и желательно требовать его наличие), nbf/iat (с leeway на часы), iss (ожидаемый издатель), aud (токен предназначен именно этому сервису). Пропуск aud ⇒ токен сервиса A примет сервис B (cross-service replay). Пропуск exp ⇒ вечный токен. В golang-jwt v5 iss/aud и обязательность exp надо включать явно.
В: Почему HS256 с коротким секретом опасен? О: Безопасность HMAC целиком зависит от секрета. Слабый/словарный секрет подбирается офлайн (hashcat, mode 16500): достаточно одного перехваченного токена. Секрет должен быть ≥256 бит случайных байт. Это одна из причин предпочитать асимметрику в публичных системах — там утечка публичного ключа не даёт подделывать токены.
В: Что такое JWKS и зачем kid?
О: JWKS — JSON Web Key Set, endpoint (/.well-known/jwks.json) с публичными ключами издателя. kid в header токена указывает, каким ключом проверять подпись. Это позволяет ротировать ключи без даунтайма: новый ключ публикуется заранее, старый держится до истечения всех выданных токенов. Verifier кэширует JWKS, чтобы не дёргать endpoint на каждый запрос.
На что копают на senior+#
- Stateless vs stateful компромисс. Senior должен внятно проговорить, что «настоящий» отзыв требует состояния, и обосновать выбор: короткий TTL + ротация refresh покрывают 95% кейсов без денормализации в stateless; denylist добавляет точечно. Уметь оценить нагрузку на Redis/БД.
- Token family и reuse detection — детали реализации: как связать цепочку (family_id), что именно отзывать, гонки при параллельных refresh с одного клиента (двойной запрос → ложное срабатывание reuse; решают grace-периодом или идемпотентностью).
- Algorithm confusion и доверие к header — умение показать конкретный уязвимый keyfunc и его исправление, понимание, что
alg/kid— это untrusted input. - Ротация ключей: overlapping keys в JWKS, как старые токены продолжают валидироваться, что делать при компрометации приватного ключа (немедленная ротация + denylist всех токенов).
- Clock skew и leeway в распределённой системе — как влияет на
nbf/exp, почему большой leeway плохо. - Token binding / DPoP / mTLS — как привязать токен к клиенту, чтобы украденный bearer-токен был бесполезен (sender-constrained tokens, RFC 9449).
- Где НЕ нужен JWT: для внутренних сессий одного монолита обычная серверная сессия (opaque token + session store) часто проще, безопаснее (мгновенный отзыв) и компактнее. Senior умеет сказать «здесь JWT не нужен».
- Размер и производительность: RS256-подпись медленнее HMAC; при высоком RPS verifier на ES256 выгоднее RSA; раздувание claims бьёт по каждому запросу.
typ: at+jwt(RFC 9068) и явное разделение access/id/refresh — чтобы id-token не приняли как access-token.