Ошибка формата приватного ключа RS256: одно сообщение, семь причин
Ошибка формата приватного ключа RS256 почти никогда не называет собственную причину. На Node v25.8.2 каждый из перечисленных промахов даёт одну и ту же строку:
code: ERR_OSSL_UNSUPPORTED
message: error:1E08010C:DECODER routines::unsupported
Запускают её пять никак не связанных между собой вещей: контейнер OpenSSH там, где ждали ключ PEM; отступ в строке -----BEGIN; открытый ключ, переданный подписывающей стороне; буквальные последовательности \n, которые никто не развернул; и файл, у которого переводы строк потерялись по дороге. В проверенном списке из раздела 2 таких причин семь, а сообщение у всех одно, поэтому поиск по тексту ошибки выбрасывает вас в чужое обсуждение чужой причины.
Сначала разделите задачу надвое:
- Библиотека так и не получила объект ключа. Тогда вам сюда.
- Библиотека загрузила ключ, а потом сказала
invalid signature. Это другой сбой с другими причинами: читайте «JWT invalid signature: все причины и способы исправления».
Тридцатисекундная сортировка для первого случая:
openssl rsa -in key.pem -noout -text | head -1
Если команда падает — виноват файл, и разделы с 3 по 6 покажут, чем именно. Если проходит — OpenSSL понял контейнер, значит дело в библиотеке или в том, что вы ей передали; это разделы 4 и 7.
Всё, что ниже, измерено 11 августа 2026 года на OpenSSL 3.6.2 7 Apr 2026, Node v25.8.2, Go go1.26.1 darwin/arm64 и Java 1.8.0_162. Там, где утверждение получено чтением исходников, а не запуском, это сказано прямо в тексте.
1. Сначала поймите, с каким сбоем вы имеете дело
Граница проходит по одному вопросу: существовал ли объект ключа вообще.
Сбои на этапе разбора случаются до того, как заработает хоть какая-то криптография. Библиотека читает ваш PEM, не может превратить его в ключ и бросает исключение. Ничего не подписано, ничего не проверено, а токен, который вы отлаживаете, вообще не был выпущен. Сбои на этапе проверки — противоположность: ключ загрузился без замечаний, подпись вычислена, и она не совпала. Такие случаи растут из побайтовых расхождений между подписывающей и проверяющей сторонами, и им посвящено руководство по invalid signature.
Различить их можно одним взглядом на стек вызовов. Сбой разбора называет декодер, key spec или структуру ASN.1. Сбой проверки называет подпись.
Вот как выглядит отвергнутый приватный ключ в трёх экосистемах:
| Среда | Проверенная версия | Сообщение, когда ключ не загружается |
|---|---|---|
Node crypto | v25.8.2 | error:1E08010C:DECODER routines::unsupported |
Go crypto/x509 | go1.26.1 darwin/arm64 | x509: failed to parse private key (use ParsePKCS1PrivateKey instead for this key format) |
Java PKCS8EncodedKeySpec | 1.8.0_162 | InvalidKeySpecException: java.security.InvalidKeyException: IOException : algid parse error, not a sequence |
Помогают эти три сообщения очень по-разному. Go прямо называет функцию, которую надо вызвать вместо текущей. Java упоминает какой-то «algid» и какую-то «sequence», а догадываться, что речь про неверный контейнер, предлагает самостоятельно. Node не сообщает вообще ничего пригодного.
Если нет уверенности, что искомый токен вообще подписан RS256, вставьте его в декодер JWT и посмотрите поле alg в заголовке, прежде чем идти дальше. Заголовок с HS256 означает, что нужен общий секрет, а не пара ключей, и тогда любой симптом из этой статьи уведёт вас не туда.
2. От текста ошибки к первопричине: таблица поиска по ошибке формата приватного ключа RS256
Найдите свою строку дословно. Правый столбец говорит, куда двигаться дальше.
| Текст ошибки | Откуда берётся | Что это означает на самом деле |
|---|---|---|
error:1E08010C:DECODER routines::unsupported | Node v25.8.2 | Семь возможных причин, перечислены ниже |
error:07880109:common libcrypto routines::interrupted or cancelled | Node v25.8.2 | Ключ зашифрован, а пароль вы не передали |
x509: failed to parse private key (use ParsePKCS8PrivateKey instead for this key format) | Go 1.26.1 | Вы вызвали ParsePKCS1PrivateKey на файле PKCS#8 |
x509: failed to parse private key (use ParsePKCS1PrivateKey instead for this key format) | Go 1.26.1 | Вы вызвали ParsePKCS8PrivateKey на файле PKCS#1 |
asn1: structure error: tags don't match (16 vs {class:0 tag:6 ...}) | Go 1.26.1 | Первый блок PEM — это EC PARAMETERS, а не ключ |
algid parse error, not a sequence | Java 1.8.0_162 | PKCS#1 передали в PKCS8EncodedKeySpec |
secretOrPrivateKey must have a value | jsonwebtoken, в исходниках | Аргумент с ключом ложный, а alg не равен none |
secretOrPrivateKey is not valid key material | jsonwebtoken, в исходниках | Не удалось построить ни приватный, ни секретный ключ |
secretOrPrivateKey must be a symmetric key when using ${header.alg} | jsonwebtoken, в исходниках | alg начинается с HS, но ключ не секретный |
secretOrPrivateKey must be an asymmetric key when using ${header.alg} | jsonwebtoken, в исходниках | alg совпал с RS, PS или ES, но ключ не приватный |
secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg} | jsonwebtoken, в исходниках | RS или PS с ключом короче 2048 бит и выключенным allowInsecureKeySizes |
Пять строк с secretOrPrivateKey прочитаны в sign.js из ветки master проекта jsonwebtoken, а не получены запуском, поэтому условия срабатывания стоит считать тем, что написано в исходниках, а не воспроизведённым на этой машине. Фрагмент ${header.alg} — подстановка шаблонной строки в тех же исходниках; во время выполнения на его месте окажется имя вашего алгоритма, и именно поэтому поиск дословной строки с фигурными скобками не находит ничего.
Семь способов получить DECODER routines::unsupported
Все семь воспроизведены на crypto.createPrivateKey() в Node v25.8.2, и все семь дали один и тот же код с одним и тем же сообщением:
- Контейнер OpenSSH. Файл начинается с
-----BEGIN OPENSSH PRIVATE KEY-----и структурой ключа PEM не является вовсе. - Отступ в строке
-----BEGINлибо в строке-----END. Строки тела — исключение; точная граница разобрана в разделе 5. - Пробел перед всем PEM. Пустая строка в начале допустима, пробел — нет.
- Переводы строк убраны полностью, так что заголовок, base64 и завершающая строка слиплись в одну.
- Открытый ключ там, где ожидался приватный.
- Неразвёрнутые последовательности из обратного слэша и
n— именно в это превращается однострочная переменная окружения. - Разделитель с неверным количеством дефисов или
begin/end, написанные строчными буквами.
Две причины из этого списка — про контейнер, четыре — про искажение текста, ещё одна — обычная путаница. Сообщение их не различает, поэтому быстрее исключать варианты, чем вчитываться.
Что Node принимает
Обратный список полезнее, потому что каждый его пункт — гипотеза, которую можно отбросить сразу. На Node v25.8.2 crypto.createPrivateKey() без единого замечания принял всё перечисленное:
- Приватные ключи PKCS#1 и PKCS#8
- Приватные ключи EC SEC1
- Переводы строк CRLF
- Отсутствие финального перевода строки
- Тело base64 одной неразбитой строкой
- Отступы в строках тела
- Пустую строку перед PEM
- UTF-8 BOM — и в виде
'' + pem, и в видеBuffer, начинающегося с0xEF 0xBB 0xBF - Заголовок PKCS#1 вокруг тела PKCS#8
Последний пункт объясняется просто: декодер читает структуру DER внутри base64 и игнорирует надпись снаружи, поэтому файл, где над содержимым PKCS#8 написано BEGIN RSA PRIVATE KEY, всё равно загрузится. Отсюда же предупреждение к разделу 3: строка заголовка — подсказка, а не гарантия.
3. Строка заголовка PEM: какой контейнер у вас на самом деле
Каждый PEM объявляет себя в первой строке. Вот значения заголовков, которые пишет OpenSSL 3.6.2:
| Содержимое | Первая строка |
|---|---|
| Приватный ключ PKCS#8 | -----BEGIN PRIVATE KEY----- |
| Приватный ключ PKCS#1 | -----BEGIN RSA PRIVATE KEY----- |
| Зашифрованный приватный ключ | -----BEGIN ENCRYPTED PRIVATE KEY----- |
| Приватный ключ OpenSSH | -----BEGIN OPENSSH PRIVATE KEY----- |
| Приватный ключ EC SEC1 | -----BEGIN EC PARAMETERS-----, затем второй блок -----BEGIN EC PRIVATE KEY----- |
| Открытый ключ SPKI | -----BEGIN PUBLIC KEY----- |
| Открытый ключ PKCS#1 | -----BEGIN RSA PUBLIC KEY----- |
| Приватный ключ Ed25519 | -----BEGIN PRIVATE KEY-----, и весь файл занимает три строки |
Так что head -1 key.pem отвечает на первый вопрос любого разбирательства. Дальше три детали, которые чаще всего упускают.
ENCRYPTED PRIVATE KEY — это не ошибка формата, а забытый пароль. Node сообщает о нём не так, как обо всём остальном: ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED и error:07880109:common libcrypto routines::interrupted or cancelled — библиотека запросила пароль и не получила ответа. Не смешивайте это сообщение с DECODER: между ними нет ничего общего.
OPENSSH PRIVATE KEY — отдельный мир: OpenSSH пишет собственный контейнер, и это не PKCS#1 и не PKCS#8, хотя он и лежит между разделителями, похожими на PEM. Node отвергает его сразу, и так же поступают парсеры Go crypto/x509 и PKCS8EncodedKeySpec из JDK. Если ключ подписи JWT вышел из ssh-keygen — вот вам и причина.
Файлы EC SEC1 содержат два блока: openssl ecparam -genkey пишет сначала блок EC PARAMETERS, а приватный ключ — вторым. Всё, что читает только первый блок PEM, получает параметры и падает с сообщением, где не упомянуто ни то, ни другое. Версия этого сбоя для Go — в разделе 4.
И раз заголовок всего лишь ярлык, важна обратная проверка: файл, у которого заголовок говорит одно, а DER другое, разбирается по DER. Чтение head -1 надёжно для файлов, вышедших прямо из OpenSSL, и ненадёжно для тех, что прошли через человека, вики-страницу или скрипт с заменой строк.
4. Какая библиотека что принимает: PKCS#1 против PKCS#8 в трёх экосистемах
Эта матрица объясняет большинство межкомандных споров о форматах. Каждая строка измерена на версиях, перечисленных в начале статьи.
| Библиотека | PKCS#1 | PKCS#8 | OpenSSH | Объясняет ли ошибка сама себя? |
|---|---|---|---|---|
Node crypto | Да | Да | Нет | Нет. Много причин, одно DECODER routines::unsupported |
Go crypto/x509 | Да, отдельная функция | Да, отдельная функция | Нет | Да. Называет функцию, на которую надо переключиться |
| Стандартная библиотека Java | Нет | Да | Нет | Нет. algid parse error, not a sequence откровенно уводит в сторону |
Прочитайте столбцы — и споры разрешаются сами. Сервис на Node и сервис на Java, живущие с одним файлом ключа, прекрасно уживаются ровно до того момента, когда ключ оказывается в PKCS#1: Node продолжает подписывать, а Java бросает сообщение про последовательности ASN.1. Ключ при этом никто не подозревает — он ведь доказуемо работает в проде у соседнего сервиса.
Node. Настраивать нечего. Если контейнер — PKCS#1 или PKCS#8, createPrivateKey() его примет. А если исключение всё-таки прилетело, тратьте время на семь причин из раздела 2, а не на формат.
const fs = require('node:fs');
const { createPrivateKey } = require('node:crypto');
try {
const key = createPrivateKey(fs.readFileSync('key.pem'));
console.log('parsed:', key.asymmetricKeyType);
} catch (err) {
console.log(err.code, '/', err.message);
}
Запускайте это на том файле, который читает приложение, а не на копии, сделанной руками: ветка catch напечатает пару «код + сообщение», которую можно найти в таблице раздела 2.
Go. Два контейнера, две функции, и вызов не той из них — самый частый сбой в Go. Сообщение подсказывает нужную, так что починка механическая. А если пробовать обе по очереди, выбор отпадает совсем:
priv, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
rsaKey, err2 := x509.ParsePKCS1PrivateKey(block.Bytes)
if err2 != nil {
log.Fatalf("neither container parsed: %v / %v", err, err2)
}
priv = rsaKey
}
Но до этого нужно обезвредить ловушку с EC. Для файла из openssl ecparam -genkey pem.Decode возвращает блок, у которого Type равен EC PARAMETERS, и все три функции разбора падают на нём с сообщением:
asn1: structure error: tags don't match (16 vs {class:0 tag:6 ...})
Про блоки PEM здесь нет ни слова, поэтому первая реакция — подозревать ключ. Вместо этого пропустите блок с параметрами:
block, rest := pem.Decode(pemBytes)
if block == nil {
log.Fatal("no PEM block found")
}
if block.Type == "EC PARAMETERS" {
block, _ = pem.Decode(rest)
}
Или вообще не создавайте лишний блок — добавьте -noout к команде ecparam, которая пишет файл.
Java. Стандартная библиотека читает PKCS#8 и больше ничего. Передайте PKCS8EncodedKeySpec ключ PKCS#1 на Java 1.8.0_162 — и получите:
InvalidKeySpecException: java.security.InvalidKeyException: IOException : algid parse error, not a sequence
algid — это идентификатор алгоритма, то самое поле, которое PKCS#8 добавляет, а в PKCS#1 его нет. Парсер искал его, наткнулся на начало модуля RSA и сдался. Сообщение верное и бесполезное в равной мере. Сконвертируйте файл — и ошибка исчезнет:
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
Когда файл уже в PKCS#8, загрузка на Java 8 умещается в несколько строк; их можно вставить прямо в тест, пока проверяете исправление:
String pem = new String(Files.readAllBytes(Paths.get("key.pem")), StandardCharsets.UTF_8)
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
byte[] der = Base64.getDecoder().decode(pem);
PrivateKey key = KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(der));
Альтернатива — подключить BouncyCastle, который PKCS#1 читать умеет. Но конвертация обходится в одну команду и ноль зависимостей, так что берите её, если только BouncyCastle не нужен вашему стеку для чего-то ещё.
5. Символы, которых не видно
С отступами и BOM популярный совет расходится с тем, что парсер делает на самом деле.
Отступы: правило работает наоборот
Широко тиражируемая инструкция гласит: каждая строка PEM, кроме строк-разделителей, должна начинаться с нулевой колонки. Проверка на Node v25.8.2 показывает ровно обратное:
| Изменение в файле | Результат |
|---|---|
| Отступ у каждой строки | Ошибка |
Отступ только у строки -----BEGIN | Ошибка |
Отступ только у строки -----END | Ошибка |
| Отступ только у строк тела base64 | Принято |
| Пробел перед всем PEM | Ошибка |
| Пустая строка перед всем PEM | Принято |
Значит, правило такое: строки -----BEGIN и -----END должны начинаться с нулевой колонки, а отступы в строках тела ни на что не влияют. Ровно те две строки, которые разрешают сдвигать, всё и ломают, а у строк, которые велят выравнивать, как раз есть запас.
Важно это потому, что вручную PEM никто не сдвигает. Отступ появляется, когда ключ вставляют в блок YAML, в файл values для Helm, в heredoc Terraform или в строку Python с тройными кавычками внутри тела класса. Каждый из этих случаев сдвигает всё целиком, вместе с разделителями, то есть даёт первую строку таблицы.
Буквальные \n из однострочной переменной окружения
В PEM есть переводы строк, а в переменной окружения их на практике нет. Поэтому ключи попадают в файлы .env одной строкой, где \n записан двумя символами. Всё, что читает такой файл, отдаёт вашему коду строку с обратными слэшами, и парсер видит разделитель, за которым идёт мусор. На Node это причина №6 из раздела 2, с тем же сообщением DECODER routines::unsupported, что и у всего остального.
Разверните её обратно в точке использования:
const pem = process.env.PRIVATE_KEY.replace(/\\n/g, '\n');
Вокруг этой строки нужны две страховки. Первая: заменяйте только тогда, когда строка действительно содержит эту двухсимвольную последовательность, — тогда по-настоящему многострочное значение, пришедшее через другой загрузчик, останется нетронутым. Вторая: если платформа позволяет, храните весь PEM в base64 — одна строка base64, декодирование при старте, и вопрос экранирования исчезает совсем.
BOM: на Node безвреден, в остальных местах не проверен
Маркер порядка байтов — это три байта, EF BB BF, которые некоторые редакторы под Windows пишут в начало файла UTF-8. Совет удалить его перед загрузкой ключа встречается часто. На Node v25.8.2 он ничего не меняет: PEM с BOM в начале успешно разобрался и как строка, и как Buffer, начинающийся с этих трёх байтов.
У этого результата узкие границы. Он измерен только на Node v25.8.2. Java, Python и другие парсеры здесь не проверялись, и статья ничего не говорит об их поведении. Если вы отлаживаете сервис на Java, BOM остаётся открытым вопросом, а не отброшенной версией.
Другие вещи BOM действительно ломает — вероятно, оттуда совет про ключи и перекочевал по ассоциации. JSON.parse на строке с BOM в начале — реальный и хорошо задокументированный сбой, разобранный в статье «UTF-8 BOM: исправление ошибок JSON.parse и CSV». Поэтому файл ключа, лежащий внутри конфигурации в формате JSON, способен упасть задолго до того, как кто-то посмотрит на сам ключ.
Переводы строк, завершающий перевод строки и ширина переноса
Ещё три подозреваемых, которых Node v25.8.2 оправдал:
- Переводы строк CRLF. Принято. Ключ, прошедший через Windows, не сломан автоматически.
- Отсутствие финального перевода строки. Принято. Здесь всё зависит от парсера: считается, что некоторые парсеры отвергают PEM без завершающего перевода строки, но Node такой файл принимает; другие парсеры здесь не проверялись.
- Тело без переносов. Принято. Base64 не обязан складываться по 64 символа.
А вот что действительно ломает тело base64 — потерянный, вставленный или подменённый символ; это уже другой сбой, не связанный с переносами. Мессенджер, превращающий перевод строки в пробел, или поле ввода, съедающее последний символ, дают тело, которое больше не декодируется. Копируйте кнопкой копирования, а не выделением мышью.
6. OpenSSL 3.x поменял значение по умолчанию у вас за спиной
Измерено на OpenSSL 3.6.2 7 Apr 2026:
| Команда | Какой контейнер пишет |
|---|---|
openssl genrsa -out k.pem 2048 | PKCS#8, заголовок BEGIN PRIVATE KEY |
openssl genrsa -traditional -out k.pem 2048 | PKCS#1 |
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 | PKCS#8 |
openssl genpkey -algorithm ED25519 | PKCS#8 |
openssl pkcs8 -topk8 -nocrypt -in a.pem -out b.pem | Превращает PKCS#1 в PKCS#8 |
openssl rsa -in b.pem -traditional -out a.pem | Превращает PKCS#8 в PKCS#1 |
Перечитайте первые две строки. В этой сборке genrsa по умолчанию выдаёт PKCS#8, а файл с BEGIN RSA PRIVATE KEY получается из -traditional. Множество руководств до сих пор описывают genrsa как команду для PKCS#1, а genpkey — как команду для PKCS#8; следуя им, вы останетесь в уверенности, что сгенерировали формат, которого не генерировали.
Практическое следствие вылезает при миграциях. Команда на Java получает рабочий ключ от коллеги со старым OpenSSL, всё в порядке, а через полгода кто-то перевыпускает ключ на свежей машине. Та же команда, та же документация, другой контейнер — и вот JDK бросает algid parse error, not a sequence в ключ, который «сгенерирован ровно тем же способом». Не тем же.
Значит, не предполагайте. Проверяйте:
head -1 key.pem
Одна строка вывода — и таблица из раздела 3 говорит, что у вас в руках. Делайте это до любой команды конвертации: превращение файла PKCS#8 в PKCS#8 — пустая операция, которая выглядит как починка и не чинит ничего.
Если думать о флагах не хочется вовсе, генератор ключей RSA выдаёт оба контейнера из одной и той же пары ключей по переключателю: можно получить копию одного ключа в PKCS#1 и в PKCS#8 и подставить каждую в библиотеку, которая вам отказывает.
7. Порог в 2048 бит, из-за которого отвергается совершенно исправный ключ
Один сбой выглядит как проблема формата, но ею не является. В исходниках jsonwebtoken файл sign.js бросает:
secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}
По исходникам это срабатывает, когда alg — алгоритм семейства RS или PS, ключ короче 2048 бит, а allowInsecureKeySizes не выставлен. Проверка принадлежит самой библиотеке, а не среде выполнения. Node v25.8.2 разбирает 1024-битный ключ RSA без замечаний: modulusLength: 1024 даёт такой же объект ключа, как любой другой. То есть ключ структурно корректен, контейнер верный, OpenSSL его читает — а вызов подписи всё равно падает.
Признак простой: это сообщение называет число. Ошибки формата говорят о декодерах, последовательностях и материале ключа; это говорит о битах. Увидели в сообщении размер — перестаньте смотреть на PEM.
Откуда берутся 1024-битные ключи, обычно объясняет история: ключ сгенерировали годы назад по умолчанию, которое с тех пор сдвинулось, или это тестовая заготовка, к которой никто не возвращался, потому что короткие ключи генерируются быстрее. Чинить это надо новой парой на 2048 бит или больше, а не аварийным люком: люк выключает проверку, которая существует не просто так.
Чтобы убедиться, что размер — единственная оставшаяся проблема, подпишите ту же полезную нагрузку свежим ключом нужной длины в JWT-энкодере. Если там токен получается, а в вашем коде нет, различие в ключе, а не в claims и не в конфигурации.
8. Повторяемый порядок разбора сбоев с ключами RS256
Выполняйте по порядку. Каждый шаг либо находит причину, либо отсекает ветку.
- Прочитайте строку заголовка.
head -1 key.pem, затем сверьтесь с таблицей из раздела 3. Она говорит, какой это контейнер, зашифрован ли файл и не является ли он ключом OpenSSH, который не заработает никогда. - Попросите OpenSSL его разобрать.
openssl rsa -in key.pem -noout -text | head -1для RSA илиopenssl pkey -in key.pem -nooutдля любого алгоритма. Если команда прошла, байты — валидный ключ, и проблема на стороне библиотеки. Если упала, файл повреждён, и вы переходите к шагу 4. - Найдите свою библиотеку в матрице. Раздел 4. Если у вас Java с файлом PKCS#1 или Go с вызовом не той функции разбора — на этом всё.
- Посмотрите на невидимые символы.
head -c 32 key.pem | xxdпоказывает первые байты и одним взглядом ловит BOM, ведущий пробел и сдвинутый разделитель. Потом убедитесь, что строки-----BEGINи-----ENDначинаются с нулевой колонки, как сказано в разделе 5. - Сделайте бисекцию заведомо исправным ключом. Сгенерируйте свежую пару в генераторе ключей RSA, направьте на неё код и посмотрите, выживет ли ошибка. Если да — дефект в коде загрузки, а не в файле ключа, и никакое переформатирование оригинала не поможет. Если нет — виноват исходный файл, а у вас теперь есть рабочий ключ, с которым его можно сравнить.
- Алгоритм и размер проверяйте последними. Убедитесь, что в заголовке стоит
RS256и что ключ не короче 2048 бит, как сказано в разделе 7.
Шаг 5 пропускают чаще всего — и именно он экономит больше всего времени. Чистый эталонный ключ превращает расплывчатое «ключ не работает» в двоичный ответ о том, какая сторона сломана.
FAQ
В чём разница между BEGIN RSA PRIVATE KEY и BEGIN PRIVATE KEY?
Это два контейнера вокруг одного и того же ключа RSA. BEGIN RSA PRIVATE KEY — это PKCS#1, где числа RSA лежат напрямую; BEGIN PRIVATE KEY — это PKCS#8, который добавляет идентификатор алгоритма, поэтому умеет нести ещё и ключи ECDSA и Ed25519. Какой нужен именно вам, целиком определяет библиотека, а генератор ключей RSA пишет любой из двух.
Почему openssl genrsa выдаёт не тот формат, что показан в учебнике?
Потому что значение по умолчанию сместилось. На OpenSSL 3.6.2 команда openssl genrsa -out k.pem 2048 пишет PKCS#8 с заголовком BEGIN PRIVATE KEY. Чтобы получить традиционную раскладку PKCS#1 из старых руководств, добавьте -traditional. И запускайте head -1 на результате, вместо того чтобы верить учебнику на слово: что именно выдаёт ваша сборка, знает только она сама.
Как исправить algid parse error, not a sequence в Java?
На Java 1.8.0_162 это сообщение означает, что в PKCS8EncodedKeySpec передали ключ PKCS#1. Стандартная библиотека PKCS#1 не читает вообще. Сконвертируйте файл один раз командой openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem или подключите BouncyCastle, если он и так нужен проекту для чего-то ещё.
Обязательно ли каждой строке приватного ключа начинаться с нулевой колонки?
Нет, и распространённый совет говорит обратное. На Node v25.8.2 отступ только у строк тела base64 разбирается нормально, а отступ только у строки -----BEGIN или только у строки -----END даёт ошибку. Пустая строка перед PEM принимается, ведущий пробел — нет.
Как хранить приватный ключ в файле .env?
Либо одной строкой в кавычках с экранированием \n, которое разворачивают при загрузке через .replace(/\\n/g, '\n'), либо одной строкой base64, которую декодируют при старте. Второй вариант надёжнее: там нет соглашения об экранировании, которое загрузчик конфигурации мог бы понять неправильно.
Можно ли использовать 1024-битный ключ с RS256?
Node v25.8.2 разбирает 1024-битный ключ RSA без ошибок, но исходники jsonwebtoken отказываются им подписывать: secretOrPrivateKey has a minimum key size of 2048 bits, если не выставлен allowInsecureKeySizes. Сгенерируйте вместо него ключ на 2048 бит. Сообщение называет количество бит — по этому признаку его и отличают от проблемы с форматом.
Почему ошибка формата приватного ключа RS256 требует асимметричный ключ, хотя я передал файл приватного ключа?
В исходниках jsonwebtoken сообщение secretOrPrivateKey must be an asymmetric key when using ${header.alg} срабатывает, когда alg — это RS, PS или ES, а ключ не приватный. Обычно в значении лежит строка-секрет в стиле HS256, оставшаяся от прежней конфигурации. Случайная строка — это про HS256 и генератор JWT-секрета; для RS256 нужна пара ключей, а не секрет.
Заключение
Дорогим этот класс дефектов делает не сложность. Дорого выходит то, что одна строка ошибки покрывает на Node семь причин, что сообщение Java показывает на ASN.1, когда правильный ответ — «не тот контейнер», и что самый тиражируемый совет про форматирование вывернут наизнанку. Вчитаться до ответа тут нельзя, поэтому остаётся исключать по списку: строка заголовка, разбор через OpenSSL, матрица библиотек, невидимые символы, заведомо исправный ключ.
От повторения спасают две привычки. Рядом с ключом в хранилище секретов записывайте, какой контейнер требует каждый сервис: ограничение живёт в библиотеке, а не в ключе. И держите в среде разработки заведомо исправную пару ключей просто как контрольный образец, чтобы первый вопрос про любой сбой с ключом получал ответ «да» или «нет» за минуту.
О том, как эти ключи выпускать, ротировать и ограничивать по области действия, когда они уже нормально загружаются, читайте в статье «Безопасность JWT: лучшие практики, атаки и защита».