Загрузить файл
  1. base64hub
  2. Статьи о Base64
  3. Base64 в JWT
Разбор

Как JWT использует Base64URL: декодируем токен вручную

JWT состоит из трёх строк Base64URL, соединённых точками. Первые две может декодировать кто угодно, защищает их только подпись. Разберём, как устроена и читается каждая часть.

Из чего состоит токен

Вот полный токен HS256, подписанный демонстрационным секретом demo-secret-do-not-use. Переносы строк добавлены для читаемости, настоящий токен записывается одной строкой:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFkYSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDM2MDB9
.nKTVXOM_r1JbE92WsFd2SSun0a1TByUV8ZpqrRUnIhk

Три части, разделённые .:

ЧастьДлина в Base64URLЧто внутри
Заголовок36{"alg":"HS256","typ":"JWT"}
Payload104{"sub":"user-42","name":"Ada","admin":false,"iat":1760000000,"exp":1760003600}
Подпись4332 сырых байта 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
HS2563243
ES2566486
RS256 (ключ 2048 бит)256342

В 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.

Читайте также