У HS256 есть неудобное свойство: тот же секрет, которым токены выпускаются, нужен и для их проверки. Как только проверяющих становится больше одного — второй сервис, воркер, мобильный бэкенд — каждый держит копию секрета, и каждый становится точкой, из которой можно выпускать токены. Утечка одного секретного конфига равна компрометации всей системы: подписать подделку нападающему мешает ровно ничего. RS256 — асимметричная подпись jwt в Go — переворачивает раскладку: приватный ключ существует в единственном экземпляре у выпускающего сервиса, а публичный можно раздавать всем проверяющим свободно, хоть публиковать на сайте. Подделать токен, владея только публичным ключом, нельзя: он умеет проверять подписи, но не создавать их. В этом уроке выпустим токен RS256, проверим его, разберём ES256 как компактную альтернативу и скажем, когда HS256 остаётся разумным.
Как устроена rsa подпись токена#
RSA-подпись строится на паре ключей, связанных математически: то, что подписано приватным, проверяется парным публичным. По алгоритму RS256 расшифровывается как RSASSA-PKCS1-v1_5 с SHA-256: подписывается не сам токен, а его хеш — конкатенация закодированных header и payload, тех же двух частей, из которых состоит строка до второй точки. Приватный ключ — файл вроде private.pem, который читает только выпускающий сервис; публичный — public.pem, лежащий у каждого проверяющего. ECDSA идёт тем же маршрутом с другими числами: ES256 использует эллиптическую кривую P-256, ключ занимает 256 бит вместо 2048 и больше у RSA, а подпись — около шестидесяти четырёх байт против двухсот пятидесяти шести у RSA-2048. Для REST-запросов разница незаметна, для устройств с узким каналом — заметна. В golang-jwt v5 семейства выглядят одинаково: меняются константа метода — SigningMethodRS256 или SigningMethodES256 — и тип ключа, который уходит в SignedString и возвращается из keyfunc.
Выпуск токена приватным ключом
GOpackage auth
import (
"crypto/rsa"
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
func loadPrivateKey() (*rsa.PrivateKey, error) {
data, err := os.ReadFile("keys/private.pem")
if err != nil {
return nil, err
}
key, err := jwt.ParseRSAPrivateKeyFromPEM(data)
if err != nil {
return nil, fmt.Errorf("битый private.pem: %w", err)
}
return key, nil
}
func IssueRS256(userID int64, role string) (string, error) {
key, err := loadPrivateKey()
if err != nil {
return "", err
}
claims := AppClaims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "gomaster",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key)
}jwt.ParseRSAPrivateKeyFromPEM принимает PEM в формате PKCS1 или PKCS8 — оба выдают openssl — и возвращает готовую структуру rsa.PrivateKey. SignedString смотрит на тип ключа: с rsa.PrivateKey она подписывает RSA-схемой, соответствующей alg из header. Передать сюда байты PEM или строковый секрет нельзя — метод вернёт ошибку неверного типа ключа ещё до криптографии, и это встроенная защита от путаницы типов.
Проверка публичным ключом на стороне сервиса#
Проверяющей стороне нужен только публичный ключ — и это главный выигрыш схемы. Сервисы, которые лишь проверяют токены, получают public.pem при деплое и никогда не видят приватный: скомпрометировать выпуск, украв конфиг проверяющего, невозможно. Структура проверки та же, что в уроке про ParseWithClaims и keyfunc: утверждаем метод, возвращаем ключ подходящего типа, добавляем WithValidMethods. Меняются ровно две детали: константа метода — SigningMethodRS256 — и тип ключа в возврате keyfunc: для RSA это *rsa.PublicKey, структура с модулем и экспонентой, без приватной половины. Всё остальное — разбор строки, сверка подписи, автоматические exp и iss — работает как раньше.
VerifyRS256: keyfunc возвращает публичный ключ
GOfunc VerifyRS256(tokenStr string, public *rsa.PublicKey) (*AppClaims, error) {
claims := &AppClaims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRS256); !ok {
return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"])
}
return public, nil
}, jwt.WithValidMethods([]string{"RS256"}))
if err != nil {
return nil, err
}
return claims, nil
}Три замка работают вместе: утверждение t.Method отсекает чужие алгоритмы в keyfunc, WithValidMethods режет список на уровне парсера ещё до её вызова, а подпись сверяется публичным ключом. Ошибка любого замка — err не nil и токен не годен. Обратите внимание: в проверяющем коде нет ни одного секрета — его можно показывать и публиковать без риска для выпуска.
Если keyfunc вернёт байты PEM вместо структуры *rsa.PublicKey, а токен придёт с alg HS256, библиотека добросовестно воспримет эти байты как HMAC-секрет и сверит подпись. Публичный ключ и так публичен: нападающий скачает его, подпишет им подделку как HS256 и пройдёт проверку, не зная приватного. Лечится двумя замками сразу — проверка t.Method в keyfunc и jwt.WithValidMethods — и я не считаю их дублированием: каждый живёт в своём слое и страхует другой. Разбор самой атаки с подменой alg и alg none — в уроке про подделку подписи.
HS256, RS256 и ES256 рядом#
| Свойство | HS256 | RS256 | ES256 |
|---|---|---|---|
| Тип ключа | общий секрет []byte | пара RSA 2048 бит и больше | пара на кривой P-256 |
| Кто может проверять | держатель секрета — он же подписывает | любой с публичным ключом | любой с публичным ключом |
| Скорость | самая быстрая | медленнее на порядок | быстрее RSA |
| Размер подписи | около 43 байт | 256 байт при RSA-2048 | около 64 байт |
| Типичный сценарий | один монолит без соседей | identity-сервис и много проверяющих | то же, но с экономией байт |
Генерация пары и дисциплина хранения#
Пара генерируется openssl-ом один раз: openssl genrsa -out private.pem 2048 создаёт приватный ключ, openssl rsa -in private.pem -pubout -out public.pem выводит из него публичный. Нижняя граница для RSA сегодня — 2048 бит; если ключ живёт годами, берите 3072 или 4096 — цена выпуска в миллисекунды вас не волнует, он происходит на логине, а не в горячем цикле. Для ES256 команда другая: openssl ecparam -name prime256v1 -genkey -noout -out private.pem. Дальше дисциплина хранения. Приватный ключ не попадает в git ни в каком виде — ни в конфиге, ни в тестовой папке; он живёт в менеджере секретов или на томе с правами 0600, а путь приходит переменной окружения. Публичный ключ секретом не является: его кладут в репозиторий, раздают проверяющим или — правильнее — отдают по URL, чем займётся следующий урок про JWKS. Протухание пары планируется заранее: смена ключей без простоя — это ротация, у неё есть свой протокол, и руками его лучше не изобретать. Отдельно про резервную копию: приватный ключ без бэкапа означает выпустить новую пару и разом перелогинить всех пользователей, поэтому копия в менеджере секретов обязательна; а вот продублировать её в общий чат или на рабочий ноутбук — значит свести всю дисциплину хранения к нулю.
Почему HS256 всё ещё жив#
После сравнительной таблицы честно сказать обратное: асимметрия — не бесплатное улучшение, а плата за архитектуру, и там, где проверяющих нет, она не нужна. Один монолит, который и выпускает, и проверяет токены сам, живёт с HS256 спокойно: один секрет в переменной окружения, ни одного файла ключей, ни одного JWKS-эндпоинта, ни одного вопроса про ротацию между сервисами. Операционная простота — реальная ценность: меньше движущихся частей, меньше мест, где можно потерять ключ. Мой критерий перехода на RS256 — не мода, а появление второго проверяющего: воркер, который читает те же токены, отдельный identity-сервис, внешний партнёр. До этого момента смена подписи добавляет работы без выигрыша в безопасности: секрет всё равно один и живёт в одном месте. Переходный период планируют заранее: пока часть сервисов ещё живёт на HMAC, проверяющая сторона держит оба семейства в WithValidMethods и сверяет alg токена с его происхождением, а по завершении миграции белый список сжимается до одного значения — это первая строка чек-листа после переезда. Ошибкой я считаю оба края: тащить RSA в монолит ради солидности и наоборот — раздавать HMAC-секрет трём сервисам, потому что «RSA это сложно». Сложность там один раз, а утечка секрета — навсегда.
Ключи в тестах: генерировать, а не хранить#
Тесты не должны читать keys/private.pem из репозитория: во-первых, это приучает к ключам в git, во-вторых — общий ключ для тестов и прода рано или поздно переползёт из фикстуры в конфиг. В тестах пара генерируется на лету: rsa.GenerateKey(rand.Reader, 2048) занимает десятки миллисекунд на обычной машине и даёт уникальную пару на каждый прогон; для ES256 та же история — ecdsa.GenerateKey с elliptic.P256(). Проверяющая сторона теста берёт key.PublicKey — и весь круг выпуск-проверка работает без единого файла и без переменных окружения. Если скорость прогонов важна, ключ генерируется один раз в TestMain и переиспользуется всеми тестами пакета: уникальность между прогонами сохраняется, внутри прогона тоже. Отдельный плюс подхода — тесты перестают зависеть от содержимого PEM-файлов: подпорченная фикстура больше не может устроить мистическую ошибку подписи в пятницу вечером.
Проверка: тип ключа в keyfunc
Какой тип должна возвращать keyfunc для токена, подписанного RS256?
Проверка: неверный тип ключа при выпуске
Что вернёт SignedString, если передать ей []byte вместо *rsa.PrivateKey при выпуске RS256?
Токен с чужим алгоритмом
Прочитайте код и выберите, что он выведет:
// токен: header {"alg":"HS256"}, подписан неизвестно чем
tokenStr := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3In0.c2ln"
claims := &AppClaims{}
_, err := VerifyRS256(tokenStr, publicKey)
// что в err?ES256: тот же сценарий тремя заменами
GOkey, err := jwt.ParseECPrivateKeyFromPEM(data)
// ...
claims := AppClaims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodES256, claims).SignedString(key)Переход на ECDSA — три замены: ParseECPrivateKeyFromPEM читает ключ кривой, SigningMethodES256 задаёт семейство, а в keyfunc возвращается *ecdsa.PublicKey. Подпись ужимается примерно до шестидесяти четырёх байт против двухсот пятидесяти шести у RSA-2048, генерация подписи быстрее. Механика проверки, claims и сроки не меняются вообще.
Понадобятся локальный Go и openssl: две команды из раздела про генерацию, go mod init и go get github.com/golang-jwt/jwt/v5. Выпустите токен IssueRS256 и распечатайте его три части. Затем попробуйте проверить токен приватным ключом вместо публичного — ошибки не будет, пара-то одна. Напоследок подмените alg на HS256 в header вручную и посмотрите, как отработает WithValidMethods: отказ придёт до криптографии.
Восстановите keyfunc проверки RS256
Допишите keyfunc: утверждение метода, проверка флага и возврат публичного ключа.
Соберите выпуск RS256-токена
Расставьте строки выпуска в правильном порядке: от чтения файла ключа до возврата подписанной строки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: асимметричные подписи
Роли из урока про RequireRole работают с RS256 без единой правки: проверка прав читает claims, ей всё равно, чем подписан payload. Главное изменение — организационное: выпуск и проверка разъезжаются по сервисам, и это вход в архитектуру из урока про JWT в микросервисах, где проверяющих много, а identity-сервис один. Каркас проверяющего сервиса — обычный HTTP-роутер, и его сборка по слоям разобрана в курсе по chi; для gin та же проверка собрана в уроке про JWT в gin. Остался последний ручной шаг — раздача публичного ключа: в следующем уроке про JWKS он уедет на URL, и проверяющие перестанут хранить файлы вовсе. Вернуться к HMAC и ParseWithClaims всегда можно на странице курса jwt-go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- RS256 jwt в Go разделяет роли: приватный ключ выпускает, публичный проверяет, и он не секрет.
- jwt.ParseRSAPrivateKeyFromPEM декодирует PKCS1 или PKCS8 в *rsa.PrivateKey; SignedString с ней подписывает RS256.
- В keyfunc возвращается *rsa.PublicKey; утверждение метода плюс WithValidMethods закрывают путаницу алгоритмов.
- ES256 — компактная альтернатива: ключ P-256, подпись около 64 байт, ParseECPrivateKeyFromPEM.
- Приватный ключ не в git: менеджер секретов или том с правами 0600; публичный можно раздавать свободно.
- HS256 остаётся разумным для одного монолита; асимметрия нужна, когда проверяющих несколько.
Домашнее задание
Сгенерируйте пару openssl-ом, выпустите токен IssueRS256 и проверьте его VerifyRS256. Затем скопируйте public.pem в отдельную папку, уберите приватный ключ из досягаемости и убедитесь, что проверка работает без него. Попробуйте SignedString с байтовым секретом вместо ключа и сохраните текст ошибки — он пригодится при отладке типов ключей.
Личные заметкиПусто
Вопросы и ответы по уроку
Насколько RS256 медленнее HS256?
Подпись RSA-2048 на типичном сервере занимает доли миллисекунды, HMAC — микросекунды; при выпуске на логине разница незаметна. Проверка RSA тоже укладывается в бюджет запроса, а её можно выносить на gateway. Замер на своём железе честнее любых общих цифр.
Можно ли проверять RS256-токен приватным ключом?
Технически да: из *rsa.PrivateKey берётся поле PublicKey, и проверка пройдёт. Но передавать приватный ключ всем проверяющим — значит терять главный выигрыш асимметрии; публичный ключ отдельно и есть правильный интерфейс проверки.
Что выбрать для нового проекта — RS256 или ES256?
Оба рабочие: ES256 даёт короткие подписи и быструю генерацию, RS256 — более старую поддержку инструментов и привычные 2048-битные ключи. Для REST API разница косметическая; если важны байты — ES256, если совместимость со старыми клиентами — RS256.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния