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

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

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

Урок 7 из 25 ~16 мин +20 XP

Ошибки токенов: expired и malformed

ParseWithClaims вернул ошибку — и сервер должен понять, что именно сломалось: строка порвана, подпись не совпала или срок истёк. Разбираем ошибки jwt в golang-jwt v5: ErrTokenExpired, ErrTokenMalformed, ErrTokenSignatureInvalid и различение через errors.Is, чтобы ответы API были честными — протухшему токену подсказка про refresh, подделке молчание.

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

Клиент приносит токен, проверка из прошлого урока падает с ошибкой — и дальше начинается самое интересное: что ответить, что записать в лог и стоит ли поднимать тревогу. Ответ «401 Unauthorized» без деталей ломает пользовательский опыт: человек с истёкшим exp не понимает, что ему нужно перелогиниться или обновить токен, и пишет в поддержку. Заливать же детали в ответ без разбора — подарок нападающему: подробная диагностика помогает понять, где у сервера слабое место. Поэтому ошибки jwt в Go раскладывают по причинам: одна и та же ошибка разбора может значить порванную строку, чужую подпись или истёкший срок, и все три случая требуют разной реакции. И разница нужна не только человеку: мониторинг, который считает только коды ответов, не отличит атаку от штатного истечения расписания, поэтому причины отказов должны быть машинами читаемы — кодом в теле ответа и меткой в логе. В golang-jwt v5 для этого всё готово: причины — именованные переменные пакета, а сравнение идёт через errors.Is, потому что парсер оборачивает внутренние ошибки и сохраняет всю цепочку. Ключевая пара запросов в поисковиках — jwt expired go и различение ErrTokenExpired от всего остального — и это действительно первый случай, который стоит выделить отдельно.

Ветвление по причинам отказа

GO
claims := &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-токен, который обновляет сам себя, и то и другое лечится настройкой, а не ретраями.

Каталог ошибок golang-jwt v5#

ОшибкаКогда возникаетОтвет клиенту
jwt.ErrTokenMalformedстрока не разобралась: частей не три, битый base64url или невалидный JSON400 или 401, без деталей
jwt.ErrTokenUnverifiablekeyfunc вернул ошибку или ключ не того типа401, без деталей
jwt.ErrTokenSignatureInvalidподпись не совпала: payload правлен или подписан чужим ключом401, молча
jwt.ErrTokenExpiredexp в прошлом — штатное истечение срока401 с подсказкой про refresh
jwt.ErrTokenNotValidYetnbf в будущем — токен действует позже401, повторить позже
jwt.ErrTokenUsedBeforeIssuediat в будущем — часы разошлись или подделка401, без деталей

errors.Is: как парсер склеивает причины#

Механика здесь — стандартный Go, тот же приём, что в уроке про оборачивание ошибок продвинутого курса: парсер возвращает обёртки, а errors.Is распаковывает цепочку и сравнивает с целевой ошибкой по identity. Важный нюанс v5: одна ошибка разбора может соответствовать нескольким причинам сразу, потому что внутренние ошибки склеиваются, а не заменяют друг друга. Типичный пример — токен, который и протух, и правлен: exp в прошлом, а подпись после правки payload не сходится. errors.Is вернёт true и для ErrTokenExpired, и для ErrTokenSignatureInvalid — обе причины в цепочке. Из этого следует правило порядка веток: сначала проверяйте то, что важнее по безопасности. Если подпись не сошлась, советовать клиенту «обновите токен через refresh» нельзя — вы подсказываете как обновить чужую подделку. Подпись — первый case, время — второй: безопасность раньше удобства, даже когда обе проверки формально пройдут. При отладке печатайте ошибку целиком через %v: v5 отдаёт читаемую цепочку причин, и по записи видно, что именно склеилось; отдельного раскручивания через Unwrap в прикладном коде почти никогда не нужно. Логи пишут ошибку целиком, а не только первый совпавший код, и в метрики уходят обе причины — иначе картина атак искажается.

Классификатор ошибок: один на весь сервис

GO
func 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 и дописывая мусор в строку.

Кейс: неделя ложной тревоги#

Сервис одной команды отвечал на все причины отказа одним словом unauthorized, а мониторинг считал только код ответа. Через месяц после релиза фронтенда отказы пошли стеной: одна из страниц начала отправлять заголовок Authorization без префикса Bearer — каждая строка падала в ErrTokenMalformed, но для графика это был всё тот же 401. Алерт на рост отказов аутентификации сработал в пятницу вечером, дежурный полчаса искал атаку по логам, а причина оказалась в одной строке клиентского кода. После внедрения классификатора из урока отказы легли в счётчики по причинам: всплеск malformed указал прямо на релиз по таймстампу, счётчик signature_invalid остался на нулевой отметке, и атака исключалась без чтения сырых логов. В этом и цена классификатора на двадцать строк: он экономит не поиск по логу, а половину пятницы и доверие команды к алертам.

Допишите классификатор

Восстановите сравнение причин: имя функции из пакета errors и константу истёкшего срока.

func tokenErrorCode(err error) string { switch { case errors.(err, jwt.): return "TOKEN_EXPIRED" case errors.Is(err, jwt.ErrTokenSignatureInvalid): return "SIGNATURE_INVALID" default: return "UNAUTHORIZED" } }

Соберите безопасный порядок веток

Расставьте строки классификатора так, чтобы подделка проверялась раньше срока годности.

Строки

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

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

Карточки: ошибки токенов

Термин

jwt.ErrTokenExpired

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

Штатное истечение exp. Единственная причина, которой наружу отдают подсказку про refresh; в логах — счётчик, а не ошибка.

1 / 5

Теперь у проверки есть полный словарь отказов, и логично заняться тем, ради чего подсказка 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 даже после истечения срока.

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · Ошибки токенов: expired и malformed)

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

Какой статус ставить на malformed — 400 или 401?

Оба защитника имеют аргументы: 400 говорит «чините запрос», 401 — «проблема с аутентификацией». На практике чаще берут 401 для всего, что касается токена, и держат один код в договорённостях API. Главное — не менять выбор между эндпоинтами.

Почему нельзя просто возвращать текст ошибки библиотеки клиенту?

Текст v5 пишет по-английски и с внутренними деталями: какие поля не сошлись, что с подписью. Для отладки — хорошо, для публичного API — утечка информации о внутреннем устройстве проверки. Код ошибки в вашем формате стабильнее и локализуется.

Как протестировать именно expired, не спя до истечения срока?

Выпустите токен с exp в прошлом: jwt.NewNumericDate(time.Now().Add(-time.Minute)). Библиотека честно признает его истёкшим, спать минуту не нужно. Для nbf — наоборот, время в будущем; оба случая проверяются в юнит-тестах мгновенно.

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

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

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

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

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

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

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