---
title: "RSA и ECDSA: асимметричные подписи"
description: "RS256 jwt в Go: rsa подпись токена приватным ключом и проверка публичным, ParseRSAPrivateKeyFromPEM, SigningMethodRS256, ES256 и сравнение с HS256. С кодом."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/12-rsa-i-es/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 20
---

# RSA и ECDSA: асимметричные подписи

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

RS256 — асимметричная подпись jwt в Go: приватный ключ выпускает токены, публичный проверяет их, и именно это снимает главный недостаток HMAC из [пятого урока](/courses/jwt-go/lessons/05-hmac-podpis/). Разбираем генерацию пары ключей, jwt.ParseRSAPrivateKeyFromPEM, SigningMethodRS256 в keyfunc, компактный ES256 и честные границы, за которыми HS256 всё ещё правильный выбор.

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

### Выпуск токена приватным ключом

```go
package 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](/courses/jwt-go/lessons/06-parsing-token/): утверждаем метод, возвращаем ключ подходящего типа, добавляем WithValidMethods. Меняются ровно две детали: константа метода — SigningMethodRS256 — и тип ключа в возврате keyfunc: для RSA это *rsa.PublicKey, структура с модулем и экспонентой, без приватной половины. Всё остальное — разбор строки, сверка подписи, автоматические exp и iss — работает как раньше.

### VerifyRS256: keyfunc возвращает публичный ключ

```go
func 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 — в [уроке про подделку подписи](/courses/jwt-go/lessons/23-poddelka-podpisi/).

### 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?

- []byte с содержимым PEM-файла
- ✅ *rsa.PublicKey
- Строку с модулем в hex
- *rsa.PrivateKey — полный ключ

Для проверки нужен только публичный ключ: *rsa.PublicKey с модулем и экспонентой. Байты PEM — приглашение к путанице алгоритмов: с alg HS256 библиотека примет их за HMAC-секрет. Полный *rsa.PrivateKey работать будет, но раздаёт приватный материал всем проверяющим — против этого и придумана асимметрия.

### Проверка: неверный тип ключа при выпуске

**Вопрос:** Что вернёт SignedString, если передать ей []byte вместо *rsa.PrivateKey при выпуске RS256?

- Токен с HS256 — метод подстроится под тип
- ✅ Ошибку неверного типа ключа
- Пустую строку без ошибки
- Панику внутри библиотеки

Метод RS256 ожидает *rsa.PrivateKey: любой другой тип — это ошибка ErrInvalidKeyType, и токен не выйдет. Подстройки под тип не происходит: alg в header задаётся при NewWithClaims, а ключ обязан соответствовать семейству. Тихих подмен в golang-jwt нет — любое несоответствие возвращается ошибкой.

### Токен с чужим алгоритмом

```go
// токен: header {"alg":"HS256"}, подписан неизвестно чем
tokenStr := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3In0.c2ln"

claims := &AppClaims{}
_, err := VerifyRS256(tokenStr, publicKey)
// что в err?
```

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

- nil: подписи HS256 и RS256 совместимы
- ✅ Ошибка: WithValidMethods и утверждение в keyfunc режут HS256 до сверки подписи
- Ошибка подписи после успешной сверки
- Паника на незнакомом алгоритме

HS256 не входит в белый список WithValidMethods, и парсер отвергает токен ещё до вызова keyfunc; даже без опции утверждённый SigningMethodRS256 в keyfunc дал бы тот же отказ. Подпись при этом не сверяется вовсе — разные семейства несравнимы. Паник в библиотеке нет: любой отказ возвращается ошибкой.

### ES256: тот же сценарий тремя заменами

```go
key, 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: утверждение метода, проверка флага и возврат публичного ключа.

```go
claims := &AppClaims{}

_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
        if _, ok := t.Method.(*jwt.___); !___ {
                return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"])
        }
        return ___, nil
}, jwt.WithValidMethods([]string{"RS256"}))
```

Подсказка: Метод называется SigningMethodRS256, утверждение даёт флаг ok, а проверка идёт структурой *rsa.PublicKey из переменной public.

Правильные ответы в пропусках: `SigningMethodRS256`, `ok`, `public`

### Соберите выпуск RS256-токена

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

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

1. `data, err := os.ReadFile("keys/private.pem")`
2. `key, err := jwt.ParseRSAPrivateKeyFromPEM(data)`
3. `claims := AppClaims{UserID: 7, Role: "user"}`
4. `token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)`
5. `signed, err := token.SignedString(key)`
6. `return signed, err`

### Карточки: асимметричные подписи

- **RS256** — Асимметричная подпись jwt: RSASSA-PKCS1-v1_5 с SHA-256. Приватный ключ выпускает, публичный проверяет; подпись RSA-2048 занимает 256 байт.
- **Пара ключей** — private.pem только у выпускающего сервиса, public.pem у всех проверяющих. Подделка по публичному ключу невозможна: он проверяет, но не подписывает.
- **ParseRSAPrivateKeyFromPEM** — jwt.ParseRSAPrivateKeyFromPEM декодирует PEM в формате PKCS1 или PKCS8 в *rsa.PrivateKey. Для ECDSA есть ParseECPrivateKeyFromPEM.
- **ES256** — ECDSA на кривой P-256: ключ 256 бит, подпись около 64 байт, генерация быстрее RSA. В golang-jwt — SigningMethodES256 и ключи *ecdsa.PrivateKey.
- **WithValidMethods** — Белый список алгоритмов парсера: jwt.WithValidMethods([]string{"RS256"}). Вместе с утверждением t.Method в keyfunc закрывает путаницу алгоритмов.

Роли из [урока про RequireRole](/courses/jwt-go/lessons/11-roli-i-prava/) работают с RS256 без единой правки: проверка прав читает claims, ей всё равно, чем подписан payload. Главное изменение — организационное: выпуск и проверка разъезжаются по сервисам, и это вход в архитектуру из [урока про JWT в микросервисах](/courses/jwt-go/lessons/22-jwt-mikroservisy/), где проверяющих много, а identity-сервис один. Каркас проверяющего сервиса — обычный HTTP-роутер, и его сборка по слоям разобрана в [курсе по chi](/courses/chi/); для gin та же проверка собрана в [уроке про JWT в gin](/courses/gin/lessons/15-jwt/). Остался последний ручной шаг — раздача публичного ключа: в [следующем уроке про JWKS](/courses/jwt-go/lessons/13-jwks/) он уедет на URL, и проверяющие перестанут хранить файлы вовсе. Вернуться к HMAC и ParseWithClaims всегда можно [на странице курса jwt-go](/courses/jwt-go/).

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

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