Из чего состоит токен
Вот полный токен HS256, подписанный демонстрационным секретом demo-secret-do-not-use. Переносы строк добавлены для читаемости, настоящий токен записывается одной строкой:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFkYSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDM2MDB9
.nKTVXOM_r1JbE92WsFd2SSun0a1TByUV8ZpqrRUnIhk
Три части, разделённые .:
| Часть | Длина в Base64URL | Что внутри |
|---|---|---|
| Заголовок | 36 | {"alg":"HS256","typ":"JWT"} |
| Payload | 104 | {"sub":"user-42","name":"Ada","admin":false,"iat":1760000000,"exp":1760003600} |
| Подпись | 43 | 32 сырых байта HMAC-SHA256 |
Заголовок и payload содержат JSON, закодированный сначала в UTF-8, а затем в Base64URL. Подпись двоичная, так что смысл она имеет только как байты. iat и exp хранят метки времени Unix в секундах: этот токен выпущен 2025-10-09 в 08:53:20 UTC и истёк через час.
Почему Base64URL без дополнения
Токен передают там, где +, / и = создают проблемы: в строках запроса (+ превращается в пробел), путях URL (/ разделяет сегменты), cookie и HTTP-заголовках. Поэтому JWS (формат подписи из RFC 7515, на котором построен JWT) использует URL-safe алфавит из RFC 4648 и отбрасывает дополнение:
Кодирование Base64 с URL- и filename-safe набором символов, определённым в разделе 5 RFC 4648, с удалением всех завершающих символов «=» (как допускает раздел 3.2) и без каких-либо переносов строк, пробелов или иных дополнительных символов.
Отсюда два отличия от стандартного Base64:
+становится-, а/заменяется на_. В подписи выше есть_, который стандартные декодеры отвергают.- В конце нет
=. Длина сегмента никогда не бывает 4n+1, так что декодер всегда может понять, сколько дополнения убрали: остаток от деления длины на 4, равный 2, означает==, а равный 3 означает=.
Разница алфавитов подробно разобрана в статье Base64 и Base64URL.
Декодируем части вручную
Разбейте токен по точкам, верните стандартный алфавит и дополнение, декодируйте, затем разберите JSON. В браузере:
function decodePart(part) {
const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
const [header, payload] = token.split(".").slice(0, 2).map(decodePart);
Шаг с TextDecoder важен. Payload {"name":"Zoë 日本"} кодируется в eyJuYW1lIjoiWm_DqyDml6XmnKwifQ. Если пропустить преобразованную строку только через atob, имя превратится в кракозябры, начинающиеся с Zoë, потому что atob возвращает по символу на байт. В средах, где есть Uint8Array.fromBase64(part, { alphabet: "base64url" }), он заменяет первые три строки функции, см. Uint8Array.toBase64. В Node.js то же делает Buffer.from(part, "base64url").
В Python urlsafe_b64decode справляется с алфавитом, но дополнение ему всё равно нужно:
import base64, json
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
header, payload, signature = token.split(".")
print(json.loads(b64url_decode(header))) # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload))) # {'sub': 'user-42', 'name': 'Ada', ...}
В оболочке замените алфавит через tr и допишите дополнение в цикле:
jwt_part() {
local s
s=$(printf '%s' "$1" | tr '_-' '/+')
while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done
printf '%s' "$s" | base64 -d
}
jwt_part "$(echo "$TOKEN" | cut -d. -f2)"
Если нужно просто заглянуть внутрь токена, вставьте его в декодер JWT. Он работает в браузере, так что боевой токен не покинет ваш компьютер.
Почему payload можно прочитать
Кодирование ничего не шифрует. Base64URL лишь меняет способ записи байтов, и декодер JWT обращает его без всякого ключа. Подписанный JWT гарантирует, что payload не меняли после подписи. Скрывать payload он не обязан.
Кодирование к тому же стоит места. Каждый claim после кодирования в Base64URL вырастает на треть, а весь токен передаётся с каждым запросом в заголовке или cookie, так что держите payload компактным: идентификатор и несколько коротких claims, а не полный профиль пользователя.
Считайте каждый claim публичным. Идентификаторы пользователей, роли и сроки действия класть в токен можно. Пароли, API-ключи и персональные данные, которые владелец токена видеть не должен, туда не кладите. Если содержимое должно быть конфиденциальным, используйте зашифрованный JWT (JWE). Его компактная форма состоит из пяти частей в Base64URL: заголовок, зашифрованный ключ, IV, шифротекст и тег аутентификации. Разница в общем виде разобрана в статье кодирование и шифрование.
Как вычисляется подпись
Подпись покрывает точный ASCII-текст header.payload: две строки Base64URL с точкой между ними. Для HS256:
import hmac, hashlib
signing_input = f"{header}.{payload}".encode("ascii")
mac = hmac.new(b"demo-secret-do-not-use", signing_input, hashlib.sha256).digest()
hmac.compare_digest(mac, b64url_decode(signature)) # True
Затем сырые байты подписи кодируются в Base64URL без дополнения. Их длина зависит от алгоритма:
| Алгоритм | Байты подписи | Символы Base64URL |
|---|---|---|
| HS256 | 32 | 43 |
| ES256 | 64 | 86 |
| RS256 (ключ 2048 бит) | 256 | 342 |
В JWS подпись ES256 записывается как сырые значения R || S по 32 байта каждое. OpenSSL и многие криптобиблиотеки вместо этого выдают подпись ECDSA в кодировке DER, которая начинается с 30 и имеет переменную длину. На преобразовании между ними часто возникают ошибки «invalid signature».
Чтобы собрать и подписать токен ключами HS, RS, ES или EdDSA, воспользуйтесь кодировщиком JWT. Сырой HMAC в Base64 считает инструмент HMAC.
Частые ошибки
atobили стандартный декодер Base64 напрямую. На одних токенах работает, на других падает, смотря по тому, попался ли-или_. Payload из нашего примера декодируется черезatob, а подпись нет.- Дополнение при создании токенов. Некоторые библиотеки отвергают сегменты, которые заканчиваются на
=. Убирайте его. - Повторное кодирование заголовка или payload перед проверкой. Подпись считается по исходному тексту Base64URL. Если разобрать JSON и сериализовать его заново, может измениться порядок ключей или пробелы, и подпись перестанет совпадать. Всегда проверяйте подпись по сегментам в том виде, в каком их получили.
- Доверие декодированному payload. Декодирование ничего не проверяет. Проверяйте подпись ожидаемым алгоритмом и никогда не принимайте
"alg":"none"(заголовокeyJhbGciOiJub25lIiwidHlwIjoiSldUIn0), если только не делаете это намеренно. - Строка токена в качестве уникального ключа. 32 байтам подписи нужно 256 бит, а 43 символа несут 258. Последние 2 бита не используются, поэтому в нашем примере последний символ
kможно заменить наl,mилиn, и нестрогие декодеры вернут те же байты. Подпись по-прежнему проходит проверку. Для безопасности это не страшно, но одному токену могут соответствовать разные строки. - Метки времени как миллисекунды.
expиiatуказаны в секундах. Прежде чем передавать их вDateв JavaScript, умножьте на 1000.
Вопросы и ответы
Зашифрована ли полезная нагрузка JWT?
Нет. Обычный подписанный JWT (JWS) лишь кодирует payload в Base64URL, так что прочитать его может любой, у кого есть токен. Зашифрованные токены называются JWE, и в них пять частей вместо трёх.
Почему JWT не заканчивается знаками =?
RFC 7515 определяет кодирование как Base64URL с удалением всех = в конце, чтобы токены можно было вставлять в URL и заголовки без экранирования. Декодерам приходится возвращать дополнение или принимать строки без него.
Можно ли декодировать части JWT без секретного ключа?
Да. Заголовок и payload записаны обычным Base64URL. Ключ нужен только для проверки подписи или создания новой. Декодер JWT показывает обе части и проверяет подпись, если вы укажете ключ.
Почему каждый JWT начинается с eyJ?
Заголовок JWT содержит объект JSON, который начинается с {", а эти два байта всегда кодируются в eyJ. Другие подобные префиксы собраны в статье частые префиксы Base64.