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

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

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

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

JWKS: публичные ключи по URL

JWKS — это документ с публичными ключами jwt, доступный по обычному URL: сторонние сервисы скачивают его, кэшируют и проверяют ваши токены без единого PEM-файла в конфиге. Разбираем структуру jwks.json с kid, n и e, эндпоинт раздачи на Go, кэш у клиента и ротацию ключей jwt, после которой не ломается ни один живой токен.

Содержание урока · 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

GO
func 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 и продолжат работу. Вся схема опирается на то, что оба состояния документа — до и после — валидны: промежуточных состояний, где ключа нет, а токены с ним ещё ходят, быть не должно. Именно это условие нарушают чаще всего, и следующим блоком — о последствиях.

Шесть шагов ротации#

  • Сгенерировать новую пару и выбрать новый 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.

"n": base64.RawURLEncoding.EncodeToString(public.), "e": base64.RawURLEncoding.EncodeToString(.NewInt(int64(public.E)).Bytes()), "kid": "2026-01-",

Соберите keyfunc для JWKS

Расставьте строки в правильном порядке: от чтения kid до отказа на неизвестном ключе.

Строки

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

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

Карточки: JWKS

Термин

JWKS

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

JSON Web Key Set: документ с массивом публичных ключей по URL, обычно /.well-known/jwks.json. Стандарт RFC 7517; так раздают ключи Google, Auth0 и Okta.

1 / 5

С 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.

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

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

Что такое 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 — это набор с идентификаторами, поэтому поддерживает ротацию, несколько алгоритмов и параллельные поколения ключей. Один ключ в конфиге работает, пока ключ один и живёт вечно, — на практике это редко.

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

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

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

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

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

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

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