Learning Platform
Глоссарий Troubleshooting
Урок 09.02 · 25 мин
Начальный
JWTBearerAuthКриптография

Bearer токены и JWT: подписанные токены с истечением

API key решает задачу «у клиента есть секрет, по которому сервер его узнаёт». Но у API key есть фундаментальный недостаток: чтобы проверить ключ, сервер должен залезть в БД. Каждый запрос — round trip к хранилищу. Для high-traffic API это узкое место.

JWT (JSON Web Token) решает эту проблему элегантно: токен содержит всю информацию о клиенте (user id, права, срок жизни) и подпись, которую сервер проверяет криптографически. БД не требуется — сервер по подписи понимает, что токен не подделан, а по полю exp понимает, что не истёк.

В этом уроке мы пройдём от Bearer-токенов общего вида к JWT в частности: структура, алгоритмы подписи, типичные уязвимости и валидация в Python.


Bearer токен (RFC 6750): просто формат заголовка

Сначала разделим понятия. Bearer — это не алгоритм, а формат передачи токена в HTTP. RFC 6750 говорит:

Authorization: Bearer <token>

<token> может быть чем угодно: случайной строкой, JWT, GUID, base64 от чего-то. Bearer описывает только синтаксис заголовка и семантику «у кого токен — у того и доступ». Имя «bearer» — английское «носитель»: владение токеном даёт доступ, без дополнительной аутентификации владельца.

Это важно: фраза «мы используем Bearer-токены» сама по себе ничего не говорит про безопасность. Можно делать Bearer с opaque API keys, можно с JWT, можно с тестовыми токенами вида secret123. Контейнер один — содержимое разное.

Bearer как контейнер
Bearer форматAuthorization: Bearer xxxxx -- стандарт RFC 6750. Синтаксис передачи, не более
Opaque tokenПросто случайная строка, для проверки нужен round trip к БД. API keys часто оформляют как Bearer
JWTSelf-contained: структура header.payload.signature, проверяется криптографически без БД
OAuth2 access tokenТокен, выданный OAuth2 authorization server. Может быть opaque или JWT -- зависит от реализации

В дальнейшем разговоре про Bearer почти всегда подразумевается JWT — это самый распространённый формат самоносимых токенов.


JWT (RFC 7519): анатомия токена

JWT — это строка из трёх частей, разделённых точками: header.payload.signature. Каждая часть — base64url-encoded JSON (кроме signature — это сырые байты в base64url).

Пример реального JWT (разбит для читаемости):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzE1MzAwMDAwLCJleHAiOjE3MTUzMDM2MDB9
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Декодируем первые две части:

import base64
import json

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzE1MzAwMDAwLCJleHAiOjE3MTUzMDM2MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
header_b64, payload_b64, signature_b64 = token.split(".")

# base64url требует padding до длины кратной 4
def b64url_decode(s: str) -> bytes:
    padding = "=" * (-len(s) % 4)
    return base64.urlsafe_b64decode(s + padding)

header = json.loads(b64url_decode(header_b64))
payload = json.loads(b64url_decode(payload_b64))

print("Header:", header)
print("Payload:", payload)

Вывод:

Header: {'alg': 'HS256', 'typ': 'JWT'}
Payload: {'sub': '1234567890', 'name': 'Alice', 'iat': 1715300000, 'exp': 1715303600}
Три части JWT
HeaderJSON с метаданными: alg (алгоритм подписи), typ (тип, обычно 'JWT'), иногда kid (key id для ротации). Закодирован base64url
.
PayloadJSON с claims: данными о пользователе и токене. sub, exp, iat, иногда custom поля. Закодирован base64url. НЕ ШИФРОВАН -- любой видит содержимое
.
SignatureHMAC или RSA/ECDSA подпись от base64(header) + . + base64(payload). Гарантирует, что header и payload не подделаны
WARNING

Ключевое заблуждение: JWT не шифрован, он подписан. Любой, кто получил токен, видит payload — name, email, любые claims. Никогда не кладите в JWT секреты. Подпись защищает от подделки, не от чтения.


Атаки на сетевые протоколы: MITM, replay, spoofing

Алгоритмы подписи: HS256, RS256, ES256

Поле alg в header указывает алгоритм. Три самых распространённых:

Алгоритмы подписи JWT
HS256HMAC-SHA256. Симметричный: ОДИН секрет для подписи и проверки. Простой, быстрый, но требует, чтобы все, кто проверяет, имели тот же секрет, что и подписывающий
RS256RSA-SHA256. Асимметричный: приватный ключ для подписи, публичный для проверки. Auth-server подписывает приватным, любой сервис может валидировать публичным
ES256ECDSA-SHA256. Асимметричный, на эллиптических кривых. Современная альтернатива RS256: меньше размер ключа и подписи, та же безопасность
Когда HS256Один сервис подписывает И проверяет токены. Внутренние API без external клиентов. Симметричный -- проще в управлении ключом
Когда RS256/ES256Многосервисная архитектура. Auth-server (один) подписывает, десятки resource-серверов валидируют. Утечка публичного ключа никого не пугает -- он публичный

В OAuth2/OpenID Connect — почти всегда RS256 или ES256. Auth0, Keycloak, AWS Cognito, Google Identity — все по умолчанию используют асимметричную подпись. Это позволяет любому сервису проверить токен через публичный JWKS endpoint без обращения к auth-серверу.


Standard claims: iss, sub, aud, exp, iat, nbf, jti

JWT-payload — это произвольный JSON. Но есть стандартные claims, которые рекомендует RFC 7519. Они называются registered claims — все три буквы для краткости.

Registered claims в JWT
iss (issuer)Кто выпустил токен. Обычно URL auth-сервера: https://auth.example.com. Используется для проверки доверия
sub (subject)Кто 'субъект' токена -- обычно user ID или email. То, ради чего токен выпущен
aud (audience)Кому предназначен токен. Может быть URL API или клиента. Сервис проверяет, что токен выпущен ИМЕННО для него
exp (expiration)Unix-timestamp истечения. После этого момента токен невалиден. Обычно 15-60 минут от iat. Критическое поле, всегда проверять
iat (issued at)Unix-timestamp выпуска токена. Полезно для аналитики и для логики 'отозвать все токены, выданные до момента X'
nbf (not before)Unix-timestamp, ДО которого токен невалиден. Редко используется -- для отложенной активации
jti (JWT ID)Уникальный ID токена (UUID). Полезно для отзыва конкретных токенов через blacklist (хотя это компромисс с stateless-идеологией JWT)

Реальный токен от Auth0 выглядит примерно так:

{
  "iss": "https://example.auth0.com/",
  "sub": "auth0|5f7c1a2b3c4d5e6f7a8b9c0d",
  "aud": ["https://api.example.com", "https://example.auth0.com/userinfo"],
  "iat": 1715300000,
  "exp": 1715303600,
  "scope": "read:users write:orders",
  "permissions": ["read:users", "write:orders"]
}

Кроме registered claims, любой кастомный claim («public» или «private» в терминологии RFC) можно добавлять. Здесь, например, scope и permissions — кастомные.


Валидация JWT: что проверить и в каком порядке

Получив JWT, серверу нужно его провалидировать. Шаги (по убыванию важности):

Валидация JWT -- порядок проверок
1. ПодписьСамое критичное. Проверить, что header.payload подписан правильным ключом и алгоритмом, который ожидаем (НЕ тем, который указан в header -- об этом ниже)
2. expТекущее время не позже exp. Часто с допуском clock skew 30-60 секунд (часы серверов могут расходиться)
3. nbfЕсли есть -- текущее время не раньше nbf
4. ississ совпадает с ожидаемым auth-сервером. Защита от 'выпустил кто-то другой свой JWT с тем же payload'
5. audaud содержит наш сервис. Защита от использования токена, выпущенного для ДРУГОГО API
6. Custom claimsscope/permissions/role -- есть ли у пользователя право на эту операцию. Это уже про авторизацию, не аутентификацию

Хорошие JWT-библиотеки (PyJWT, jose) выполняют шаги 1-5 автоматически, нужно только передать параметры.


Валидация в Python: PyJWT

PyJWT — стандартная библиотека для JWT в Python.

pip install PyJWT
# Для RS256/ES256 нужны крипто-зависимости
pip install "PyJWT[crypto]"

Подписать токен (HS256)

import jwt
import time

SECRET = "super-secret-key-32-bytes-min-or-longer"

payload = {
    "sub": "user_42",
    "name": "Alice",
    "iat": int(time.time()),
    "exp": int(time.time()) + 3600,  # 1 час
}

token = jwt.encode(payload, SECRET, algorithm="HS256")
print(token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIi...

Проверить токен (HS256)

import jwt

try:
    decoded = jwt.decode(
        token,
        SECRET,
        algorithms=["HS256"],  # Явный список разрешённых алгоритмов
        options={"require": ["exp", "iat"]},  # Обязательные claims
    )
    print("Valid:", decoded)
except jwt.ExpiredSignatureError:
    print("Token expired")
except jwt.InvalidTokenError as e:
    print(f"Invalid token: {e}")

Проверить с дополнительными опциями (issuer, audience)

decoded = jwt.decode(
    token,
    SECRET,
    algorithms=["HS256"],
    issuer="https://auth.example.com",
    audience="https://api.example.com",
)

Если iss или aud не совпадают — jwt.InvalidIssuerError или jwt.InvalidAudienceError.

RS256: подписать приватным, проверить публичным

# На auth-сервере (подпись)
with open("private_key.pem", "rb") as f:
    private_key = f.read()

token = jwt.encode(payload, private_key, algorithm="RS256")

# На resource-сервере (проверка)
with open("public_key.pem", "rb") as f:
    public_key = f.read()

decoded = jwt.decode(token, public_key, algorithms=["RS256"])

Опасность №1: alg=none

В стандарте RFC 7519 есть алгоритм none — означает «токен без подписи». Идея была в сценариях, где transport-слой уже обеспечивает целостность. На практике это превратилось в катастрофическую уязвимость.

Сценарий атаки:

  1. Атакующий получает любой валидный JWT (через UI, перехват)
  2. Декодирует payload, меняет "sub": "user_42" на "sub": "admin", добавляет "role": "admin"
  3. Меняет header на {"alg": "none", "typ": "JWT"}
  4. Кодирует обратно, оставляя signature пустой: header.payload.
  5. Отправляет как Bearer

Если сервер использует jwt.decode(token, algorithms=None) или принимает любой alg из header — он распарсит токен и поверит, что атакующий — admin.

DANGER

Никогда не доверяйте alg из header токена. Всегда явно указывайте algorithms=["HS256"] (или другой ожидаемый список) в jwt.decode. PyJWT с пустым algorithms бросает ошибку с версии 2.0+, но другие библиотеки (или старые версии) могут быть уязвимы.


Опасность №2: alg confusion (HS256 vs RS256)

Более тонкая уязвимость, известная с 2015 года. Сценарий:

  1. Сервер ожидает RS256 (асимметричный), у него публичный ключ для проверки
  2. Атакующий создаёт JWT с alg: HS256 (симметричный)
  3. В качестве «секрета» атакующий использует публичный ключ сервера (он публичный, легко достать через JWKS endpoint)
  4. Если сервер слепо использует alg из header и берёт «ключ для проверки», он попробует проверить HMAC-подпись с публичным ключом — и преуспеет

Защита: явный список algorithms=["RS256"] в decode(). Если приходит токен с alg: HS256 — отклонять.

# Правильно: явно RS256
decoded = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],  # отбрасывает токены с alg=HS256
)

# Неправильно: алгоритм из header
decoded = jwt.decode(token, public_key)  # уязвимо в старых библиотеках

Опасность №3: expired token

Самая «штатная», но до сих пор массовая ошибка — не проверять exp. JWT по дизайну должен иметь короткий срок жизни (15-60 минут). Если приложение принимает токен без проверки exp, токен фактически живёт вечно — украденный год назад токен всё ещё работает.

PyJWT проверяет exp по умолчанию. Но некоторые разработчики отключают эту проверку «для удобства»:

# ОПАСНО -- отключение exp-проверки
decoded = jwt.decode(
    token,
    SECRET,
    algorithms=["HS256"],
    options={"verify_exp": False},  # НЕ ДЕЛАТЬ ТАК В PROD
)

В тестах это нормально (фиксированный токен в фикстурах), в production — никогда.


Refresh tokens: как обновлять access без повторного логина

Короткий срок жизни access-токена (15-60 минут) хорош для безопасности, но плох для UX: пользователю не хочется вводить пароль каждый час. Решение — refresh token.

Access + refresh -- двухтокенная схема
Access tokenКороткоживущий (15-60 мин). JWT, проверяется без БД. Используется для каждого API-запроса
Refresh tokenДолгоживущий (дни/недели). Opaque (не JWT), хранится в БД на сервере. Используется только для получения нового access-токена
При логинеСервер выдаёт ОБА токена. Клиент сохраняет: access -- в памяти, refresh -- в более защищённом месте (httpOnly cookie / Keychain)
Access истёкAPI-запрос вернул 401. Клиент шлёт refresh на /auth/refresh, получает новый access (и часто новый refresh)
Refresh истёкТогда -- полный логин с паролем. Refresh-tokens обычно живут 1-30 дней, при подозрении на компрометацию -- отзыв на сервере

Преимущество: access-токен короткий -> ущерб от утечки ограничен 1 часом. Refresh-токен долгий, но хранится осторожнее и может быть отозван в БД.

В Python:

class TokenManager:
    def __init__(self, refresh_token: str, base_url: str):
        self.refresh_token = refresh_token
        self.base_url = base_url
        self.access_token = None
        self.expires_at = 0

    def get_access_token(self) -> str:
        if self.access_token is None or time.time() >= self.expires_at - 60:
            self._refresh()
        return self.access_token

    def _refresh(self):
        response = requests.post(
            f"{self.base_url}/auth/refresh",
            json={"refresh_token": self.refresh_token},
            timeout=10,
        )
        response.raise_for_status()
        data = response.json()
        self.access_token = data["access_token"]
        self.expires_at = time.time() + data["expires_in"]

expires_at - 60 — refresh за 60 секунд до истечения, чтобы успеть до того, как любой in-flight запрос упадёт с 401.


JWKS: как resource-сервер получает публичный ключ

Если auth-сервер подписывает приватным ключом (RS256/ES256), resource-серверу нужен публичный ключ для проверки. Стандарт распространения публичных ключей — JWKS (JSON Web Key Set) endpoint.

OAuth2-сервера обычно отдают JWKS по адресу типа https://auth.example.com/.well-known/jwks.json:

{
  "keys": [
    {
      "kid": "abc123",
      "kty": "RSA",
      "alg": "RS256",
      "use": "sig",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx...",
      "e": "AQAB"
    }
  ]
}

Resource-сервер:

  1. Скачивает JWKS (с кэшированием)
  2. По kid из header токена находит правильный ключ
  3. Проверяет подпись

В Python для этого есть PyJWKClient:

import jwt
from jwt import PyJWKClient

jwks_url = "https://auth.example.com/.well-known/jwks.json"
jwks_client = PyJWKClient(jwks_url)

signing_key = jwks_client.get_signing_key_from_jwt(token)
decoded = jwt.decode(
    token,
    signing_key.key,
    algorithms=["RS256"],
    audience="https://api.example.com",
    issuer="https://auth.example.com/",
)

PyJWKClient сам кэширует ключи и обновляет при ротации (когда приходит токен с новым kid).


Где хранить токены на клиенте

Для Junior DE, который пишет ETL-скрипты, тема в основном относится к access/refresh-токенам сервисов. Но для общей картины:

Хранение токенов в разных средах
Backend сервисТокены в memory или Redis, никогда на диске в plain text. Для рестартов -- повторный обмен через client_credentials flow (или refresh)
Mobile appKeychain (iOS) / Keystore (Android). Шифрование на уровне OS. Доступ только для нашего приложения
Web SPAHttpOnly cookies для refresh -- недоступны JS, защита от XSS. Access -- в памяти JS (sessionStorage хуже из-за XSS)
CLI / скриптKeychain (macOS/Linux Secret Service/Windows Credential Manager) или зашифрованный конфиг. .env с правами 600 -- минимально приемлемо

В Python для CLI можно использовать keyring:

import keyring

# Сохранить
keyring.set_password("my-etl-service", "access_token", token)

# Прочитать
token = keyring.get_password("my-etl-service", "access_token")

Это лучше, чем plain text в .env, особенно для интерактивных tools.


Итоги урока

Bearer — это формат заголовка Authorization: Bearer <token>, описывающий синтаксис передачи. Содержимое токена — что угодно: opaque API key, JWT, OAuth2 access token.

JWT — самоносимый токен с тремя частями: header.payload.signature, разделёнными точками. Header и payload — это base64url JSON, signature — криптографическая. JWT не шифрован, только подписан — payload видит каждый.

Алгоритмы: HS256 (симметричный, для одного сервиса), RS256/ES256 (асимметричные, для микросервисов и OAuth2). Стандартные claims: iss, sub, aud, exp, iat, nbf, jti.

Главные опасности: alg=none (никогда не доверяйте header), alg confusion (всегда явный algorithms=[...]), expired tokens (никогда не отключать verify_exp).

В Python для JWT — PyJWT, для асимметричных алгоритмов — PyJWT[crypto] extras. JWKS endpoint и PyJWKClient для получения публичных ключей в OAuth2-сценариях.

В следующем уроке перейдём к OAuth 2.0 — протоколу, который определяет, как получить access-токен. JWT — формат токена, OAuth — процесс его выдачи.


Проверка знанийKnowledge check
Junior получил JWT, декодировал payload через base64 и увидел: {sub: 42, role: 'user', exp: 1715303600}. Junior считает, что 'видит claims через base64 -- значит JWT не безопасен'. Что не так с этим выводом?
ОтветAnswer
Junior путает чтение и подделку. JWT -- это подписанный, а не зашифрованный токен. Дизайн-цель: payload читаем, но не модифицируем без знания секрета подписи. Любой может декодировать base64 и увидеть содержимое -- это by design. Если бы payload был секретный -- нужно использовать JWE (зашифрованный JWT), а не JWS (подписанный JWT). Правильное правило: не кладите в JWT секреты (пароли, API keys, PII более чем нужно). Что туда кладут безопасно: user ID, scopes, exp. Подмена payload (например, role: admin) детектируется проверкой подписи -- без знания секрета HMAC или приватного ключа RSA подделать подпись нельзя. Поэтому критично всегда проверять подпись через jwt.decode с явным algorithms=[...] и не использовать decode без verify.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. JWT состоит из трёх частей: header.payload.signature. Что про payload верно?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4