JWT токен (JSON Web Token) записывается строкой из трёх частей, разделённых точками: заголовок, данные и подпись. Данные в обычном JWT не зашифрованы, прочитать их может любой, а подпись позволяет серверу проверить, что после выдачи в токене не изменили ни одного символа.
- Что такое JWT
- Структура JWT: header.payload.signature
- Header (заголовок)
- Payload (полезная нагрузка)
- Signature (подпись)
- Собираем и подписываем JWT на Python
- Расшифровка JWT: данные читаются без ключа
- Проверка токена: ловим подделку
- Алгоритм подписи: HMAC или RSA
- JWT на Python с PyJWT
- Типичные ошибки и уязвимости
- Access и refresh токены
- Частые вопросы о JWT
- Можно ли расшифровать JWT токен без ключа?
- Чем JWT отличается от Bearer?
- Сколько живёт JWT?
- Можно ли отозвать 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 с секретным ключом, который хранится на сервере. Если изменить хоть одну букву в данных, подпись перестанет совпадать, а пересчитать её может только владелец ключа.
Собираем и подписываем 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 -> срок действия истёк или не задан
чужой ключ -> подпись не совпала
- Подделка role: в данные вписан
"role": "admin", подпись старая, HMAC от нового содержимого с ней не совпал. alg: none: стандарт допускает токен без подписи с пустой третьей частью, и некоторые библиотеки принимали такой токен вообще без проверки. Наша функция пропускает только HS256.- Момент истечения: токен не принимается начиная с указанного времени, поэтому сравнение
>=. Токен без срока мы тоже отклоняем, хотя стандарт поле не требует: иначе украденный токен проходил бы проверку всегда. - Чужой ключ: те же данные, подписанные другим секретом, не прошли.
Функция 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-секрет.
Исключения проверки наследуются от 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 или сменой ключа подписи: после неё все выданные токены перестанут проходить проверку, как токен с чужим ключом в примере выше.








