Содержание урока · 11 разделов
- Устройство jwt: header — паспорт алгоритма
- Payload: claims, которым сервер верит
- Три части токена в одном кадре
- Signature: что защищает HMAC-SHA256
- Подделка на практике: меняю payload и смотрю
- Кейс: как крадут токен и что защищает подпись
- Спорное мнение: длинный exp — не всегда ошибка
- Где токен едет по сети
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Устройство 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-токен руками
GOpackage 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, который читать может каждый, а верить ему можно только после подписи. Именно поэтому в нём живут идентификатор и роль, а не профиль целиком.
Три части токена в одном кадре#
| Часть | Что внутри | Пример | Кто читает |
|---|---|---|---|
| header | JSON: алгоритм и тип | alg HS256, typ JWT | кто угодно |
| payload | JSON с claims | sub, role, exp | кто угодно |
| signature | HMAC-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 без подписи
GOparts := 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)) // truePayload подменён на роль admin, подпись оставлена от старой версии. ParseWithClaims пересчитывает HMAC от новых частей и сравнивает с приложенной — несовпадение оборачивается ошибкой, в которой errors.Is находит jwt.ErrTokenSignatureInvalid. Декодирование при этом прошло бы без проблем: читаемость payload и проверка подписи — независимые операции.
Токен проходит через прокси, балансировщики, логи и браузер — payload видит каждый, у кого он оказался. Пароли, номера карт, паспортные данные, точный адрес — в claims им не место. Я бы добавил сюда и email с телефоном: пользы от них в каждом запросе немного, а утечка одного лога превращается в утечку персональных данных. Правило: в payload — идентификатор, роль и служебные поля, всё остальное сервер достаёт по id из базы.
Стандартный base64 использует плюс, слэш и добивающие знаки равенства — символы, которые плохо живут в URL и заголовках. В JWT применяется URL-вариант без паддинга: в Go это base64.RawURLEncoding. Отсюда практический вывод: декодировать части токена нужно именно RawURLEncoding, а не StdEncoding — иначе на ровном месте получите ошибку декодирования.
Проверка: анатомия строки
Сколько частей у JWT и чем они разделены?
Проверка: подмена payload
Атакующий изменил поле role в payload токена, подпись не тронул. Что произойдёт при проверке?
Проверка: поле alg
Что делает поле alg в header токена?
Декодирование без проверки
Прочитайте код и выберите, что он выведет:
// token — настоящий токен, выпущенный сервером
// проверка подписи нигде не вызывалась
parts := strings.Split(token, ".")
payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
fmt.Println(string(payload))Разбор токена на части
Допишите разрезание строки токена и декодирование payload.
Соберите токен вручную
Расставьте строки сборки HS256-токена: от упаковки частей до приклейки подписи.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Куда это ведёт: с анатомией закончено, и дальше токен перестаёт быть ручной работой. В уроке про установку golang-jwt та же сборка сожмётся в пять строк библиотечного кода, а ключи и даты в claims обзаведутся типами. Если хочется повторить ручную сборку с другими данными — держите под рукой вводный урок с пятишаговой схемой проверки и расписанием всего курса. Из теории впереди ещё одно обязательное звено — HMAC и секрет: почему подпись одного и того же токена у двух сервисов разная и как не потерять ключ. Код этого урока, как и весь курс, рассчитан на локальный Go: песочница сайта внешних пакетов не импортирует, но стандартной библиотеке отсюда хватит с головой.
Карточки: анатомия токена
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 отличается от шифрования?
Ничем — потому что это не шифрование. JWT гарантирует целостность: содержимое не изменено после подписи. Прятать содержимое умеет JWE, отдельный стандарт, но в аутентификации он редок: в токене обычно нет данных, которые стоило бы прятать.
Можно ли хранить в payload секретные данные?
Нет. Payload закодирован base64url — декодер пишется в три строки, и токен видят прокси, логи и браузер. Секретные данные остаются на сервере, в токен едет только идентификатор, по которому сервер достаёт остальное.
Почему в токене base64url, а не обычный base64?
Плюс и слэш плохо переносятся в URL и заголовках, а знаки равенства удлиняют строку. URL-вариант алфавита без паддинга делает токен пригодным для cookie, query-параметров и Authorization без всякого экранирования.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния