Содержание урока · 10 разделов
После перехода на RS256 у каждого проверяющего сервиса появился public.pem — и каждая ротация ключа превращается в рассылку файлов, деплои и молитву, что все скопировали новую версию. JWKS решает это стандартным способом: выпускающий сервис держит HTTP-эндпоинт, который отдаёт JSON с публичными ключами, а проверяющие скачивают документ и кэшируют его. Формат описан в RFC 7517, путь обычно /.well-known/jwks.json, и ровно так живут Google, Auth0 и Okta: любой совместимый клиент проверяет их токены, не видя ни одного файла. Внедрить то же самое в свой identity-сервис — три части: собрать документ, отдать его по GET и научить keyfunc выбирать ключ по kid из header токена. Вторая половина урока — ротация ключей jwt: она делается без простоя, но по протоколу, шаги которого легко перепутать, и цена перепутывания — волна 401 у всех клиентов разом.
jwks.json: kid, n и e#
Документ — объект с единственным полем keys: массивом JWK, по одной записи на каждый публичный ключ. У RSA-ключа поля такие: kty со значением RSA — тип ключа; use sig — ключ для подписей, а не для шифрования; alg RS256 — ожидаемый алгоритм; kid — строка-идентификатор, которую вы придумываете сами и которая связывает токен с записью; n — модуль в base64url без паддинга; e — экспонента, для стандартной 65537 это три символа AQAB. kid — самая нагруженная деталь: при выпуске токена сервер кладёт его в header, а keyfunc проверяющего ищет запись с таким же kid в массиве. Без kid проверяющему пришлось бы перебирать ключи и сверять подпись каждым — работает, но медленнее и без диагностичности: ошибка «нет ключа для 2026-01-primary» говорит всё сразу. Имя kid я делаю из даты и роли ключа — так ротация читается в логах сама, без отдельной документации.
Документ JWKS с одним ключом
GO{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "2026-01-primary",
"n": "xGOr...модуль в base64url",
"e": "AQAB"
}
]
}Запись читается как анкета ключа: тип, назначение, алгоритм, имя и два числа RSA. Модуль n закодирован base64url без паддинга — тем же RawURLEncoding, каким закодированы части самого токена. Экспонента 65537 в такой кодировке даёт ровно AQAB, поэтому её короткое значение в примерах — не сокращение, а норма для стандартного выбора.
Эндпоинт раздачи ключей на Go
GOfunc jwksHandler(public *rsa.PublicKey) http.HandlerFunc {
const kid = "2026-01-primary"
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"keys": []map[string]string{
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": kid,
"n": base64.RawURLEncoding.EncodeToString(public.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(
big.NewInt(int64(public.E)).Bytes()),
},
},
})
}
}Публичная структура *rsa.PublicKey уже содержит N и E: модуль уходит в base64url байтовым представлением, экспонента — через big.Int. Хендлер замыкается на ключе и отвечает обычным json.NewEncoder — библиотек сверх стандартной не нужно. Срок жизни ответа проверяющие регулируют заголовком Cache-Control, но стратегию обновления каждый выбирает сам — об этом ниже.
Ротация ключей jwt без простоя#
Протокол ротации строится на одном факте: старый ключ нужен до тех пор, пока жив хоть один токен, им подписанный. Поэтому новый ключ появляется рядом со старым, а не вместо него. Последовательность такая: генерируем новую пару, добавляем публичный в массив keys вторым элементом — документ теперь отдаёт оба; переключаем выпуск на новый kid, дописав его в header новых токенов; ждём exp последнего токена, подписанного старым; убираем старую запись. Проверяющие при этом не делают ничего: они увидят новый kid, не найдут его в кэше, обновят JWKS и продолжат работу. Вся схема опирается на то, что оба состояния документа — до и после — валидны: промежуточных состояний, где ключа нет, а токены с ним ещё ходят, быть не должно. Именно это условие нарушают чаще всего, и следующим блоком — о последствиях.
Самая дорогая ошибка ротации: новый ключ опубликован, выпуск переключён, и через час старую запись удалили — а у части пользователей access ещё жив по старому kid. Волна 401 накрывает тех, кому не повезло с моментом, и повторяется на каждом сервисе-проверяющем независимо. Моё правило: срок жизни старой записи равен максимальному exp старых токенов плюс запас, и убирается она календарной задачей, а не руками. Исключение, когда ждать нельзя, — компрометация ключа: там отзыв важнее плавности, и все токены с compromised kid гасятся немедленно.
Шесть шагов ротации#
- Сгенерировать новую пару и выбрать новый kid — например из даты и роли ключа.
- Добавить новый публичный ключ в JWKS рядом со старым: в keys теперь две записи.
- Переключить выпуск: новые токены получают kid новой пары в header.
- Держать старую запись, пока живы токены со старым kid: срок равен их exp плюс запас.
- Убрать старую запись календарной задачей и убедиться, что 401 по ней не растут.
- Старый приватный ключ перевести в архив: подписывать им больше нельзя, проверять им нечего.
keyfunc, которая ищет ключ по kid
GO// jwksCache — map[string]*rsa.PublicKey, собранный из загруженного JWKS
func keyFromJWKS(t *jwt.Token) (any, error) {
kid, _ := t.Header["kid"].(string)
if kid == "" {
return nil, errors.New("нет kid в header токена")
}
key, ok := jwksCache[kid]
if !ok {
return nil, fmt.Errorf("неизвестный kid: %s", kid)
}
return key, nil
}Две ветки отказа различаются смыслом: пустой kid — битый выпуск со стороны сервера, неизвестный kid — либо токен выпущен до загрузки нового ключа, либо запись уже убрали. В обоих случаях ParseWithClaims завершится ошибкой, и запрос получит 401. Утверждение про SigningMethodRS256 здесь опущено для краткости — в рабочем коде оно стоит первой строкой keyfunc, как в уроке про RSA.
Типичная ошибка: kid как доверенный вход#
kid приходит в header токена, а header — часть входных данных, контролируемая клиентом. В коде выше это безопасно: kid ищется в map, промах даёт ошибку. Проблемы начинаются там, где kid уходит дальше: в SQL-запрос с конкатенацией строк, в путь к файлу ключа на диске, в лог без экранирования. Атакующий подставляет kid с кавычкой и логикой внутри и добивается либо чтения чужой записи, либо выхода за пределы каталога. Правило то же, что для любого пользовательского ввода: kid — строка для поиска в заранее загруженном наборе, а не аргумент запроса. Если ключи живут в базе, сравнение делает параметризованный запрос: незнакомый kid возвращает пустой результат и обычный отказ 401, без побочных эффектов. Три строки кода — и целое семейство атак закрыто.
Кэш: как часто ходить за ключами#
Ходить за JWKS на каждый запрос — значит добавлять сетевой прыжок к каждому логину и ставить собственную доступность в зависимость от чужого эндпоинта. Правильный режим: кэш в памяти процесса, обновление по событию. Событие — неизвестный kid: токен пришёл, kid в кэше нет, проверяющий перечитывает JWKS — раз, и не чаще, чем раз в несколько секунд, чтобы чужой сбой не превратить в собственный шторм повторов. Google и Auth0 дополнительно публикуют Cache-Control, и уважающий его клиент обновляет документ ещё и по расписанию. Спорный вопрос — что делать при недоступности эндпоинта, когда кэш уже устарел: я держу устаревшую копию и продолжаю проверять ею, потому что альтернатива — отключить весь вход из-за одной сети. Исключение — после компрометации ключа: тогда устаревший кэш опустошается принудительно, и никакая доступность не важнее отзыва. Общий кэш с TTL в Redis строится на той же механике сроков, что разобрана в уроке про TTL курса go-redis.
Кейс: кэш, который пережил ротацию#
Разбор одного инцидента: identity-сервис провёл ротацию по правилам — новый ключ в JWKS, выпуск переключён, старая запись держится до exp. Через месяц у одного партнёра посыпались 401 на заведомо свежих токенах, а вскоре выяснилось: их сервис читал jwks.json один раз при старте процесса, в ноябре. Новый kid в их кэше так и не появился, и часть токенов отбрасывалась до ближайшего перезапуска. Сначала обвиняли наш эндпоинт, но его лог показал: за неделю ноль запросов с их адреса. Вывод одинаков для обеих сторон: у проверяющего обязано быть обновление по неизвестному kid, а у выпускающего — алерт на странный трафик, когда одни клиенты перечитывают документ постоянно, а другие не приходят вовсе. Чек-лист из урока про лучшие практики вырос из историй вроде этой.
Спорное мнение: маленькой системе JWKS не нужен#
Скажу непопулярное: если выпускающий сервис один, а проверяющих два и все они живут в одном репозитории, JWKS — оверинжиниринг. Эндпоинт, кэш, обновление по kid, тесты на оба состояния документа — неделя работы и постоянная забота ради экономии одного PEM-файла в конфиге. Файл у проверяющих работает, пока их мало и деплоятся они из одного CI. Граница, за которой я передумываю: проверяющих больше трёх, они в разных командах или вообще чужие компании — тогда рассылка файлов перестаёт масштабироваться, и JWKS окупает себя за первую же ротацию. Между этими состояниями лежит серая зона, и решение там принимается не по красоте архитектуры, а по ответу на простой вопрос: кто звонит в поддержку, когда ключ сменился, а у кого-то остался старый?
Проверка: назначение kid
За что отвечает kid в header токена?
Проверка: режим обновления кэша
Как правильно обновлять JWKS-кэш у проверяющего сервиса?
Ключ убрали раньше срока
Прочитайте код и выберите, что он выведет:
// в jwksCache остался только ключ с kid "2026-02-primary"
// приходит токен с header {"alg":"RS256","kid":"2026-01-primary"}
claims := &AppClaims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, keyFromJWKS)
// что в err?Допишите сборку JWK
Восстановите модуль, экспоненту и имя ключа в записи JWKS.
Соберите keyfunc для JWKS
Расставьте строки в правильном порядке: от чтения kid до отказа на неизвестном ключе.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: JWKS
Понадобятся локальный Go и пара ключей из урока про RSA: поднимите сервис с jwksHandler, заберите документ curl-ом и разложите его в jwksCache простым декодированием. Проверьте круг: выпуск токена с kid в header, проверка через keyFromJWKS. Обновление по неизвестному kid удобнее всего отладить, добавив в кэш второй ключ и выпуская токены то одним, то другим.
С JWKS замыкается вся асимметричная схема: identity-сервис держит приватный ключ и эндпоинт ключей, все остальные проверяют токены по URL и kid, а ротация превращается из аварии в рутину. Дальше по курсу тема разворачивается в две стороны: в уроке про хранение токена на клиенте — что браузер делает с этим токеном между запросами, а в уроке про HttpOnly cookie — как отдать его так, чтобы скрипт не видел вовсе. Вердикт о keyfunc из урока про ParseWithClaims остаётся прежним: это единственное место доверия, и теперь в нём появился ещё один источник — чужой URL. Кэш для JWKS можно держать и в Redis — от строк и сроков до обновлений по событию это вся территория курса go-redis. Карта курса — на странице jwt-go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- JWKS отдаёт публичные ключи jwt по URL: RFC 7517, путь /.well-known/jwks.json, без PEM-файлов у проверяющих.
- Запись JWK: kty, use, alg, kid и два числа RSA — n и e в base64url без паддинга; 65537 даёт AQAB.
- kid связывает токен с ключом: сервер кладёт его в header, keyfunc ищет запись в массиве keys.
- Ротация без простоя: новый ключ рядом со старым, переключение выпуска, старая запись живёт до exp последних токенов.
- Кэш JWKS в памяти: обновление по неизвестному kid с ограничением частоты и по расписанию Cache-Control.
- Убранный раньше срока ключ даёт волну 401 по живым токенам со старым kid.
Домашнее задание
Поднимите jwksHandler с двумя ключами и выпускайте токены то с одним kid, то с другим. Проверяющая сторона с keyFromJWKS должна принимать оба; удалите из кэша старый ключ и наблюдайте волну отказов по живым токенам. Затем проведите ротацию по шагам из списка и убедитесь, что ни на одном этапе ни один валидный токен не получил 401.
Личные заметкиПусто
Вопросы и ответы по уроку
Что такое JWKS простыми словами?
JWKS (JSON Web Key Set) — это JSON-документ с публичными ключами, который сервис отдаёт по обычному HTTP-URL (обычно /.well-known/jwks.json). Любой, кто проверяет ваши JWT, скачивает документ один раз, кэширует и проверяет подписи токенов по ключу, выбранному по kid из заголовка. Так работают Google, Auth0 и Okta — без рассылки PEM-файлов каждому потребителю.
Обязательно ли открывать JWKS наружу, если проверяющие только свои?
Нет: эндпоинт может жить во внутренней сети, а формат остаётся тем же. Выигрыш не в публичности, а в единственном источнике ключей вместо рассылки файлов: ротация сводится к правке одного документа, а не к деплою всех потребителей.
Что кладут в kid?
Любую строку, которую вы контролируете: дату, номер поколения, роль ключа. Требования два — уникальность в наборе и стабильность: kid попадает в header каждого токена, и менять его смысл после выпуска нельзя.
Чем JWKS отличается от одного публичного ключа в конфиге?
Форматом и числом ключей: JWKS — это набор с идентификаторами, поэтому поддерживает ротацию, несколько алгоритмов и параллельные поколения ключей. Один ключ в конфиге работает, пока ключ один и живёт вечно, — на практике это редко.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния