Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 2 из 25 ~18 мин +20 XP

Три части токена: header, payload, signature

Устройство jwt видно невооружённым глазом: любой токен — три блока текста, склеенные двумя точками, и каждый блок читается без секретов. Разберём jwt-структуру токена по слоям — header, payload, signature — соберём все три части руками на стандартной библиотеке Go и проверим, что именно защищает подпись, а что остаётся открытым всем.

Содержание урока · 11 разделов

Устройство jwt: header — паспорт алгоритма#

Возьмите любой токен из вашего проекта — из лога, из DevTools, из теста — и присмотритесь. Между тремя кусками бессмысленной на вид латиницы стоят две точки. Это не украшение: точка — разделитель, а каждый кусок — base64url-закодированный JSON. Первое открытие, которое ждёт каждого новичка, звучит неприятно: второй кусок, payload, читается бесплатно, без ключей и без библиотек — любой декодер base64 справится за секунду. base64url — это не шифрование, а упаковка: способ перегнать произвольные байты в символы, безопасные для URL и заголовков. Шифрование превращает данные в мусор без ключа; кодирование — просто другая форма записи тех же данных. Весь токен целиком проходит перед глазами каждого, кто встретит его в пути: прокси, балансировщики, логи, системы аналитики. С этого факта начинается вся гигиена работы с JWT, и к концу урока вы соберёте токен руками, чтобы больше его не бояться.

Первый блок токена — заголовок, и устройство jwt начинается с него. Это маленький JSON вида {alg:HS256, typ:JWT}: поле alg объявляет алгоритм подписи, typ традиционно равен JWT. На первый взгляд — декорация, на деле — самый опасный байт токена: сервер читает alg до того, как проверит подпись, а библиотеки прошлых лет верили ему слепо. Отсюда историческая атака alg none: атакующий меняет алгоритм на none и выбрасывает подпись — некоторые реализации принимали такой токен как подписанный. Современный golang-jwt отвергает alg none на корню, но урок остался: алгоритм проверки нужно фиксировать явно, а не читать из токена. Второй гость заголовка — поле kid, идентификатор ключа: оно пригодится, когда ключей станет несколько и серверу придётся выбирать, каким именно проверять. Для HS256-токена, который собираем в этом курсе, header остаётся двухполевым, и это честно отражает его роль: паспорт, а не грузовой отсек.

Собираем HS256-токен руками

GO
package main

import (
        "crypto/hmac"
        "crypto/sha256"
        "encoding/base64"
        "encoding/json"
        "fmt"
)

func main() {
        head, _ := json.Marshal(map[string]string{
                "alg": "HS256",
                "typ": "JWT",
        })
        body, _ := json.Marshal(map[string]any{
                "sub":  "42",
                "role": "admin",
                "exp":  1767225600,
        })

        enc := base64.RawURLEncoding
        header := enc.EncodeToString(head)
        payload := enc.EncodeToString(body)
        signingInput := header + "." + payload

        mac := hmac.New(sha256.New, []byte("goma-demo-secret"))
        mac.Write([]byte(signingInput))
        signature := enc.EncodeToString(mac.Sum(nil))

        fmt.Println(signingInput + "." + signature)
}

Тот же токен, который выдаёт golang-jwt, собран вручную на четырёх пакетах стандартной библиотеки. json.Marshal готовит два JSON, base64.RawURLEncoding упаковывает их без плюсов, слэшей и знаков равенства, hmac.New с sha256.New считает подпись первых двух частей. Прогоните программу локальным Go — на выходе строка из трёх частей, по форме неотличимая от настоящей.

Payload: claims, которым сервер верит#

Второй блок — payload, JSON с утверждениями о токене, по-английски claims. Часть имён закреплена стандартом RFC 7519: sub — о ком токен, exp — когда умирает, iat — когда выпущен, jti — уникальный номер. Поверх стандартных кладут свои: role, user_id, версия прав. Пока токен не проверен, это просто слова; после проверки подписи они становятся фактами, которым сервер верит без похода в базу — ведь факты написал и подписал он сам. Отсюда и граница доверия: claims описывают состояние на момент выпуска. Поменяли пользователю роль в базе — токен со старой ролью будет врать до истечения exp, и длинный срок жизни превращает payload в устаревающую копию профиля. Разбор каждого стандартного поля, типы в Go и правила выбора собственных полей — тема следующего урока, здесь достаточно каркаса: payload — это JSON, который читать может каждый, а верить ему можно только после подписи. Именно поэтому в нём живут идентификатор и роль, а не профиль целиком.

Три части токена в одном кадре#

ЧастьЧто внутриПримерКто читает
headerJSON: алгоритм и типalg HS256, typ JWTкто угодно
payloadJSON с claimssub, role, expкто угодно
signatureHMAC-SHA256 от первых двух частей43 символа base64urlтолько пересчитывает сервер

Signature: что защищает HMAC-SHA256#

Третий блок — подпись, и это единственная часть, которую нельзя прочитать, только пересчитать. HMAC-SHA256 принимает два аргумента: сообщение и ключ. Сообщение — склейка header.payload ровно в том виде, в каком части едут в токене, байт в байт; ключ — секрет сервера. Подпись детерминирована: те же данные и тот же секрет дают тот же результат, поэтому проверка тривиальна — пересчитать и сравнить. А вот подделка упирается в стену: изменить один байт payload — и подпись разъедется, а пересчитать её без секрета невозможно, HMAC не позволяет восстановить ключ из известных пар «сообщение — подпись». Один нюанс, на котором ловят: сравнивать подписи обычным == опасно — сравнение байтов в Go останавливается на первом несовпадении, и по времени ответа можно выуживать правильные байты по одному. В стандартной библиотеке для этого есть hmac.Equal, сравнивающий за постоянное время; golang-jwt использует его внутри, но привычка запомнить стоит.

Подделка на практике: меняю payload и смотрю#

Проверим всё это экспериментом, который стоит прогнать руками хотя бы раз. Берём настоящий токен, декодируем payload, меняем роль с user на admin, кодируем обратно и оставляем подпись прежней. На вид токен неотличим от настоящего: три части, валидный base64url, осмысленные claims. Проверка развеивает иллюзию мгновенно: сервер пересчитывает HMAC от новых header и payload своим секретом, получает одну строку, сравнивает с приложенной — и видит несовпадение. golang-jwt вернёт ошибку ErrTokenSignatureInvalid, и это не исключение из правила, а правило в действии: подпись связывает конкретные байты конкретных частей с конкретным секретом. Заметьте, что декодирование при этом прошло успешно — прочитать подделанный payload смог бы кто угодно; провалилась именно проверка. Поэтому в боевых middleware решение принимают только по результату валидации, а не по внешнему виду токена.

Кейс: как крадут токен и что защищает подпись#

Подделку мы отбили, но у атаки есть вторая дверь, и подпись её не закрывает: не править токен, а украсть целиком. Сценарии прозаичны — скрипт на взломанной странице вытаскивает токен из localStorage, токен оседает в логе прокси, ноутбук остался разблокированным. Атакующий не меняет ни байта: отправляет ваш токен как есть, и проверка сходится идеально, потому что целостность не нарушена. HMAC гарантирует, что содержимое не тронуто, но не доказывает, что предъявитель — владелец. Против кражи работают другие рычаги: TLS защищает путь, короткий exp сжимает окно жизни украденной копии, а место хранения определяет, достанет ли его чужой скрипт — этой теме посвящён урок про хранение токена на клиенте. Экстренный сценарий — отзыв по jti из урока про denylist: токен вычёркивается до истечения срока.

Спорное мнение: длинный exp — не всегда ошибка#

Закреплю и спорный тезис, который вы услышите на ревью: короткий exp — добродетель, а длинный — ошибка. Для access-токенов живого пользователя это правда: пятнадцать минут ограничивают ущерб и от кражи, и от устаревшей роли. Но граница проходит не по длине срока, а по цене ошибки: сервисный токен между двумя внутренними контейнерами живёт часами, потому что канал изолирован, владельца-человека нет, а перевыпуск каждые пять минут окупается только нервами. Правило, которым пользуюсь: срок пропорционален цене утечки минус стоимости отзыва. Отзыв в JWT не бесплатный — это отдельный механизм с хранилищем, и пока его в проекте нет, длинный exp остаётся заявкой на инцидент. К сервисным токенам и их компромиссам курс вернётся в отдельном уроке.

Где токен едет по сети#

Остался вопрос, где именно токен путешествует. Стандартное место — заголовок Authorization со схемой Bearer: один заголовок, читается любым клиентом, не портит URL и не оседает в истории браузера, как query-параметр. Токен в URL — типовая ошибка: адрес с токеном попадает в логи серверов, прокси и закладки, и убрать его оттуда потом сложно. В cookie токен тоже встречается — для веб-интерфейсов это рабочий вариант со своими нюансами защиты от CSRF, до которых курс доберётся в уроке про HttpOnly cookie. Ещё одно практическое ограничение — размер: заголовки HTTP измеряются килобайтами, и раздутый payload с корзиной и настройками упрётся в предел раньше, чем вы заметите. Отсюда бытовая норма: токен — десятки-сотни байт, не больше. Если хочется носить больше данных, это сигнал, что часть их должна жить на сервере.

Эксперимент: role admin без подписи

GO
parts := strings.Split(token, ".")

payload, _ := json.Marshal(map[string]string{
        "sub":  "42",
        "role": "admin",
})

forged := parts[0] + "." +
        base64.RawURLEncoding.EncodeToString(payload) + "." + parts[2]

keyfunc := func(t *jwt.Token) (any, error) {
        return []byte(secret), nil
}

_, err := jwt.ParseWithClaims(forged, &jwt.MapClaims{}, keyfunc,
        jwt.WithValidMethods([]string{"HS256"}))

fmt.Println(errors.Is(err, jwt.ErrTokenSignatureInvalid)) // true

Payload подменён на роль admin, подпись оставлена от старой версии. ParseWithClaims пересчитывает HMAC от новых частей и сравнивает с приложенной — несовпадение оборачивается ошибкой, в которой errors.Is находит jwt.ErrTokenSignatureInvalid. Декодирование при этом прошло бы без проблем: читаемость payload и проверка подписи — независимые операции.

Проверка: анатомия строки

Сколько частей у JWT и чем они разделены?

Проверка: подмена payload

Атакующий изменил поле role в payload токена, подпись не тронул. Что произойдёт при проверке?

Проверка: поле alg

Что делает поле alg в header токена?

Декодирование без проверки

Прочитайте код и выберите, что он выведет:

// token — настоящий токен, выпущенный сервером
// проверка подписи нигде не вызывалась

parts := strings.Split(token, ".")
payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
fmt.Println(string(payload))

Разбор токена на части

Допишите разрезание строки токена и декодирование payload.

parts := strings.(token, ".") payload, _ := base64.RawURLEncoding.(parts[1]) fmt.Println(string(payload))

Соберите токен вручную

Расставьте строки сборки HS256-токена: от упаковки частей до приклейки подписи.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Куда это ведёт: с анатомией закончено, и дальше токен перестаёт быть ручной работой. В уроке про установку golang-jwt та же сборка сожмётся в пять строк библиотечного кода, а ключи и даты в claims обзаведутся типами. Если хочется повторить ручную сборку с другими данными — держите под рукой вводный урок с пятишаговой схемой проверки и расписанием всего курса. Из теории впереди ещё одно обязательное звено — HMAC и секрет: почему подпись одного и того же токена у двух сервисов разная и как не потерять ключ. Код этого урока, как и весь курс, рассчитан на локальный Go: песочница сайта внешних пакетов не импортирует, но стандартной библиотеке отсюда хватит с головой.

Карточки: анатомия токена

Термин

header

нажмите, чтобы перевернуть
Определение

Первая часть токена: JSON с алгоритмом подписи (alg) и типом (typ). Читается всеми, доверия не заслуживает — сервер фиксирует алгоритм сам.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Токен — три base64url-блока через две точки: header, payload, signature.
  • header объявляет алгоритм (alg) и тип; сервер не верит полю, а фиксирует алгоритм сам.
  • payload — JSON с claims: читается всеми, верить ему можно только после проверки подписи.
  • Подпись — HMAC-SHA256 от первых двух частей секретом; подделать её без секрета нельзя.
  • Сравнение подписей — hmac.Equal за постоянное время, не ==.
  • Токен едет в заголовке Authorization со схемой Bearer — не в URL и не в query-параметрах.
  • Собрать и разобрать токен можно пакетами encoding/base64, encoding/json и crypto/hmac без внешних зависимостей.

Домашнее задание

Соберите токен руками: программа из урока на локальном Go, секрет любой. Вставьте результат в декодер вроде jwt.io и сверьте, что header и payload читаются, а подпись декодер помечает невалидной, пока вы не впишете свой секрет. Затем поменяйте один символ в payload и убедитесь, что проверка разваливается: это то же самое, что сделает ваш middleware в продакшене.

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · Три части токена: header, payload, signature)

Вопросы и ответы по уроку

Чем JWT отличается от шифрования?

Ничем — потому что это не шифрование. JWT гарантирует целостность: содержимое не изменено после подписи. Прятать содержимое умеет JWE, отдельный стандарт, но в аутентификации он редок: в токене обычно нет данных, которые стоило бы прятать.

Можно ли хранить в payload секретные данные?

Нет. Payload закодирован base64url — декодер пишется в три строки, и токен видят прокси, логи и браузер. Секретные данные остаются на сервере, в токен едет только идентификатор, по которому сервер достаёт остальное.

Почему в токене base64url, а не обычный base64?

Плюс и слэш плохо переносятся в URL и заголовках, а знаки равенства удлиняют строку. URL-вариант алфавита без паддинга делает токен пригодным для cookie, query-параметров и Authorization без всякого экранирования.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Telegram25 уроков~12 чновичок25 уроков уже доступны

Telegram-боты на Go: от эха до полноценного сервиса

Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.

  • Создавать бота через BotFather и хранить токен безопасно
  • Получать обновления через long polling и webhook
  • Отвечать сообщениями, командами и клавиатурами
  • Строить многошаговые диалоги с сохранением состояния