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 почти всегда подразумевается 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 не шифрован, он подписан. Любой, кто получил токен, видит payload — name, email, любые claims. Никогда не кладите в JWT секреты. Подпись защищает от подделки, не от чтения.
Атаки на сетевые протоколы: MITM, replay, spoofing
Алгоритмы подписи: HS256, RS256, ES256
Поле alg в header указывает алгоритм. Три самых распространённых:
В 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 — все три буквы для краткости.
Реальный токен от 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-библиотеки (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-слой уже обеспечивает целостность. На практике это превратилось в катастрофическую уязвимость.
Сценарий атаки:
- Атакующий получает любой валидный JWT (через UI, перехват)
- Декодирует payload, меняет
"sub": "user_42"на"sub": "admin", добавляет"role": "admin" - Меняет header на
{"alg": "none", "typ": "JWT"} - Кодирует обратно, оставляя signature пустой:
header.payload. - Отправляет как Bearer
Если сервер использует jwt.decode(token, algorithms=None) или принимает любой alg из header — он распарсит токен и поверит, что атакующий — admin.
Никогда не доверяйте alg из header токена. Всегда явно указывайте algorithms=["HS256"] (или другой ожидаемый список) в jwt.decode. PyJWT с пустым algorithms бросает ошибку с версии 2.0+, но другие библиотеки (или старые версии) могут быть уязвимы.
Опасность №2: alg confusion (HS256 vs RS256)
Более тонкая уязвимость, известная с 2015 года. Сценарий:
- Сервер ожидает RS256 (асимметричный), у него публичный ключ для проверки
- Атакующий создаёт JWT с
alg: HS256(симметричный) - В качестве «секрета» атакующий использует публичный ключ сервера (он публичный, легко достать через JWKS endpoint)
- Если сервер слепо использует
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-токен короткий -> ущерб от утечки ограничен 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-сервер:
- Скачивает JWKS (с кэшированием)
- По
kidиз header токена находит правильный ключ - Проверяет подпись
В 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-токенам сервисов. Но для общей картины:
В 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 — процесс его выдачи.