RS256 秘密鍵フォーマットエラー:同じ 1 行に 7 つの原因
RS256 の秘密鍵フォーマットエラーは、自分の原因をまず名乗らない。Node v25.8.2 では、次に挙げるどのミスを踏んでも、返ってくる行は完全に同一だ。
code: ERR_OSSL_UNSUPPORTED
message: error:1E08010C:DECODER routines::unsupported
引き金になるのは互いに無関係な 5 つの事柄である。PEM 鍵を期待している場所に置かれた OpenSSH コンテナ、インデントされた -----BEGIN 行、署名側に渡されてしまった公開鍵、誰もアンエスケープしなかった字面どおりの \n、そして転送の途中で改行を落とされたファイル。セクション 2 の実測リストでは 7 つまで増える。それでも返るのは毎回まったく同じ 1 行だ。エラー文字列で検索すると、他人の、しかも別の原因についてのスレッドに着地してしまうのはこのためである。
問題は 2 つに割れる。
- ライブラリが鍵オブジェクトをそもそも受け取れていない。本稿の担当範囲はこちら。
- ライブラリは鍵を読み込めたうえで
invalid signatureと言った。別の失敗、別の原因だ。JWTの「invalid signature」エラー:原因の特定と修正方法へ。
前者の 30 秒トリアージはこれだ。
openssl rsa -in key.pem -noout -text | head -1
これがエラーになるならファイルそのものが原因で、セクション 3 から 6 で正体が割れる。通るなら OpenSSL はコンテナを理解できたということで、疑うべきはライブラリか、そこへ渡した値のほうだ。つまりセクション 4 と 7 に進む。
以下の内容はすべて 2026-08-11 に、OpenSSL 3.6.2 7 Apr 2026、Node v25.8.2、Go go1.26.1 darwin/arm64、Java 1.8.0_162 で実測したものである。実行ではなくソースを読んで得た記述には、その旨を明記した。
1. まず、どちらの失敗かを見分ける
分かれ目は、鍵オブジェクトが一度でも存在したかどうかだ。
パース時の失敗は、暗号処理が走る前に起きる。ライブラリは PEM を読み、鍵に変換できずに例外を投げる。何も署名されていないし、何も検証されていない。いま追いかけているトークンは、そもそも生成されていない。検証時の失敗はその逆だ。鍵はきれいに読み込まれ、署名が計算され、それが一致しなかった。こちらは署名側と検証側のバイト単位の食い違いから生まれるもので、JWTの「invalid signature」エラー:原因の特定と修正方法が扱っている領域である。
見分けるにはスタックトレースを一度見れば足りる。パース時の失敗はデコーダー、鍵仕様、あるいは ASN.1 構造の名前を挙げる。検証時の失敗は署名の名前を挙げる。
秘密鍵が弾かれたとき、3 つのエコシステムでは次のように見える。
| ランタイム | 検証したバージョン | 鍵が読み込めないときのメッセージ |
|---|---|---|
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 | 原因は 7 通り。下に列挙する |
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 | PKCS#1 のファイルに ParsePKCS8PrivateKey を呼んだ |
x509: failed to parse private key (use ParsePKCS1PrivateKey instead for this key format) | Go 1.26.1 | PKCS#8 のファイルに ParsePKCS1PrivateKey を呼んだ |
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、ソース上 | 鍵の引数が falsy で、かつ alg が none ではない |
secretOrPrivateKey is not valid key material | jsonwebtoken、ソース上 | 秘密鍵も共通鍵も構築できなかった |
secretOrPrivateKey must be a symmetric key when using ${header.alg} | jsonwebtoken、ソース上 | alg が HS で始まるのに、鍵が secret ではない |
secretOrPrivateKey must be an asymmetric key when using ${header.alg} | jsonwebtoken、ソース上 | alg が RS/PS/ES に一致するのに、鍵が private ではない |
secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg} | jsonwebtoken、ソース上 | RS または PS で鍵長が 2048 ビット未満、かつ allowInsecureKeySizes が無効 |
secretOrPrivateKey の 5 本は jsonwebtoken の master ブランチの sign.js を読み取ったもので、ローカルで実行してはいない。よって発火条件は「ソースにそう書かれている」ものとして扱ってほしい。この環境で再現した事実ではない。${header.alg} はそのソース内のテンプレート変数であり、実行時にはそこへ自分のアルゴリズム名が入る。波括弧ごと検索しても何も出てこないのはそのためだ。
DECODER routines::unsupported を出す 7 通りの方法
7 つとも Node v25.8.2 の crypto.createPrivateKey() で再現し、7 つとも同じコードと同じメッセージを返した。
- OpenSSH コンテナ。ファイルが
-----BEGIN OPENSSH PRIVATE KEY-----で始まり、そもそも PEM 鍵の構造ではない。 -----BEGIN行、または-----END行がインデントされている。本文行は対象外で、正確な境界はセクション 5 にある。- PEM 全体の前に空白がある。先頭の空行は問題ないが、先頭の半角スペースは駄目だ。
- 改行が完全に取り除かれ、ヘッダーと base64 とフッターが 1 行に繋がっている。
- 秘密鍵が期待される場所に公開鍵を渡した。
- バックスラッシュ + n の 2 文字がエスケープされたまま残っている。1 行の環境変数に押し込んだ鍵はこうなる。
- 区切り線のダッシュの本数が違う、あるいは
begin/endが小文字で書かれている。
このうち 2 つはコンテナの問題、4 つはテキストが壊れた問題、1 つは単なる取り違えである。メッセージはどれなのかを教えてくれないので、読み解くより消去法のほうが速い。
Node が受け付けるもの
逆側のリストのほうが役に立つ。載っている項目は、その場で捨てられる仮説だからだ。Node v25.8.2 の crypto.createPrivateKey() は、次のすべてを文句なしに受け付けた。
- PKCS#1 と PKCS#8 の秘密鍵
- EC SEC1 の秘密鍵
- CRLF の改行コード
- 末尾の改行がない
- base64 本文が折り返されず 1 行になっている
- 本文行のインデント
- PEM の前の空行
- UTF-8 BOM。
'' + pemの形でも、0xEF 0xBB 0xBFで始まるBufferの形でも - PKCS#8 の本文に PKCS#1 のヘッダーが被せられたファイル
最後の 1 つは立ち止まる価値がある。デコーダーは base64 の中の DER 構造を読み、外側のラベルは無視する。だから BEGIN RSA PRIVATE KEY と書かれた PKCS#8 の中身のファイルでも、そのまま読み込まれてしまう。知っておくと便利であり、同時にセクション 3 への警告でもある。ヘッダー行はヒントであって保証ではない。
3. PEM のヘッダー行:手元にあるのはどのコンテナか
PEM はどれも 1 行目で自分の素性を名乗る。以下は 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-----、続いて 2 つめのブロック -----BEGIN EC PRIVATE KEY----- |
| SPKI 公開鍵 | -----BEGIN PUBLIC KEY----- |
| PKCS#1 公開鍵 | -----BEGIN RSA PUBLIC KEY----- |
| Ed25519 秘密鍵 | -----BEGIN PRIVATE KEY-----、そしてファイル全体が 3 行 |
だから調査の最初の問いには head -1 key.pem が答える。押さえておきたい細部が 3 つある。
ENCRYPTED PRIVATE KEY はフォーマットエラーではない。 渡し忘れたパスフレーズの話だ。Node はこれだけ別扱いで、ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED と error:07880109:common libcrypto routines::interrupted or cancelled を返す。ライブラリがパスフレーズを求め、何も返ってこなかったからである。DECODER のメッセージと混ぜて考えてはいけない。両者は互いに何の関係もない。
OPENSSH PRIVATE KEY は別世界だ。 OpenSSH は独自のコンテナを書き出す。PEM 風の区切り線に挟まれてはいるが、PKCS#1 でも PKCS#8 でもない。Node はこれを一切受け付けず、Go の crypto/x509 のパーサー群も、JDK の PKCS8EncodedKeySpec も同様である。JWT の署名鍵が ssh-keygen から出てきたものなら、それがバグだ。
EC SEC1 のファイルはブロックを 2 つ持つ。 openssl ecparam -genkey は先に EC PARAMETERS ブロックを、その後ろに秘密鍵を書く。最初の PEM ブロックしか読まない実装は、鍵ではなくパラメータを掴み、そのどちらにも触れない形で失敗する。この失敗の Go 版はセクション 4 にある。
そしてヘッダーが単なるラベルである以上、逆向きの確認も効いてくる。ヘッダーの表記と DER の中身が食い違うファイルは、DER のほうに従ってパースされる。head -1 を読む手は、OpenSSL からそのまま出てきたファイルには信頼できるが、人間やウィキのページ、文字列置換をするスクリプトを経由したファイルには信頼できない。
4. どのライブラリがどれを受け付けるか:3 つのエコシステムで見る PKCS#1 と PKCS#8
チーム間のフォーマット論争のほとんどは、この表で片が付く。各行は本稿冒頭のバージョンで実測した。
| ライブラリ | PKCS#1 | PKCS#8 | OpenSSH | エラーは自分で説明してくれるか |
|---|---|---|---|---|
Node crypto | 可 | 可 | 不可 | 説明しない。多数の原因に対して DECODER routines::unsupported 1 本 |
Go crypto/x509 | 可、専用関数あり | 可、専用関数あり | 不可 | する。切り替えるべき関数名を挙げてくれる |
| Java 標準ライブラリ | 不可 | 可 | 不可 | しない。algid parse error, not a sequence はむしろ誤解を招く |
列を読めば論争は自然に決着する。Node のサービスと Java のサービスが 1 つの鍵ファイルを共有していても、鍵が PKCS#1 になった瞬間から、Node は署名を続け、Java は ASN.1 のシーケンスについてのメッセージを投げる。誰も鍵を疑わない。もう一方のサービスでは本番で現に動いているからだ。
Node。 設定するものは何もない。コンテナが PKCS#1 か PKCS#8 なら createPrivateKey() は受け取る。それでも例外が飛ぶなら、フォーマットではなくセクション 2 の 7 つの原因に時間を使うべきだ。
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。 コンテナが 2 つ、関数が 2 つあり、間違ったほうを呼ぶのが 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 であり、3 つのパース関数はいずれもそこで失敗して次を返す。
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)
}
あるいは、ファイルを書き出す ecparam コマンドに -noout を足して、余計なブロックを作らせないという手もある。
Java。 標準ライブラリが読めるのは PKCS#8 だけだ。Java 1.8.0_162 で PKCS8EncodedKeySpec に PKCS#1 の鍵を食わせると、こうなる。
InvalidKeySpecException: java.security.InvalidKeyException: IOException : algid parse error, not a sequence
「algid」はアルゴリズム識別子、つまり PKCS#8 が付け足し PKCS#1 は持っていないフィールドのことである。パーサーはそれを探しに行き、RSA の法(modulus)の先頭を見つけて諦めた。メッセージは正確であると同時に、同じだけ役に立たない。ファイルを変換すればエラーは消える。
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));
変換する代わりに、PKCS#1 を読める BouncyCastle を導入する道もある。変換ならコマンド 1 本で依存も増えないので、スタックの別のところで既にそのライブラリが必要になっているのでなければ、変換を選ぶべきだ。
5. 目に見えない文字
世間に広まっているアドバイスがここで間違っており、しかもそれは実測で示せる。
インデント:教えられてきた話の逆
「PEM は区切り行を除いて全行が行頭から始まっていなければならない」という指示が広く出回っている。Node v25.8.2 で試すと、話は逆だ。
| ファイルへの変更 | 結果 |
|---|---|
| 全行をインデント | 失敗 |
-----BEGIN 行だけをインデント | 失敗 |
-----END 行だけをインデント | 失敗 |
| base64 の本文行だけをインデント | 受理 |
| PEM 全体の前に半角スペース | 失敗 |
| PEM 全体の前に空行 | 受理 |
つまり規則はこうだ。-----BEGIN と -----END の 2 行は行頭から始まっていなければならず、本文行のインデントは解析結果に影響しない。 字下げしてよいと教えられてきたまさにその 2 行が壊れる側で、揃えろと教えられてきた行のほうに余裕がある。
これが効いてくるのは、そもそも秘密鍵がインデントされてしまう経緯のためだ。PEM を手で字下げする者はいない。起きるのは、鍵を YAML のブロック、Helm の values ファイル、Terraform のヒアドキュメント、あるいはクラス本体の中にある Python の三重引用符文字列へ貼り付けたときである。どれも区切り行を含めて全体を一律に字下げする。表の 1 行目そのものだ。
1 行の環境変数が生む字面どおりのバックスラッシュ n
PEM には改行があり、環境変数には実際上それがない。だから鍵は .env ファイルへ、\n を 2 文字として書き下した 1 行として着地する。そのファイルを読む側はバックスラッシュ入りの文字列をコードに渡し、パーサーは区切り線の後ろにゴミを見ることになる。Node ではこれがセクション 2 の 6 番目の原因で、返るメッセージは他と同じ DECODER routines::unsupported だ。
使う場所で元に戻す。
const pem = process.env.PRIVATE_KEY.replace(/\\n/g, '\n');
この周りに置いておきたい防御が 2 つある。1 つめ、置換は文字列に実際にその 2 文字の並びが含まれているときだけ適用すること。別のローダーを通った本物の複数行の値なら、そもそも手を触れずに済む。2 つめ、プラットフォームが許すなら PEM 全体を base64 にするほうがよい。base64 を 1 行で保存して起動時にデコードすれば、エスケープの問題は最初から存在しない。
BOM:Node では無害、それ以外は未検証
バイトオーダーマークは EF BB BF の 3 バイトで、一部の Windows のエディタが UTF-8 ファイルの先頭に書き込む。鍵を読み込む前に取り除けというアドバイスはよく見かける。Node v25.8.2 では違いは出なかった。BOM を前置した PEM は、文字列として渡しても、その 3 バイトで始まる Buffer として渡しても、問題なくパースされた。
この結果の適用範囲は慎重に押さえてほしい。測定したのは Node v25.8.2 だけである。 Java や Python をはじめ他のパーサーは本稿では検証しておらず、それらの挙動について本稿は何も述べていない。Java のサービスをデバッグしているなら、BOM は「除外済み」ではなく未解決の問いのままだ。
BOM が他のものを壊すのは事実で、鍵に関するあのアドバイスもおそらくそこからの連想で生まれた。BOM 付き文字列に対する JSON.parse は現実に起きる、よく知られた失敗であり、UTF-8 BOM で JSON.parse が失敗する原因と削除方法で扱っている。JSON の設定ファイルの中に鍵を格納しているなら、誰かが鍵を見るはるか手前で失敗しうるということだ。
改行コード、末尾の改行、折り返し幅
Node v25.8.2 が容疑を晴らした項目がもう 3 つある。
- CRLF の改行コードは受理された。Windows を経由した鍵が、それだけで壊れるわけではない。
- 末尾の改行がなくても受理された。ただしこれはパーサー依存だという点に注意したい。末尾の改行を欠く PEM を拒むパーサーがあるとされており、Node はそこに含まれない。他のパーサーは本稿では検証していない。
- 本文が折り返されていなくても受理された。base64 を 64 文字で折る必要はない。
base64 の本文を壊すのは、文字が失われたり、混入したり、置き換わったりすることであって、折り返しとは別の失敗だ。改行を空白に変えてしまうチャットクライアントや、末尾の 1 文字を食う入力欄は、もはやデコードできない本文を生む。マウスでドラッグせず、コピーボタンでコピーすること。
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 へ変換 |
最初の 2 行をもう一度読んでほしい。このビルドでは 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
出力は 1 行、あとはセクション 3 の表が、いま手元にあるものの正体を教えてくれる。これを変換コマンドより先にやること。PKCS#8 のファイルを PKCS#8 に変換する操作は、修正のように見えて何も直さない空振りだからだ。
フラグのことを考えたくないなら、オンライン RSA 鍵ペア生成ツールが同じ鍵ペアからトグル 1 つで両方のコンテナを書き出す。1 つの鍵の PKCS#1 版と PKCS#8 版を作って、受け付けてくれないライブラリにそれぞれ試せる。
7. 何の問題もない鍵を弾く 2048 ビットの下限
フォーマットの問題に見えて、そうではない失敗が 1 つある。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 も読めるのに、署名の呼び出しだけが失敗する。
見分ける手掛かりは、このメッセージが数値を挙げている点だ。フォーマットのエラーはデコーダー、シーケンス、key material の話をする。こちらはビット数の話をする。メッセージにサイズが出ていたら、PEM を見るのはやめていい。
1024 ビットの鍵の出どころは、たいてい歴史である。当時のデフォルトに従って何年も前に生成された鍵か、小さい鍵は生成が速いという理由で誰も見直さなかったテスト用の固定データだ。直し方は、2048 ビット以上で鍵ペアを作り直すことだ。抜け道のフラグもあるにはあるが、それは理由があって存在する検査を黙らせるだけである。
残る問題がサイズだけだと確認するには、JWT エンコーダーで、正しいサイズの新しい鍵を使って同じペイロードに署名してみるとよい。そちらでトークンが出て自分のコードで出ないなら、違いはクレームでも設定でもなく鍵にある。
8. RS256 の鍵トラブルに使える再現可能な手順
上から順に実行する。各手順は原因を特定するか、分岐を 1 つ潰すかのどちらかだ。
- 先頭行を読む。
head -1 key.pemを実行し、セクション 3 の表と突き合わせる。コンテナが何か、ファイルが暗号化されているか、そして永遠に動かない OpenSSH 鍵ではないかが分かる。 - OpenSSL にパースさせる。 RSA なら
openssl rsa -in key.pem -noout -text | head -1、アルゴリズムを問わないならopenssl pkey -in key.pem -noout。成功すればバイト列は正しい鍵であり、問題はライブラリ側にある。失敗すればファイルが壊れているので手順 4 へ進む。 - マトリクスで自分のライブラリの行を確認する。 セクション 4 だ。Java で PKCS#1 のファイルを使っている、あるいは Go で間違ったパース関数を呼んでいるなら、ここで終わる。
- 目に見えない文字を見る。
head -c 32 key.pem | xxdが先頭のバイト列を表示し、BOM も先頭の空白もインデントされた区切り行も一目で捕まえられる。そのうえで、セクション 5 のとおり-----BEGINと-----ENDの行が行頭から始まっていることを確かめる。 - 既知の正しい鍵で二分する。 オンライン RSA 鍵ペア生成ツールで新しい鍵ペアを作り、コードの参照先をそちらへ向け、エラーが生き残るかどうかを見る。生き残るならバグは鍵ファイルではなく読み込み側のコードにあり、元のファイルをどれだけ整形し直しても解決しない。消えるなら元のファイルが原因で、しかも差分を取れる動く鍵が手元にできたことになる。
- アルゴリズムとサイズは最後に確認する。 ヘッダーが
RS256であること、そしてセクション 7 のとおり鍵が 2048 ビット以上であることを確かめる。
手順 5 は誰もが飛ばすが、いちばん時間を節約するのはこれだ。きれいな対照用の鍵が 1 つあれば、漠然とした「鍵が動かない」が、どちら側が壊れているかという二択の答えに変わる。
FAQ
BEGIN RSA PRIVATE KEY と BEGIN PRIVATE KEY は何が違うのか
同じ RSA 鍵を包む 2 種類のコンテナだ。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 は BEGIN PRIVATE KEY ヘッダーの PKCS#8 を書く。古い記事が説明している従来の PKCS#1 の形が欲しければ -traditional を足す。自分のビルドが何を出すかは、どのチュートリアルでもなく出力に対する head -1 に聞くこと。
Java の algid parse error, not a sequence はどう直すのか
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 エスケープ付きで引用符に包んだ 1 行にして、読み込み時に .replace(/\\n/g, '\n') で戻すか、base64 の 1 行にして起動時にデコードするかのどちらかだ。後者のほうが安全である。設定ローダーが取り違えうるエスケープの流儀が、そもそも存在しないからだ。
RS256 で 1024 ビットの鍵は使えるのか
Node v25.8.2 は 1024 ビットの RSA 鍵をエラーなくパースするが、jsonwebtoken のソースはそれでの署名を拒む。allowInsecureKeySizes を設定しない限り secretOrPrivateKey has a minimum key size of 2048 bits だ。2048 ビットの鍵を作り直すこと。メッセージがビット数を挙げているかどうかが、フォーマットの問題と見分ける目印になる。
秘密鍵ファイルを渡したのに、非対称鍵が必要という RS256 秘密鍵フォーマットエラーが出るのはなぜか
jsonwebtoken のソースでは、secretOrPrivateKey must be an asymmetric key when using ${header.alg} は alg が RS、PS、ES のいずれかで、かつ鍵が秘密鍵ではないときに発火する。たいていは、以前の設定から残った HS256 向けのシークレット文字列が値に入っている。ランダムな文字列の居場所は HS256 と JWT シークレット生成ツールであり、RS256 に必要なのはシークレットではなく鍵ペアだ。
まとめ
この種のバグが高くつく理由は、手掛かりが軒並み潰されている点にある。Node では 1 本のエラー文字列が 7 つの原因を覆い隠す。Java のメッセージは「コンテナ違い」という答えを差し置いて ASN.1 を指す。そしてこの話題で最も繰り返されている整形アドバイスが反転している。読んで答えに辿り着けない以上、消していくしかない。先頭行を読み、OpenSSL にパースさせ、ライブラリのマトリクスを引き、目に見えない文字を見て、最後に既知の正しい鍵と突き合わせる。
再発を防ぐ習慣は 2 つある。各サービスがどのコンテナを要求するかを、シークレットストアの鍵の隣に書き留めておくこと。その制約は鍵の側ではなくライブラリの側にあるからだ。そして開発環境に、対照実験のためだけの既知の正しい鍵ペアを 1 組置いておくこと。鍵の失敗についての最初の問いに、1 分で「はい」か「いいえ」が返るようになる。
読み込みが通るようになった鍵をどう発行し、どう更新し、どこまでの権限に絞るかは、JWT セキュリティのベストプラクティス:攻撃と防御(2026)で扱っている。