Клиент приносит токен, проверка из прошлого урока падает с ошибкой — и дальше начинается самое интересное: что ответить, что записать в лог и стоит ли поднимать тревогу. Ответ «401 Unauthorized» без деталей ломает пользовательский опыт: человек с истёкшим exp не понимает, что ему нужно перелогиниться или обновить токен, и пишет в поддержку. Заливать же детали в ответ без разбора — подарок нападающему: подробная диагностика помогает понять, где у сервера слабое место. Поэтому ошибки jwt в Go раскладывают по причинам: одна и та же ошибка разбора может значить порванную строку, чужую подпись или истёкший срок, и все три случая требуют разной реакции. И разница нужна не только человеку: мониторинг, который считает только коды ответов, не отличит атаку от штатного истечения расписания, поэтому причины отказов должны быть машинами читаемы — кодом в теле ответа и меткой в логе. В golang-jwt v5 для этого всё готово: причины — именованные переменные пакета, а сравнение идёт через errors.Is, потому что парсер оборачивает внутренние ошибки и сохраняет всю цепочку. Ключевая пара запросов в поисковиках — jwt expired go и различение ErrTokenExpired от всего остального — и это действительно первый случай, который стоит выделить отдельно.
Ветвление по причинам отказа
GOclaims := &AppClaims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, verifyKey)
if err != nil {
switch {
case errors.Is(err, jwt.ErrTokenExpired):
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
http.Error(w, "токен истёк, обновите через /api/v1/refresh",
http.StatusUnauthorized)
case errors.Is(err, jwt.ErrTokenNotValidYet):
http.Error(w, "токен ещё не действует", http.StatusUnauthorized)
case errors.Is(err, jwt.ErrTokenMalformed):
http.Error(w, "токен не разобран", http.StatusBadRequest)
default:
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
return
}errors.Is проверяет цепочку обёрнутых причин, поэтому сравнение работает даже когда парсер вернул склеенную ошибку. Протухший exp получает подсказку про refresh, nbf в будущем — вежливое повторить позже, порванная строка — 400. Ветка default молчит: подделку подписи и отказ keyfunc клиенту не диагностируем. WWW-Authenticate — стандартный спутник 401.
jwt expired go: почему истёкший токен — норма#
Из всех причин отказа только expired — штатное событие. Токен с exp в пятнадцать минут умирает по расписанию, и каждый пользователь долгой сессии регулярно приносит на сервер протухший токен: это не авария, а штатная работа по TTL, сама идея короткого срока жизни. Отсюда практические следствия. В ответе клиенту — внятная подсказка, где взять свежий токен: код TOKEN_EXPIRED и путь refresh-эндпоинта, который соберём в следующем уроке. В логах — не ошибка, а счётчик: алерт на каждое истечение заглушит мониторинг ложными срабатываниями за один вечер. В тестах — отдельный кейс: токен, выпущенный с exp в прошлом, обязан проходить по ветке expired, а не по общему 401. При этом важно не путать expired с остальным: истёкший токен подписан верно, и это ровно тот случай, когда подсказка безопасна — обновить его может только владелец, а чужой токен и так не пройдёт сверку подписи. Смешивать эти причины в один ответ — значит лишать честных пользователей подсказки или дарить её нападающим. Отдельный тонкий момент — автоповтор на клиенте: получив TOKEN_EXPIRED, клиент один раз молча обновляет пару и повторяет запрос; повторный отказ — уже сигнал идти на логин. Бесконечный цикл «истёк — обновил — снова истёк» обычно означает расхождение часов или refresh-токен, который обновляет сам себя, и то и другое лечится настройкой, а не ретраями.
Спорный вопрос любых ошибок jwt: единый 401 UNAUTHORIZED на всё или разные коды по причинам. Я держусь правила: в логи и метрики — все причины точно, наружу — подсказку только для expired. Детали вроде signature invalid помогают нападающему понять, дошла ли его подделка до сверки подписи, а диагностика портой строки и так бесполезна клиенту: он не чинит токен руками, он запрашивает новый. У правила есть исключение по духу: внутренние админ-панели и CLI-инструменты для своих — там подробности экономят часы отладки, и риск другой природы.
Каталог ошибок golang-jwt v5#
| Ошибка | Когда возникает | Ответ клиенту |
|---|---|---|
| jwt.ErrTokenMalformed | строка не разобралась: частей не три, битый base64url или невалидный JSON | 400 или 401, без деталей |
| jwt.ErrTokenUnverifiable | keyfunc вернул ошибку или ключ не того типа | 401, без деталей |
| jwt.ErrTokenSignatureInvalid | подпись не совпала: payload правлен или подписан чужим ключом | 401, молча |
| jwt.ErrTokenExpired | exp в прошлом — штатное истечение срока | 401 с подсказкой про refresh |
| jwt.ErrTokenNotValidYet | nbf в будущем — токен действует позже | 401, повторить позже |
| jwt.ErrTokenUsedBeforeIssued | iat в будущем — часы разошлись или подделка | 401, без деталей |
errors.Is: как парсер склеивает причины#
Механика здесь — стандартный Go, тот же приём, что в уроке про оборачивание ошибок продвинутого курса: парсер возвращает обёртки, а errors.Is распаковывает цепочку и сравнивает с целевой ошибкой по identity. Важный нюанс v5: одна ошибка разбора может соответствовать нескольким причинам сразу, потому что внутренние ошибки склеиваются, а не заменяют друг друга. Типичный пример — токен, который и протух, и правлен: exp в прошлом, а подпись после правки payload не сходится. errors.Is вернёт true и для ErrTokenExpired, и для ErrTokenSignatureInvalid — обе причины в цепочке. Из этого следует правило порядка веток: сначала проверяйте то, что важнее по безопасности. Если подпись не сошлась, советовать клиенту «обновите токен через refresh» нельзя — вы подсказываете как обновить чужую подделку. Подпись — первый case, время — второй: безопасность раньше удобства, даже когда обе проверки формально пройдут. При отладке печатайте ошибку целиком через %v: v5 отдаёт читаемую цепочку причин, и по записи видно, что именно склеилось; отдельного раскручивания через Unwrap в прикладном коде почти никогда не нужно. Логи пишут ошибку целиком, а не только первый совпавший код, и в метрики уходят обе причины — иначе картина атак искажается.
Классификатор ошибок: один на весь сервис
GOfunc tokenErrorCode(err error) string {
switch {
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
return "SIGNATURE_INVALID"
case errors.Is(err, jwt.ErrTokenExpired):
return "TOKEN_EXPIRED"
case errors.Is(err, jwt.ErrTokenNotValidYet):
return "TOKEN_NOT_VALID_YET"
case errors.Is(err, jwt.ErrTokenMalformed):
return "TOKEN_MALFORMED"
default:
return "UNAUTHORIZED"
}
}Единая точка, где ошибки jwt превращаются в коды ответов: middleware, refresh-эндпоинт и тесты зовут её, и порядок причин не расползается по кодовой базе. Signature стоит первым — склеенные ошибки сначала проверяются на подделку. Внизу default молчит про всё остальное: отказ keyfunc и неизвестные причины не диагностируются наружу.
Разделение труда: лог, ответ, метрика#
- Логу — точную ошибку целиком, user_id из claims, если они успели распаковаться, и источник запроса; сам токен в лог не пишем — он действующий ключ до exp.
- Клиенту — код и подсказку только для expired; остальным общий 401 без диагностики.
- Метрикам — счётчики по каждой причине: устойчивый рост signature_invalid часто означает атаку или разъехавшиеся ключи, а не чью-то опечатку.
- Тестам — по одному токену на причину: expired, malformed, чужая подпись, nbf в будущем — ветки проверяются отдельно.
- Ретраям — запрет на повтор с тем же токеном: 401 не лечится повторной отправкой той же строки, меняет её только refresh или перелогин.
Проверка: склеенная ошибка
errors.Is(err, jwt.ErrTokenExpired) вернул true, и errors.Is(err, jwt.ErrTokenSignatureInvalid) тоже true. Что произошло?
Проверка: рваная строка
Клиент прислал строку abc вместо токена. Какую ошибку вернёт разбор?
Порядок case решает
Прочитайте код и выберите, что он выведет:
switch {
case errors.Is(err, jwt.ErrTokenExpired):
// подсказка: обновите через /api/v1/refresh
respond(w, 401, "TOKEN_EXPIRED")
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
respond(w, 401, "UNAUTHORIZED")
}
// err — склеенная: и Expired, и SignatureInvalidОтдельно про транспорт ответа: отказ аутентификации — это 401, и по стандарту рядом с ним едет заголовок WWW-Authenticate со схемой Bearer, что видно в первом примере урока. Порванную строку часть API отвечает как 400 Bad Request — тоже рабочая схема, лишь бы клиент различал «почини запрос» и «перелогинься». Тело ответа удобно держать в едином формате кода ошибки — так его разберёт и фронтенд, и скрипт мониторинга; для chi это ложится на централизованные ошибки, для gin — на единый формат ответов. Честная оговорка про запуск: в браузерной песочнице сайта ветвление не проверишь — нужен локальный Go, установленный пакет github.com/golang-jwt/jwt/v5 и три токена из тестов: нормальный, протухший и испорченный. Их удобно выпустить функцией из урока про HMAC-подпись, подставляя разные exp и дописывая мусор в строку.
Соберите локально три фиксированные строки: свежий токен, токен с exp в прошлом и тот же свежий с заменённой буквой в payload — правка base64 ломает подпись, не трогая формат. На них вы увидите все три главных отказа: success, expired и signature invalid, и сможете настроить ветки ответов без постоянного перезапуска клиента.
Кейс: неделя ложной тревоги#
Сервис одной команды отвечал на все причины отказа одним словом unauthorized, а мониторинг считал только код ответа. Через месяц после релиза фронтенда отказы пошли стеной: одна из страниц начала отправлять заголовок Authorization без префикса Bearer — каждая строка падала в ErrTokenMalformed, но для графика это был всё тот же 401. Алерт на рост отказов аутентификации сработал в пятницу вечером, дежурный полчаса искал атаку по логам, а причина оказалась в одной строке клиентского кода. После внедрения классификатора из урока отказы легли в счётчики по причинам: всплеск malformed указал прямо на релиз по таймстампу, счётчик signature_invalid остался на нулевой отметке, и атака исключалась без чтения сырых логов. В этом и цена классификатора на двадцать строк: он экономит не поиск по логу, а половину пятницы и доверие команды к алертам.
Допишите классификатор
Восстановите сравнение причин: имя функции из пакета errors и константу истёкшего срока.
Соберите безопасный порядок веток
Расставьте строки классификатора так, чтобы подделка проверялась раньше срока годности.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ошибки токенов
Теперь у проверки есть полный словарь отказов, и логично заняться тем, ради чего подсказка expired вообще нужна: обновлением токена без перелогина. В уроке про refresh-токены соберём пару access и refresh, ротацию и эндпоинт обновления — это следующий шаг курса. Назад один шаг — в урок про ParseWithClaims и keyfunc, без которого сегодняшние ветвления не к чему прикладывать. Карта всех двадцати пяти уроков — на странице курса jwt-go, оттуда же удобно прыгать в смежные темы: хранение токена на клиенте и отзыв через denylist.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Причины отказа в v5 — именованные константы пакета jwt; сравнивают их через errors.Is, потому что ошибки обёрнуты и склеиваются.
- Expired — штатное событие: подсказка про refresh наружу, счётчик вместо алерта в мониторинге.
- Signature invalid наружу молчит: детали помогают нападающему, полный текст уходит в лог и метрики.
- Одна ошибка может совпадать с несколькими причинами: подпись проверяйте раньше срока годности.
- Malformed означает порванную строку до криптографии; 400 или 401 — единый выбор на весь API.
- Тесты собирают по токену на причину: свежий, протухший, испорченный, nbf в будущем.
Домашнее задание
Возьмите VerifyToken из прошлого урока и добавьте функцию tokenErrorCode с порядком: подпись, expired, not valid yet, malformed. Соберите три токена — свежий, протухший и со сломанной подписью — и прогоните локально: убедитесь, что протухший даёт TOKEN_EXPIRED, а испорченный строковый payload остаётся молчаливым UNAUTHORIZED даже после истечения срока.
Личные заметкиПусто
Вопросы и ответы по уроку
Какой статус ставить на malformed — 400 или 401?
Оба защитника имеют аргументы: 400 говорит «чините запрос», 401 — «проблема с аутентификацией». На практике чаще берут 401 для всего, что касается токена, и держат один код в договорённостях API. Главное — не менять выбор между эндпоинтами.
Почему нельзя просто возвращать текст ошибки библиотеки клиенту?
Текст v5 пишет по-английски и с внутренними деталями: какие поля не сошлись, что с подписью. Для отладки — хорошо, для публичного API — утечка информации о внутреннем устройстве проверки. Код ошибки в вашем формате стабильнее и локализуется.
Как протестировать именно expired, не спя до истечения срока?
Выпустите токен с exp в прошлом: jwt.NewNumericDate(time.Now().Add(-time.Minute)). Библиотека честно признает его истёкшим, спать минуту не нужно. Для nbf — наоборот, время в будущем; оба случая проверяются в юнит-тестах мгновенно.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния