Claims — утверждения. Так это и переводится: токен говорит «субъект — 42», «выдан — сегодня», «годен — до обеда». Формально payload — обычный JSON, и класть туда можно что угодно; пользу приносят договорённости: имена полей закреплены стандартом RFC 7519, поэтому серверы и библиотеки разных языков понимают друг друга без перевода. В golang-jwt v5 два способа задать claims. Первый — jwt.MapClaims, map[string]any из прошлого урока: быстро и гибко, но компилятор не подскажет опечатку в имени поля. Второй — своя структура со встраиванием jwt.RegisteredClaims: стандартные поля с типами и методами валидации плюс ваши собственные. Для учебных скриптов и тестов хватает карты; для продакшена я завожу структуру — и в этом уроке покажу обе дороги, чтобы выбор был сознательным, а не случайным.
Стандартные claims RFC 7519 в RegisteredClaims#
| Поле структуры | JSON-имя | Смысл |
|---|---|---|
| Issuer | iss | кто выпустил токен: имя сервиса или домен |
| Subject | sub | о ком токен: обычно идентификатор пользователя строкой |
| Audience | aud | для кого токен: получатель или список получателей |
| ExpiresAt | exp | время смерти, после которого токен невалиден |
| NotBefore | nbf | не принимать раньше этого момента |
| IssuedAt | iat | момент выпуска |
| ID | jti | уникальный номер токена — по нему ищут и отзывают |
jwt.RegisteredClaims и NewNumericDate: типы, а не сырые числа#
Стандартные поля в структуре типизированы, и главное открытие ждёт там, где ждёшь int64. Даты — IssuedAt, ExpiresAt, NotBefore — живут в типе *jwt.NumericDate, и получают их через jwt.NewNumericDate от time.Time. Почему не голые числа: NumericDate хранит unix-секунды с возможной дробной частью, умеет сравниваться и главное — несёт методы валидации, по которым v5 понимает, что exp просрочен, а nbf ещё не наступил. Audience устроен хитрее всех: тип jwt.ClaimStrings принимает и строку, и массив строк, потому что стандарт допускает обе записи. Subject — строка даже для числовых идентификаторов: fmt.Sprintf с %d привычен и честен. Свои поля добавляются обычными полями структуры: UserID int64, Role string. Единственная шероховатость — имена в JSON: без json-тегов ваши поля уедут в payload как UserID и Role, а команда обычно хочет user_id и role; теги дописываются при объявлении структуры одной строкой и ничего не ломают.
Быстрый путь: MapClaims
GOclaims := jwt.MapClaims{
"sub": "42",
"role": "admin",
"exp": jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
"iat": jwt.NewNumericDate(time.Now()),
"jti": "42-1767225600",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(secret))MapClaims — это map[string]any: имена полей пишутся руками, и компилятор опечатку не поймает. Зато никакую структуру объявлять не нужно, и в тестах это самый быстрый путь. jti собран из идентификатора и времени — при повторе он не уникален, но для демонстрации нагляден; в продакшене его делает UUID или похожий генератор.
Взрослый путь: своя структура с RegisteredClaims
GOtype UserClaims struct {
UserID int64
Role string
jwt.RegisteredClaims
}
func makeClaims(id int64, role string) UserClaims {
now := time.Now()
return UserClaims{
UserID: id,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", id),
Issuer: "goma-auth",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
ID: fmt.Sprintf("%d-%d", id, now.UnixNano()),
},
}
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, makeClaims(42, "admin"))Своя структура встраивает jwt.RegisteredClaims: вместе с ним прилетают типы полей и методы валидации, которые ParseWithClaims вызовет сам. Subject заполняется строкой из id, ID — jti из UnixNano, ExpiresAt — пятнадцатью минутами от now. Конструктор makeClaims собирает всё в одном месте, и пропустить exp становится сложно.
exp, iat, nbf: что библиотека проверяет сама#
Свои поля приносят и свои ошибки, и моя любимая — jti из идентификатора пользователя. Схема выглядит разумно: sub и jti совпадают, всё предсказуемо. Проблемы начинаются при отзыве: denylist хранит номера вычеркнутых токенов, и вычеркнутый jti убивает не конкретный токен, а все текущие и будущие токены пользователя — вместе с refresh-парой и следующими входами, которые он не заслужил. Вторая вариация — jti из time.Now().Unix(): два токена, выпущенные в одну секунду, получают один номер и обмениваются судьбой. Требование к jti ровно одно — уникальность, и дешевле закрыть её сразу: UUID, снежинка или UnixNano из примера выше. Проверяется это одной строкой в тесте: выпустите два токена подряд и сравните ID. Как строится отзыв и что он стоит — урок про denylist.
Проверка дат — то место, где v5 заметно добрее прежних версий. При ParseWithClaims библиотека автоматически сверяет exp с текущим временем и отвергает просроченный токен, проверяет nbf — не рано ли, и iat — не из будущего ли. Условие одно: поля должны быть заполнены. А вот отсутствие exp ошибкой по умолчанию не считается: токен без срока годности пройдёт проверку и будет валиден вечно — та самая ошибка из вводного урока, теперь на уровне API. Лечится опцией jwt.WithExpirationRequired(): парсер требует exp и возвращает ошибку, если его нет. Я ставлю эту опцию во все парсеры проекта по умолчанию: она превращает забывчивость выпуска в мгновенный отказ проверки. Ещё в копилку автопроверки — jwt.WithLeeway(time.Minute): часы серверов расходятся, и льготная минута снимает целую категорию случайных отказов на границе exp.
Вариация примера, которая показывает темпоральные поля в деле: токен, действующий с будущего момента. NotBefore заполняется тем же jwt.NewNumericDate, только время указывается не сейчас, а позже: ночной батч получает токен, который примется только после полуночи, или заранее выпускается партия токенов для завтрашнего мероприятия. Проверка идёт автоматически: парсер сверяет nbf наравне с exp и отвергает рано пришедший токен ошибкой jwt.ErrTokenNotValidYet — её, как и все ошибки v5, разбирают через errors.Is. Нюанс, о который спотыкаются: leeway действует и на nbf, поэтому льготная минута парсера сдвигает и начало действия — токен может приняться чуть раньше заявленного. Для большинства задач это неважно, для токенов с жёстким окном запуска — критично: либо убирайте leeway, либо закладывайте запас в nbf. iat остаётся честным временем выпуска: это момент подписи, а не начала действия.
Проверка UserClaims с обязательным exp
GOparsed, err := jwt.ParseWithClaims(signed, &UserClaims{},
func(t *jwt.Token) (any, error) {
return []byte(secret), nil
},
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithExpirationRequired())
if errors.Is(err, jwt.ErrTokenExpired) {
fmt.Println("токен просрочен — нужен refresh")
return
}
if err != nil {
fmt.Println("токен отвергнут:", err)
return
}
claims := parsed.Claims.(*UserClaims)
fmt.Println(claims.UserID, claims.Role)Два опциона-замка: WithValidMethods держит алгоритм, WithExpirationRequired требует exp. При ошибке первым проверяется jwt.ErrTokenExpired через errors.Is — просрочка отвечает клиенту подсказкой про refresh, остальное молча превращается в 401. На успехе claims уже декодированы в UserClaims: поле Role читается как обычное поле структуры.
Стандартные поля не обязаны проверяться вручную: парсер умеет сам. jwt.WithIssuer("goma-auth") требует точного совпадения iss, jwt.WithAudience("api") — попадания aud в ожидаемый список. Полезно, когда один секрет обслуживает несколько сервисов: токен соседнего сервиса подписан верно, но отвергается по audience ещё до вашей бизнес-логики. Ставьте обе опции рядом с WithValidMethods — одна строка парсера, минус целый класс путаницы между сервисами.
Выпустите токен без ExpiresAt и проверьте его без WithExpirationRequired — проверка пройдёт, Valid станет true, и токен будет вечным: подпись настоящая, срока годности нет. Именно так бесконечные сессии и появляются в проектах: не злой умысел, а незаполненное поле. Две строчки страховки: exp при выпуске и WithExpirationRequired при проверке.
Свои поля в jwt claims: сколько данных носить с собой#
Сколько пользовательских данных класть в jwt claims — вопрос компромисса, и решать его стоит по цене запроса. Каждое поле увеличивает токен, а токен едет в заголовке каждого запроса: сотни байт против похода в базу. В токене хорошо живут идентификатор, роль, версия прав — то, что меняется редко и читается всегда. Плохо живут корзина покупателя, настройки интерфейса, статистика — любые данные, которые редактируются чаще, чем раз в exp: обновите корзину в базе, а токен продолжит нести старую копию до истечения срока. Помните и о цене ошибки: payload читается всеми, поэтому ничего личного сверх идентификатора я бы не клал вовсе. Изменяемые данные достаются по sub из базы или кэша — токен отвечает за личность, база за состояние. Это разделение сохранит и размер, и свежесть данных, и спокойный сон.
Email, телефон, имя и адрес в payload выглядят удобно — и превращают каждый лог с токеном в утечку персональных данных. Токен декодируется без ключей, живёт у клиента до exp и остаётся в логах серверов и прокси на всём этом пути. В claims — идентификатор и роль; имя и контакты сервер достанет по sub, когда они понадобятся ответу.
Пара слов о выборе между картой и структурой напоследок. MapClaims — для тестов, примеров и разовых скриптов: меньше церемоний, но имена полей никто не проверяет. Структура с RegisteredClaims — для сервисов: компилятор ловит опечатки, валидация дат работает из коробки, а добавление своего поля — одна строка и понятный дифф. В курсе дальше будут и то, и другое: тесты удобно собирать картами, а middleware — структурой. Выбор структуры не помешает и переходу на RSA в уроке про асимметричные подписи: claims не зависят от алгоритма, меняются только тип ключа в SignedString и источник ключа в keyfunc. А если маршрут курса кажется вам затянутым — перечитайте программу на странице курса и посмотрите, что осталось до итогового проекта.
Проверка: время смерти
Какое поле RegisteredClaims задаёт время смерти токена?
Проверка: токен без exp
Токен выпущен без поля exp. Что сделает проверка ParseWithClaims с настройками по умолчанию?
Проверка: смена прав
Пользователь поменял роль в базе с user на admin, но продолжает ходить со старым токеном на 15 минут. Что вернёт ваш middleware?
Что напечатает декодер payload?
Прочитайте код и выберите, что он выведет:
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": "42",
"role": "admin",
"email": "ivan@example.com",
})
signed, _ := token.SignedString([]byte("secret"))
parts := strings.Split(signed, ".")
decoded, _ := base64.RawURLEncoding.DecodeString(parts[1])
fmt.Println(string(decoded))Даты в RegisteredClaims
Допишите заполнение дат: время смерти и момент выпуска токена.
Соберите claims по порядку
Расставьте строки сборки MapClaims: от точки отсчёта времени до закрывающей скобки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: claims
Токен обзавёлся типами и сроками, и остался последний кирпич фундамента — подпись. В уроке про HMAC и HS256 разберём, что именно считает SignedString, где жить секрету и как не дать атакующему подменить алгоритм. Если хотите освежить, как токен выглядит снаружи, — вернитесь к рукотворной сборке из второго урока и добавьте в неё поля из таблицы этого урока: те же строки, теперь с полным набором стандартных claims. Домашнее задание, как всегда, под локальный Go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Claims — поля payload: стандарт RFC 7519 собран в jwt.RegisteredClaims, свои поля добавляются в структуру или карту.
- Даты — тип NumericDate через jwt.NewNumericDate; Audience принимает jwt.ClaimStrings — строку или массив.
- Библиотека сама проверяет exp, nbf и iat; отсутствие exp по умолчанию пропускается — ставьте WithExpirationRequired().
- Короткий exp — способ разносить изменения прав: новая роль подхватится после истечения срока.
- В claims живут идентификатор, роль и версия прав; корзина, настройки и персональные данные — в базе.
- MapClaims — для тестов и скриптов, своя структура с RegisteredClaims — для сервисов.
Домашнее задание
Расширьте программу из прошлого урока: заведите структуру UserClaims с UserID и Role, заполните RegisteredClaims полностью — Subject, Issuer, IssuedAt, ExpiresAt, ID. Выпустите токен и проверьте тем же ParseWithClaims с WithExpirationRequired. Затем уберите ExpiresAt из конструктора и убедитесь, что проверка стала отказывать: так работает обязательный exp.
Личные заметкиПусто
Вопросы и ответы по уроку
MapClaims или своя структура — что выбрать?
Для тестов и примеров — MapClaims: быстрее собрать. Для сервисов — структура со встраиванием RegisteredClaims: компилятор проверяет имена полей, валидация дат работает из коробки, а payload получается предсказуемым.
Почему Subject — строка, если идентификаторы числовые?
Так диктует RFC 7519: sub — строка или URI. Число переводится fmt.Sprintf, обратно распознаётся strconv. Числовой идентификатор можно держать и в своём поле структуры рядом — но стандартное поле остаётся строкой.
Что происходит с токенами после смены прав пользователя?
Ничего: claims — снимок на момент выпуска, и старый токен будет врать до exp. Поэтому access-токены живут минуты, права читаются из свежего токена, а радикальные случаи — отзыв по jti — требуют отдельного механизма вроде denylist.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния