У библиотеки JWT в Go непростая судьба, и её стоит знать, чтобы не скопировать в проект мёртвый пакет. Годы де-факто стандартом был dgrijalva/jwt-go — а к 2020 году он перестал поддерживаться, накопив в том числе CVE-2020-26160, обход проверки аудитории, закрытый уже в форке. Экосистема съехала на github.com/golang-jwt/jwt — поддерживаемый форк с тем же API, который с тех пор развивается самостоятельной жизнью. Сегодня это стандарт: пакет, на который переехали фреймворки, туториалы и большая часть продакшена. Пятая мажорная версия живёт в пути импорта как /v5 по правилам семантического версионирования Go. Отсюда практическое правило: встретили в старой статье импорт dgrijalva/jwt-go — читайте ради идей, а импорты и тонкости API переписывайте на v5. В пятой версии даты задаются через jwt.NewNumericDate, а ошибки парсинга агрегируются и различаются через errors.Is — об этом чуть ниже на живом примере.
Установка: три команды терминала
GOmkdir auth && cd auth
go mod init example.com/auth
go get github.com/golang-jwt/jwt/v5Стандартный старт любого библиотечного курса: модуль создаёт go mod init с именем проекта, зависимость подтягивает go get. В go.mod появится строка github.com/golang-jwt/jwt/v5 — и слэш /v5 обязателен в импорте: без него компилятор не найдёт пакет. Это семантическое версионирование Go, а не опечатка.
После go get загляните в go.mod: там появится require с конкретной версией вроде v5.2.1. Быстрая проверка из терминала: go list -m github.com/golang-jwt/jwt/v5 напечатает установленную версию. go mod tidy вычистит лишние записи и дополнит go.sum — прогоняйте его после правок импортов, а уязвимости пакета отслеживает govulncheck.
Что появилось в go.mod
GOmodule example.com/auth
go 1.23
require github.com/golang-jwt/jwt/v5 v5.2.1Файл go.mod после установки: require фиксирует конкретную версию — у вас она может быть новее, это нормально. Команда go mod tidy перепроверит зависимости и заполнит go.sum контрольными суммами. Мажор в пути импорта и в require должен совпадать: /v5 в обеих строках.
Создание jwt-токена в Go: NewWithClaims и SignedString#
В golang-jwt v5 выпуск токена распадается на две операции, и обе умещаются в одну строку каждая. jwt.NewWithClaims(method, claims) собирает заготовку: в неё уходят алгоритм подписи — для нашего курса jwt.SigningMethodHS256 — и набор claims, например jwt.MapClaims. Результат — указатель на jwt.Token, ещё не строка: токен не подписан. Вторая операция — SignedString(key): метод пересчитывает подпись тем алгоритмом, который был заявлен в NewWithClaims, и склеивает три части в финальную строку. Ключ для HS256 — срез байтов секрета, []byte(secret). Разделение не бюрократия: между сборкой и подписью можно заглянуть внутрь заготовки в отладчике, а сам интерфейс один и тот же для HS256, RS256 и ES256 — поменяются только алгоритм и тип ключа. Ошибка, если секрет окажется не того типа, вернётся второй возвращаемой величиной — привычная идиома Go, без паник и молчаливых провалов.
Первая программа: печатаем настоящий токен
GOpackage main
import (
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
func main() {
secret := os.Getenv("JWT_SECRET")
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "42",
"role": "admin",
"exp": jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
"iat": jwt.NewNumericDate(time.Now()),
})
signed, err := token.SignedString([]byte(secret))
if err != nil {
fmt.Println("ошибка подписи:", err)
return
}
fmt.Println(signed)
}Программа целиком: секрет читается из переменной окружения JWT_SECRET, claims заданы jwt.MapClaims, даты — через jwt.NewNumericDate, потому что v5 работает с NumericDate, а не с голыми числами. exp на пятнадцать минут и iat на момент выпуска — минимум, без которого токен выпускать нельзя. Запуск: go run main.go после export JWT_SECRET=значение.
Что печатает программа: знакомые три части#
Запустите программу — и на экране появится строка, которую вы уже умеете читать после урока про устройство токена: два куска base64url, точка, ещё кусок. Первый декодируется в заголовок с alg HS256, второй — в ваши claims с sub и role, третий — подпись, непрозрачная строка байтов. Полезная привычка: прогнать только что выпущенный токен через jwt.io или любой оффлайн-декодер и убедиться, что содержимое совпадает с задуманным — так ловятся опечатки в именах полей до того, как они доедут до клиента. Заодно проверка наглядно показывает цену безопасности: декодер без вашего секрета пометит подпись невалидной и будет прав — он же не знает ключа. Сам токен на вид стандартен, и именно поэтому к нему легко привыкнуть как к чёрному ящику; знание анатомии из прошлого урока — то, что оставит ящик открытым.
secret := "super-secret-key" прямиком в исходниках? Так начинается половина историй с утечкой токенов: секрет уезжает в git, репозиторий рано или поздно становится публичным, и любой читатель получает право выпускать токены от имени вашего сервиса. Секрет живёт в переменной окружения или секрет-хранилище — и никогда в исходниках. Утёкший секрет не меняют в одном месте кода: он меняется на всех сервисах сразу, а все выданные токены умирают.
Секрет и claims: из чего собирается токен#
Два ингредиента программы заслуживают отдельного слова. Секрет — строка из окружения, и её качество мы разберём в уроке про HMAC: пока важно лишь то, что она не хардкодится и не коммитится. Claims — это jwt.MapClaims, то есть map[string]any: имена полей произвольные, значения любые. Стандарту RFC 7519 соответствуют sub и exp; role — поле нашего проекта, и его права на жизнь обсуждаются в уроке про claims. Обратите внимание на exp: без него токен выпустится без единого предупреждения и будет валиден вечно — библиотека не заставляет срок годности при выпуске, это ваша ответственность как выпускающего. Пятнадцать минут — привычный срок для access-токена в паре с refresh-схемой, до которой курс доберётся в середине. А если хочется строгой гарантии на стороне проверки — она появится в виде опции WithExpirationRequired там же, в уроке про claims.
У самой простой программы урока есть тёмная сторона, и она стоит реальных инцидентов. Забыли ExpiresAt при выпуске, парсер на проверке остался без WithExpirationRequired — и токен, которого нет ни в базе, ни в сессиях, нельзя отозвать кнопкой, будет приниматься годами. Хуже того, такой токен часто выпускается в отладке с ролью admin и расползается по логам, скриншотам и тестовым записям. Утечка обычного токена лечится ожиданием: пятнадцать минут — и мёртв. Утечка вечного лечится только ротацией секрета, то есть перелогином всех пользователей сразу. Диагностика простая: декодируйте payload подозрительного токена — если exp в нём нет, перед вами не баг проверки, а дыра выпуска. Страховка умещается в две строки: exp при каждом выпуске и WithExpirationRequired при каждой проверке; обе разбираем в следующем уроке про claims.
Токен без проверки — половина дела, и библиотека позволяет проверить выпущенное в той же программе. jwt.ParseWithClaims принимает строку, контейнер для claims и keyfunc — функцию, которая возвращает ключ проверки. Для HS256 это тот же []byte(secret). Опция jwt.WithValidMethods([]string{"HS256"}) фиксирует ожидаемый алгоритм и отбрасывает токены с чужим alg до всякой проверки подписи — привычка, которую стоит ставить с первого дня. Если ошибок нет и parsed.Valid равен true, токен подписан нами и не просрочен, а claims уже декодированы в переданный контейнер. Полный разбор парсинга, keyfunc и всех типов ошибок — тема шестого урока, здесь важен сам факт: выпуск и проверка живут в одной библиотеке и делят один секрет.
Проверка тем же секретом
GOparsed, err := jwt.ParseWithClaims(signed, &jwt.MapClaims{},
func(t *jwt.Token) (any, error) {
return []byte(secret), nil
},
jwt.WithValidMethods([]string{"HS256"}))
if err != nil || !parsed.Valid {
fmt.Println("токен отвергнут:", err)
return
}
claims := *parsed.Claims.(*jwt.MapClaims)
fmt.Println(claims["sub"], claims["role"])ParseWithClaims режет строку, декодирует header и payload, достаёт ключ из keyfunc и сверяет подпись. WithValidMethods отсекает чужие алгоритмы заранее. Парная проверка err и parsed.Valid — идиома v5: ошибки агрегированы, а claims к моменту успеха уже лежат в MapClaims и читаются по именам.
API форка наследует оригинал, поэтому туториалы по dgrijalva/jwt-go почти применимы к v5 — за тремя отличиями: путь импорта, даты через jwt.NewNumericDate и структурированные ошибки вроде jwt.ErrTokenExpired, которые сравнивают через errors.Is. Копировать импорты не стоит, а вот идеи из старых статей пригодятся.
Спорное мнение: JWT — не замена сессиям#
Урок был бы неполным без спорного мнения: JWT — не автоматическое улучшение классических серверных сессий. Сессия в Redis отзывается мгновенно: сервер удалил ключ — и всё. Токен живёт у клиента до exp, и мгновенный выход требует denylist по jti, то есть хранилища и похода в него при каждой проверке — ровно того stateless, ради которого JWT обычно брали. Если у вас монолит с серверными страницами и один процесс, cookie-сессия проще и честнее. JWT выигрывает там, где проверять токены должны несколько независимых сервисов без общего хранилища: шлюз сверяет подпись на входе, бэкенд и фоновые воркеры делают то же, и никто не ходит в базу сессий. Я выбираю JWT осознанно, под задачу распределённой проверки, а не по умолчанию — правильный вопрос на ревью не «JWT или сессии», а «кто и где проверяет». Цене отзыва посвящён урок про denylist.
Чек-лист первого токена#
- Модуль создан go mod init, пакет подтянут go get github.com/golang-jwt/jwt/v5.
- Импорт — github.com/golang-jwt/jwt/v5, со слэшем /v5.
- Секрет приходит из переменной окружения, а не из исходников.
- У claims заполнены exp и iat через jwt.NewNumericDate.
- Алгоритм заявлен константой jwt.SigningMethodHS256, а не строкой из header.
- Проверка при парсинге фиксирует ожидаемый алгоритм через jwt.WithValidMethods.
Проверка: путь импорта
Какой путь импорта у пятой версии библиотеки?
Проверка: SignedString
Что делает метод SignedString у jwt.Token?
Токен без exp: сколько частей?
Прочитайте код и выберите, что он выведет:
signed, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "42",
}).SignedString([]byte("secret"))
fmt.Println(len(strings.Split(signed, ".")))Выпуск токена: два вызова
Допишите сборку заготовки и подпись.
Соберите main() выпускного однострочника
Расставьте строки программы: от создания claims до печати результата.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: golang-jwt v5
Первый токен выпущен и проверен, дальше — содержимое. В уроке про claims разберём стандартные поля RFC 7519, заведём собственную структуру claims и настроим автоматическую проверку дат, а следом в уроке про HMAC — наконец поймём, что именно делает SignedString с секретом внутри. Если HTTP-фреймворка в проекте ещё нет, самое время выбрать: middleware из середины курса мы напишем и на чистом net/http, и на chi с Gin. Код этого урока запускается локальным Go — песочница сайта не импортирует внешние пакеты, поэтому установка с go.dev остаётся обязательным шагом домашнего задания.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- dgrijalva/jwt-go заморожен с CVE; актуальная библиотека — github.com/golang-jwt/jwt/v5.
- Выпуск токена — две операции: jwt.NewWithClaims собирает заготовку, SignedString подписывает и склеивает строку.
- Секрет HS256 — []byte из переменной окружения; в исходниках ему не место.
- Даты в v5 задаются jwt.NewNumericDate; exp и iat обязательны для каждого выпускаемого токена.
- Проверка в один заход — ParseWithClaims c WithValidMethods, успех по parsed.Valid.
- Код курса требует локального Go: песочница не импортирует внешние пакеты.
Домашнее задание
Пройдите путь урока локально: go mod init, go get github.com/golang-jwt/jwt/v5, программа из урока с секретом из окружения. Выпустите токен, вставьте его в декодер и сверьте header и payload с задуманными. Затем выпустите второй токен с role user и сравните их payload глазами: различие должно быть ровно в одном поле.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем golang-jwt отличается от jwt-go?
golang-jwt — форк замороженного dgrijalva/jwt-go, который развивается сообществом: закрытые CVE, NumericDate для дат, структурированные ошибки в v5. API близкий, импорт другой — туториалы по старому пакету читают ради идей, а не копируют.
Каким должен быть секрет?
Для HS256 секрет — просто срез байтов, но не строка из словаря: 32 байта и больше, сгенерированные криптографическим генератором. Сложность важна не форме, а энтропии: перебор длинных случайных ключей неокупаем.
Почему токен не выпустить в песочнице сайта?
Песочница интерпретирует подмножество стандартной библиотеки и не умеет внешние пакеты, сеть и файловую систему. Библиотечные курсы проекта рассчитаны на локальный Go: код урока запускается go run после установки с go.dev.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния