JWT токен: что это и как устроен JSON Web Token

Строка JWT токена из трёх цветных частей на экране ноутбука Безопасность

JWT токен (JSON Web Token) записывается строкой из трёх частей, разделённых точками: заголовок, данные и подпись. Данные в обычном JWT не зашифрованы, прочитать их может любой, а подпись позволяет серверу проверить, что после выдачи в токене не изменили ни одного символа.

Что такое JWT

JSON Web Token описан в RFC 7519 (май 2015 года). JWT передаёт информацию между двумя сторонами в виде утверждений (claims): пар «имя: значение» о пользователе или о самом токене, записанных JSON-объектом. В JWS (JSON Web Signature) данные подписаны, но открыты, и частей три. В JWE (JSON Web Encryption) данные зашифрованы, а частей пять.

Пользователь вводит логин и пароль, сервер проверяет учётные данные и выдаёт токен. Клиент отправляет его в каждом запросе к REST API в заголовке Authorization: Bearer <токен>, а сервер проверяет подпись и срок и берёт идентификатор пользователя и его роль из данных токена (payload), так что для аутентификации и авторизации может обойтись без запроса к базе. ID token в OpenID Connect тоже передаётся в виде JWT, подробнее в статье про OAuth 2.0.

Структура JWT: header.payload.signature

Каждая часть кодируется в Base64url: это Base64 с алфавитом, безопасным для URL и имён файлов (- и _ вместо + и /), и без знаков = в конце, которые в URL обычно пришлось бы кодировать.

Три цветные пластины, соединённые двумя бусинами

Header (заголовок)

Заголовок говорит, каким алгоритмом подписан токен, например {"alg": "HS256", "typ": "JWT"}. Поле alg обязательно. Поле typ (тип токена) по стандарту необязательно, а если его указывают, рекомендуют значение JWT. Ещё бывает kid: подсказка, каким ключом подписан токен, если ключей несколько.

В закодированном виде этот заголовок даёт eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.

Payload (полезная нагрузка)

В payload лежат данные токена. Зарегистрированных имён семь, все они необязательны: какие нужны, решает приложение.

Claim Расшифровка Что содержит
iss issuer кто выдал токен (издатель)
sub subject о ком токен, например идентификатор пользователя
aud audience для кого токен; получатель, которого нет в списке, токен отклоняет
exp expiration time момент, начиная с которого токен не принимается
nbf not before момент, раньше которого токен не принимается
iat issued at время создания токена
jti JWT ID уникальный идентификатор токена, помогает не принять его повторно

Время в exp, nbf и iat пишется в секундах с 1 января 1970 года (UTC). Кроме этих семи, в payload кладут свои поля с информацией о пользователе: имя, роль (role), права доступа.

Signature (подпись)

Подпись считается от строки base64url(header) + "." + base64url(payload) с ключом. Для HS256 это HMAC на SHA-256 с секретным ключом, который хранится на сервере. Если изменить хоть одну букву в данных, подпись перестанет совпадать, а пересчитать её может только владелец ключа.

Читайте также:  XSS атака: что это такое и как защитить свой код

Собираем и подписываем JWT на Python

Соберём токен на модулях стандартной библиотеки Python 3.14.3. Время зафиксировано, вывод не меняется, срок жизни 15 минут.

import base64
import hashlib
import hmac
import json

# 32 байта от secrets.token_hex(32); в проекте ключ хранят вне кода
SECRET = bytes.fromhex("17420db075cfc1d093460c3def15b06fcb7b84159b6feeaf0a2570f6c0b46bf2")
ISSUED_AT = 1791633600  # 10.10.2026 12:00:00 UTC


def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def b64url_decode(part: str) -> bytes:
    return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))


def to_json(obj: dict) -> bytes:
    return json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def sign_hs256(header: dict, payload: dict, key: bytes) -> str:
    signing_input = b64url_encode(to_json(header)) + "." + b64url_encode(to_json(payload))
    signature = hmac.new(key, signing_input.encode("ascii"), hashlib.sha256).digest()
    return signing_input + "." + b64url_encode(signature)


header = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": "42", "name": "Анна", "role": "user", "iat": ISSUED_AT, "exp": ISSUED_AT + 900}
token = sign_hs256(header, payload, SECRET)
print(token)
print("частей:", len(token.split(".")))

Вывод:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiLQkNC90L3QsCIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzkxNjMzNjAwLCJleHAiOjE3OTE2MzQ1MDB9.rLQPFGTVse1LdrQi9UqnRTgCpIEds3Sx2qSIlQJBgCU
частей: 3

Функция b64url_decode дописывает отброшенные знаки =: без них подпись из 43 символов не декодируется (binascii.Error: Incorrect padding).

Расшифровка JWT: данные читаются без ключа

Расшифровать JWT в привычном смысле не нужно, его ничем не шифровали. Первые две части декодируются с помощью той же функции:

h, p, s = token.split(".")
print(json.loads(b64url_decode(h)))
print(json.loads(b64url_decode(p)))

Вывод:

{'alg': 'HS256', 'typ': 'JWT'}
{'sub': '42', 'name': 'Анна', 'role': 'user', 'iat': 1791633600, 'exp': 1791634500}

Ключ здесь не участвует, в PyJWT то же делает jwt.decode(token, options={"verify_signature": False}). Отсюда правило безопасности: в токен не кладут пароли, номера документов и другие персональные данные пользователя. Иначе есть два способа: зашифровать токен (JWE) или передавать его только по HTTPS. Рабочий токен в чужой онлайн-декодер не вставляйте: bearer-токеном может воспользоваться любой, у кого он есть, пока не истёк срок.

Открытка и запечатанный конверт на столе

Проверка токена: ловим подделку

Наша функция проверяет токен по порядку: три части, разрешённый алгоритм, подпись, срок. Список алгоритмов задаёт сервер, а поле из заголовка только сверяет с ним. Подписи сравниваются через hmac.compare_digest: за постоянное время, чтобы закрыть атаки по времени ответа. Код продолжает предыдущие блоки.

def verify_hs256(token: str, key: bytes, when: int) -> dict:
    parts = token.split(".")
    if len(parts) != 3:
        raise ValueError("ожидалось три части через точку")
    h, p, s = parts
    alg = json.loads(b64url_decode(h)).get("alg")
    if alg != "HS256":
        raise ValueError(f"алгоритм {alg!r} не разрешён")
    expected = hmac.new(key, f"{h}.{p}".encode("ascii"), hashlib.sha256).digest()
    if not hmac.compare_digest(expected, b64url_decode(s)):
        raise ValueError("подпись не совпала")
    claims = json.loads(b64url_decode(p))
    if "exp" not in claims or when >= claims["exp"]:
        raise ValueError("срок действия истёк или не задан")
    return claims


print("исходный ->", verify_hs256(token, SECRET, ISSUED_AT))

claims = json.loads(b64url_decode(p))
claims["role"] = "admin"
forged = h + "." + b64url_encode(to_json(claims)) + "." + s
unsigned = b64url_encode(b'{"alg":"none"}') + "." + p + "."

cases = [
    ("подделка role", forged, ISSUED_AT),
    ("alg none", unsigned, ISSUED_AT),
    ("момент exp", token, ISSUED_AT + 900),
    ("чужой ключ", sign_hs256(header, payload, b"x" * 32), ISSUED_AT),
]
for name, t, when in cases:
    try:
        verify_hs256(t, SECRET, when)
        print(name, "-> ПРИНЯТ")
    except ValueError as err:
        print(name, "->", err)

Вывод:

исходный -> {'sub': '42', 'name': 'Анна', 'role': 'user', 'iat': 1791633600, 'exp': 1791634500}
подделка role -> подпись не совпала
alg none -> алгоритм 'none' не разрешён
момент exp -> срок действия истёк или не задан
чужой ключ -> подпись не совпала
  1. Подделка role: в данные вписан "role": "admin", подпись старая, HMAC от нового содержимого с ней не совпал.
  2. alg: none: стандарт допускает токен без подписи с пустой третьей частью, и некоторые библиотеки принимали такой токен вообще без проверки. Наша функция пропускает только HS256.
  3. Момент истечения: токен не принимается начиная с указанного времени, поэтому сравнение >=. Токен без срока мы тоже отклоняем, хотя стандарт поле не требует: иначе украденный токен проходил бы проверку всегда.
  4. Чужой ключ: те же данные, подписанные другим секретом, не прошли.
Читайте также:  SQL-инъекция: как работает и как защититься параметризованными запросами

Функция verify_hs256 не обращается ни к базе, ни к другому хранилищу. Выданный токен она примет до конца срока, даже если пользователь уже вышел из аккаунта. Если токены нужно отзывать раньше, сервер ведёт чёрный список по jti и iss и сверяет его при каждом запросе.

Свою проверку мы писали, чтобы показать устройство токена, в проекте лучше использовать библиотеку, например PyJWT. В ней уже есть проверка nbf и aud, допуск на расхождение часов (leeway) и асимметричные алгоритмы.

Алгоритм подписи: HMAC или RSA

HS256 симметричный: подписывают и проверяют одним секретным ключом. Значит, секрет должен быть у каждого, кто проверяет токен, и любой из них может выпустить свой токен. RS256 и ES256 асимметричные: сервер авторизации подписывает закрытым ключом, остальные сервисы проверяют открытым и подделать токен не могут.

Алгоритм Что внутри Ключ Статус в стандарте
HS256 HMAC на SHA-256 общий секрет, не меньше 256 бит обязателен
RS256 RSA (PKCS#1 v1.5) и SHA-256 пара RSA от 2048 бит рекомендован
ES256 ECDSA (P-256) и SHA-256 пара ключей P-256 рекомендован
none без подписи нет необязателен

Мы бы советовали так: один сервис, который сам выдаёт и сам проверяет токены, спокойно живёт на HS256. Когда токены проверяют несколько сервисов, берите RS256 или ES256 и раздавайте им только открытый ключ.

Два одинаковых ключа слева, золотой ключ и три замка справа

JWT на Python с PyJWT

Для RSA и ECDSA библиотеке PyJWT нужен пакет cryptography, поэтому ставим pip install "pyjwt[crypto]". Параметр algorithms обязателен: без него PyJWT 2.15.1 падает с DecodeError: It is required that you pass in a value for the "algorithms" argument when calling decode(). Пример с RS256, отдельный файл:

from datetime import datetime, timedelta, timezone

import jwt  # pip install "pyjwt[crypto]"
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_pem = private_key.public_key().public_bytes(
    serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
)
now = datetime.now(timezone.utc)

access = jwt.encode({"sub": "42", "role": "user", "exp": now + timedelta(minutes=15)}, private_key, algorithm="RS256")
claims = jwt.decode(access, public_pem, algorithms=["RS256"])
print("принят:", claims["sub"], claims["role"])

h, p, s = access.split(".")
fake = jwt.utils.base64url_encode(b'{"sub":"42","role":"admin","exp":4102444800}').decode()
expired = jwt.encode({"sub": "42", "exp": now - timedelta(minutes=1)}, private_key, algorithm="RS256")
for name, t in [("подделка role", f"{h}.{fake}.{s}"), ("просрочен", expired)]:
    try:
        jwt.decode(t, public_pem, algorithms=["RS256"])
        print(name, "-> ПРИНЯТ")
    except jwt.InvalidTokenError as err:
        print(f"{name} -> {type(err).__name__}: {err}")

try:
    jwt.encode({"sub": "1", "role": "admin"}, public_pem, algorithm="HS256")
except jwt.InvalidKeyError as err:
    print("HS256 на открытом ключе ->", err)

Вывод:

принят: 42 user
подделка role -> InvalidSignatureError: Signature verification failed
просрочен -> ExpiredSignatureError: Signature has expired
HS256 на открытом ключе -> The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

Строка «HS256 на открытом ключе» связана с атакой: злоумышленник меняет алгоритм на HS256 и подписывает токен открытым ключом сервера как HMAC-секретом, а некоторые библиотеки такой токен принимали. Сервер с algorithms=["RS256"] такой токен не примет, а PyJWT к тому же не даёт использовать открытый ключ как HMAC-секрет.

Читайте также:  Хеш-функция: что это, свойства, коллизии и примеры на Python

Исключения проверки наследуются от jwt.InvalidTokenError, так что в обработчике хватает одного except с ответом 401. Для HS256 секрет можно передать в байтах. Наш токен из первых блоков PyJWT принимает, если дописать в тот же файл:

import jwt  # продолжение файла с первыми тремя блоками

# время в примере зафиксировано (10.10.2026, 12:00-12:15 UTC), поэтому проверку времени отключаем
print(jwt.decode(token, SECRET, algorithms=["HS256"], options={"verify_exp": False, "verify_iat": False}))

Вывод:

{'sub': '42', 'name': 'Анна', 'role': 'user', 'iat': 1791633600, 'exp': 1791634500}

Срок токена из примера истёк 10.10.2026 в 12:15 UTC, без этих опций будет ExpiredSignatureError. В рабочем коде проверку времени не отключают.

Руки разработчика на клавиатуре, терминал на мониторе

Типичные ошибки и уязвимости

  • Ключ-пароль вроде secret123 для HS256 можно подобрать перебором по словарю, как только у злоумышленника есть хоть один токен. Ключ генерируйте secrets.token_bytes(32) и храните вне кода.
  • Без aud токен, который сервер авторизации выдал для одного API, может принять другой API того же издателя. Ставьте aud и проверяйте его.
  • Значение kid приходит от клиента: подставленное в SQL-запрос без проверки, оно открывает путь SQL-инъекции.

Access и refresh токены

Короткий access token уходит в API с каждым запросом, а по refresh token клиент получает новый access token без повторного ввода пароля. Браузерным и мобильным приложениям, которые не могут хранить секрет, refresh token меняют при каждом обновлении или криптографически привязывают к клиенту.

В localStorage мы бы токен не клали: данные там доступны любому JavaScript на странице, и одна XSS-уязвимость может отдать его злоумышленнику. Надёжнее cookie с флагом HttpOnly: скрипт её не прочитает. Тогда браузер передаёт токен в заголовке Cookie, и сервер берёт его оттуда, а не из Authorization. Браузер при этом отправляет её с запросами сам, так что защита от CSRF обязательна, часть таких атак отсекает атрибут SameSite=Strict. В URL токен не кладут: адреса остаются в истории браузера и логах веб-сервера.

Браузер на мониторе, замок и ключ на столе

Частые вопросы о JWT

Можно ли расшифровать JWT токен без ключа?

Да: заголовок и данные закодированы в Base64url, ключ нужен только для проверки подписи. Исключение составляет JWE, где данные зашифрованы.

Чем JWT отличается от Bearer?

JWT задаёт формат токена, а Bearer способ передачи: заголовок Authorization: Bearer ... и правило «кто предъявил токен, тот им и пользуется». Bearer-токеном может быть и JWT, и случайная строка, которую сервер сверяет со своей базой.

Сколько живёт JWT?

Столько, сколько записано в exp: стандарт срок не задаёт и само поле не требует. Bearer-токены, особенно для браузера, лучше выдавать не больше чем на час: короткий срок может снизить ущерб при утечке.

Можно ли отозвать JWT до истечения срока?

Отзывают по списку отозванных jti или сменой ключа подписи: после неё все выданные токены перестанут проходить проверку, как токен с чужим ключом в примере выше.

Оцените статью
bestprogrammer.ru
Добавить комментарий