Модуль: 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+SameSite cookie, а не 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 и подписывать заново — нужно брать ровно те байты, что пришли.

ПолеНазначение
algАлгоритм подписи (HS256, RS256, ES256, none, …)
typТип токена, обычно JWT. Для защиты от подмены контекста используют at+jwt (access token, RFC 9068)
kidKey ID — какой ключ использовать для проверки (важно при ротации ключей и JWKS)
{ "alg": "RS256", "typ": "JWT", "kid": "2024-q1-key" }

Payload (claims)#

Registered claims (RFC 7519, стандартизированы):

ClaimПолное имяСмысл
ississuerКто выпустил токен
subsubjectКому принадлежит (обычно user id)
audaudienceДля кого предназначен (какой сервис/API должен принимать)
expexpiration timeUnix timestamp, после которого токен невалиден
nbfnot beforeНе валиден ДО этого времени
iatissued atКогда выпущен
jtiJWT 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
}

Что именно валидируется (порядок логический):

  1. Парсинг и формат — три части, валидный base64url, валидный JSON.
  2. Алгоритм — соответствует ожидаемому (whitelist). Делать ДО проверки подписи.
  3. Подпись — верна для данного ключа/алгоритма.
  4. Временны́е claimsexp (не истёк), nbf (уже наступил), iat (не из будущего). С WithLeeway для часов.
  5. iss — токен от ожидаемого издателя.
  6. aud — токен предназначен этому сервису. Пропуск aud = токен для сервиса A примут на сервисе B.

golang-jwt/jwt v5 проверяет 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 из токена, атакующий:

  1. Меняет alg на HS256.
  2. Подписывает токен по HMAC, используя публичный RSA-ключ как HMAC-секрет (публичный ключ известен — он публичный!).
  3. Сервер видит 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 / blocklistjti отозванных токенов в 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. Где хранить на клиенте#

localStoragehttpOnly cookie
Доступ из JSДа (уязвимо к XSS)Нет (JS не читает)
Авто-отправкаНет (ручной заголовок)Да (браузер сам шлёт)
CSRFНе подвержен (заголовок ставится явно)Подвержен → нужен SameSite + CSRF-токен
РекомендацияИзбегать для токеновПредпочтительно

Рекомендуемая конфигурация cookie:

Set-Cookie: refresh=...; HttpOnly; Secure; SameSite=Strict; Path=/auth; Max-Age=2592000
  • HttpOnly — недоступна 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.