---
title: "JWKS: публичные ключи по URL"
description: "JWKS — это набор публичных ключей jwt в JSON, отдаваемый по URL: структура jwks.json с kid, n и e, эндпоинт раздачи ключей на Go, кэш у проверяющих и ротация ключей без простоя."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/13-jwks/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 16
---

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

*Курс «JWT-аутентификация в Go: golang-jwt и bcrypt», урок 13 из 25. ~16 минут. Обновлено 02.10.2026. Источник: https://golangmaster.ru/courses/jwt-go/lessons/13-jwks/*

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

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

> **Живое место: убрать старый ключ слишком рано.** Самая дорогая ошибка ротации: новый ключ опубликован, выпуск переключён, и через час старую запись удалили — а у части пользователей 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](/courses/go-redis/lessons/05-ttl-i-istechenie/).

### Кейс: кэш, который пережил ротацию

Разбор одного инцидента: identity-сервис провёл ротацию по правилам — новый ключ в JWKS, выпуск переключён, старая запись держится до exp. Через месяц у одного партнёра посыпались 401 на заведомо свежих токенах, а вскоре выяснилось: их сервис читал jwks.json один раз при старте процесса, в ноябре. Новый kid в их кэше так и не появился, и часть токенов отбрасывалась до ближайшего перезапуска. Сначала обвиняли наш эндпоинт, но его лог показал: за неделю ноль запросов с их адреса. Вывод одинаков для обеих сторон: у проверяющего обязано быть обновление по неизвестному kid, а у выпускающего — алерт на странный трафик, когда одни клиенты перечитывают документ постоянно, а другие не приходят вовсе. Чек-лист из [урока про лучшие практики](/courses/jwt-go/lessons/24-luchshie-praktiki/) вырос из историй вроде этой.

### Спорное мнение: маленькой системе JWKS не нужен

Скажу непопулярное: если выпускающий сервис один, а проверяющих два и все они живут в одном репозитории, JWKS — оверинжиниринг. Эндпоинт, кэш, обновление по kid, тесты на оба состояния документа — неделя работы и постоянная забота ради экономии одного PEM-файла в конфиге. Файл у проверяющих работает, пока их мало и деплоятся они из одного CI. Граница, за которой я передумываю: проверяющих больше трёх, они в разных командах или вообще чужие компании — тогда рассылка файлов перестаёт масштабироваться, и JWKS окупает себя за первую же ротацию. Между этими состояниями лежит серая зона, и решение там принимается не по красоте архитектуры, а по ответу на простой вопрос: кто звонит в поддержку, когда ключ сменился, а у кого-то остался старый?

### Проверка: назначение kid

**Вопрос:** За что отвечает kid в header токена?

- Шифрует payload токена
- ✅ Указывает, какой ключ из JWKS использовать для проверки
- Заменяет exp как срок жизни
- Хранит роль пользователя

kid — идентификатор ключа: keyfunc ищет запись с таким же kid в массиве keys и возвращает её ключ. Шифрования jwt не делает вообще, срок жизни живёт в exp, а роль — в payload. Без kid проверяющему пришлось бы пробовать каждый ключ из набора по очереди.

### Проверка: режим обновления кэша

**Вопрос:** Как правильно обновлять JWKS-кэш у проверяющего сервиса?

- Скачать документ заново на каждый запрос
- ✅ Обновлять по неизвестному kid с ограничением частоты и по расписанию из Cache-Control
- Никогда не обновлять: ключи вечные
- Обновлять раз в год по регламенту

Кэш в памяти плюс обновление по событию: неизвестный kid вызывает перечитывание с ограничением частоты, а Cache-Control задаёт фоновое расписание. Запрос за ключом на каждый запрос умножает задержки и зависимости, а вечный кэш сломается на первой же ротации.

### Ключ убрали раньше срока

```go
// в jwksCache остался только ключ с kid "2026-02-primary"
// приходит токен с header {"alg":"RS256","kid":"2026-01-primary"}

claims := &AppClaims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, keyFromJWKS)
// что в err?
```

**Что выведет программа?**

- nil: подпись сверится первым ключом из кэша
- ✅ Ошибка «неизвестный kid: 2026-01-primary», запрос получит 401
- nil: библиотека игнорирует kid
- Паника на отсутствующем ключе

keyFromJWKS не находит запись с kid 2026-01-primary и возвращает ошибку — ParseWithClaims завершается ею, хендлер отвечает 401. Это ровно та волна, которую вызывает раннее удаление старого ключа при ротации: токены с ним ещё живы, а записи уже нет. Лечение — держать старую запись до exp последнего токена.

### Допишите сборку JWK

Восстановите модуль, экспоненту и имя ключа в записи JWKS.

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

Подсказка: Модуль лежит в поле N публичного ключа, экспонента оборачивается в big.Int, а kid из примера называется 2026-01-primary.

Правильные ответы в пропусках: `N`, `big`, `primary`

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

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

Правильный порядок строк:

1. `func keyFromJWKS(t *jwt.Token) (any, error) {`
2. `kid, _ := t.Header["kid"].(string)`
3. `key, ok := jwksCache[kid]`
4. `if !ok {`
5. `return nil, fmt.Errorf("неизвестный kid: %s", kid)`
6. `}`

### Карточки: JWKS

- **JWKS** — JSON Web Key Set: документ с массивом публичных ключей по URL, обычно /.well-known/jwks.json. Стандарт RFC 7517; так раздают ключи Google, Auth0 и Okta.
- **kid** — Идентификатор ключа: сервер кладёт его в header при выпуске, проверяющий ищет запись с тем же kid в keys. Пустой или неизвестный kid — отказ.
- **n и e** — Модуль и экспонента RSA-ключа в base64url без паддинга. Для стандартной экспоненты 65537 кодировка даёт ровно AQAB.
- **Ротация** — Новый ключ публикуется рядом со старым, выпуск переключается на новый kid, старая запись живёт до exp последних токенов и убирается задачей.
- **Кэш JWKS** — Хранится в памяти процесса; обновляется при неизвестном kid с ограничением частоты и по расписанию Cache-Control. При недоступности источника — работать на устаревшей копии.

> **Как повторить у себя.** Понадобятся локальный Go и пара ключей из урока про RSA: поднимите сервис с jwksHandler, заберите документ curl-ом и разложите его в jwksCache простым декодированием. Проверьте круг: выпуск токена с kid в header, проверка через keyFromJWKS. Обновление по неизвестному kid удобнее всего отладить, добавив в кэш второй ключ и выпуская токены то одним, то другим.

С JWKS замыкается вся асимметричная схема: identity-сервис держит приватный ключ и эндпоинт ключей, все остальные проверяют токены по URL и kid, а ротация превращается из аварии в рутину. Дальше по курсу тема разворачивается в две стороны: в [уроке про хранение токена на клиенте](/courses/jwt-go/lessons/14-hranenie-tokena/) — что браузер делает с этим токеном между запросами, а в [уроке про HttpOnly cookie](/courses/jwt-go/lessons/16-httponly-cookie/) — как отдать его так, чтобы скрипт не видел вовсе. Вердикт о keyfunc из [урока про ParseWithClaims](/courses/jwt-go/lessons/06-parsing-token/) остаётся прежним: это единственное место доверия, и теперь в нём появился ещё один источник — чужой URL. Кэш для JWKS можно держать и в Redis — от строк и сроков до обновлений по событию это вся территория [курса go-redis](/courses/go-redis/). Карта курса — [на странице jwt-go](/courses/jwt-go/).

## Коротко: что запомнить

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