RS256 私钥格式报错:同一条错误,七种根因
RS256 私钥格式报错几乎从不说出自己的成因。在 Node v25.8.2 上,下面每一种错误都产出完全相同的一行:
code: ERR_OSSL_UNSUPPORTED
message: error:1E08010C:DECODER routines::unsupported
触发它的是五件互不相干的事:本该放 PEM 密钥的地方放了 OpenSSH 容器(container)、-----BEGIN 那一行被缩进、把公钥递给了签名方、没人反转义的字面 \n 序列、以及文件在传输途中丢掉了换行。第 2 节里实测出来的清单一共七条。而每次都是同样这一行字,所以你拿错误文本去搜,只会掉进别人那条讲着别人根因的帖子里。
先把问题劈成两半:
- 库根本没拿到 key 对象。 那就留在本篇。
- 库把密钥加载进去了,然后报
invalid signature。 那是另一种失败、另一批成因,去看 JWT 报 invalid signature:逐个排查真正的原因。
第一种情况,三十秒自检:
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. 先分清你碰上的是哪一类失败
分界线只有一条:key 对象到底有没有存在过。
解析期(parse-time)失败发生在任何密码学运算之前。库读你的 PEM,没能把它变成一把密钥,于是抛错。什么都没签,什么都没验,你正在调试的那个 token 压根就没生成出来。验证期(verify-time)失败正好相反:密钥干净地加载了,签名也算出来了,只是对不上。这类问题来自签发端与校验端在字节层面的分歧,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 则什么有用的都没说。
如果你连手上这个 token 是不是 RS256 都不确定,先把它贴进 JWT 解码器,读一下 header 里的 alg 再往下走。header 写着 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 | 你对 PKCS#8 文件调用了 ParsePKCS1PrivateKey |
x509: failed to parse private key (use ParsePKCS1PrivateKey instead for this key format) | Go 1.26.1 | 你对 PKCS#1 文件调用了 ParsePKCS8PrivateKey |
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 源码 | key 参数是 falsy 值,且 alg 不是 none |
secretOrPrivateKey is not valid key material | jsonwebtoken 源码 | 私钥和对称密钥都构造不出来 |
secretOrPrivateKey must be a symmetric key when using ${header.alg} | jsonwebtoken 源码 | alg 以 HS 开头,但这把 key 不是对称密钥 |
secretOrPrivateKey must be an asymmetric key when using ${header.alg} | jsonwebtoken 源码 | alg 匹配 RS、PS 或 ES,但这把 key 不是私钥 |
secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg} | jsonwebtoken 源码 | RS 或 PS,密钥不足 2048 位,且 allowInsecureKeySizes 没开 |
那五条 secretOrPrivateKey 字符串取自 jsonwebtoken master 分支的 sign.js,是读源码读来的,没有在本机跑过,所以触发条件请按「源码是这么写的」来理解,而不是「在这台机器上复现过」。其中的 ${header.alg} 是源码里的模板占位符,运行时你看到的是自己那个算法名,所以带着花括号去搜这条字面字符串,什么都搜不到。
触发 DECODER routines::unsupported 的七种方式
七种全部在 Node v25.8.2 的 crypto.createPrivateKey() 上复现过,七种给出的 code 与 message 完全一致:
- OpenSSH 容器。文件以
-----BEGIN OPENSSH PRIVATE KEY-----开头,它根本不是 PEM 密钥结构。 -----BEGIN行被缩进,或者-----END行被缩进。正文行不在此列,精确边界见第 5 节。- 整份 PEM 前面多了空白字符。前面多一个空行没事,多一个空格就完了。
- 换行被整个抹掉,header、base64 与 footer 挤在同一行上。
- 该给私钥的地方给了公钥。
- 字面的反斜杠加 n 没有被反转义,单行环境变量存密钥就是这个形态。
- 分隔线的短横数量不对,或者
begin/end写成了小写。
七条里两条是容器问题,四条是文本被搞坏,还有一条纯属拿错了东西。这条消息没法告诉你是哪一种,所以最快的路子是逐项排除,而不是盯着报错读。
反过来看 Node 接受什么,搜索范围收得更快
反向清单其实更有用,因为上面每一条都是你可以立刻扔掉的假设。在 Node v25.8.2 上,crypto.createPrivateKey() 对下面这些照单全收,一声不吭:
- PKCS#1 与 PKCS#8 私钥
- EC SEC1 私钥
- CRLF 换行
- 没有结尾换行
- base64 正文不折行、挤在一行上
- 正文行带缩进
- PEM 前面有一个空行
- UTF-8 BOM,
'' + pem和以0xEF 0xBB 0xBF开头的Buffer两种形式都行 - PKCS#1 的 header 套在 PKCS#8 的正文上
最后那一条尤其反直觉。解码器读的是 base64 里面的 DER 结构,压根不看外面那张标签,所以一份写着 BEGIN RSA PRIVATE KEY 却装着 PKCS#8 内容的文件照样能加载。这个知识点顺带也是对第 3 节的预警:header 行是提示,不是保证。
3. PEM header 行:你手上到底是哪种容器
每份 PEM 都在第一行自报家门。下面这些是 OpenSSL 3.6.2 写出来的 header 值:
| 内容 | 首行 |
|---|---|
| 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 用的是自己那套容器,虽然外面套着看起来像 PEM 的分隔线,里面既不是 PKCS#1 也不是 PKCS#8。Node 直接拒收,Go 的 crypto/x509 解析函数和 JDK 的 PKCS8EncodedKeySpec 同样拒收。如果你的 JWT 签名密钥是 ssh-keygen 生出来的,bug 就在这儿。
EC SEC1 文件里有两个块。 openssl ecparam -genkey 先写一个 EC PARAMETERS 块,私钥排在第二个。任何只读第一个 PEM 块的代码拿到的都是参数,而失败信息里对此只字不提。第 4 节有这个失败的 Go 版本。
还有,既然 header 只是一张标签,反向的检查同样成立:header 说一套、DER 是另一套的文件,最终按 DER 解析。head -1 对刚从 OpenSSL 里出来的文件是可靠的,对经手过人、经过 wiki 页面、或者被某个做字符串替换的脚本处理过的文件则不可靠。
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 sequence 的消息。没人会怀疑密钥,因为它在另一个服务的生产环境里明摆着能用。
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 分支打印出来的 code 与 message 组合就能拿去第 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)
}
或者干脆别产出这个多余的块,写文件的那条 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 模数的开头,于是放弃。这条消息说得没错,也没有一点用。把文件转一下,错误就消失:
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。转换只要一条命令、不加任何依赖,所以除非你的技术栈里已经有别的地方需要这个库,否则就转文件。
5. 看不见的那些字符
流行建议在这里是错的,而且错得可以当场验证。
缩进:出问题的是分隔线,不是正文行
有一条被反复转载的说法:PEM 里除了首尾两行标记之外,每一行都必须顶格。在 Node v25.8.2 上实测,这话是反的:
| 对文件做的改动 | 结果 |
|---|---|
| 每一行都缩进 | 失败 |
只缩进 -----BEGIN 行 | 失败 |
只缩进 -----END 行 | 失败 |
| 只缩进 base64 正文行 | 接受 |
| 整份 PEM 前面加一个空格 | 失败 |
| 整份 PEM 前面加一个空行 | 接受 |
所以规则应该这么写:-----BEGIN 和 -----END 两行必须顶格,正文行缩不缩进无关紧要。 恰恰是那条说法放行的首尾两行会崩,而它要求对齐的正文行反而有余量。
这条之所以要紧,是因为没人会一行行手工去缩一份 PEM。缩进出现在别的时候:你把密钥粘进 YAML 块、Helm 的 values 文件、Terraform 的 heredoc,或者某个类体里的 Python 三引号字符串。这几种全都会把整块内容连同分隔线一起统一缩进,也就是上表的第一行。
单行环境变量带来的字面反斜杠 n
PEM 有换行,而环境变量在实践中没有。于是密钥落进 .env 文件时成了一行,\n 被写成了两个字符。加载它的那个库把一个带反斜杠的字符串交给你的代码,解析器看到的就是一条分隔线后面跟着一堆乱码。在 Node 上这是第 2 节的第 6 种成因,报的还是那条和别的一模一样的 DECODER routines::unsupported。
在用到它的地方还原回去:
const pem = process.env.PRIVATE_KEY.replace(/\\n/g, '\n');
围绕这一行值得加两道保险。第一,只在字符串真的含有那两个字符的序列时才做替换,这样从别的 loader 传进来的真多行值不会受影响。第二,如果你的平台允许,整份 PEM 直接用 base64 存:存一行 base64,启动时解码,转义问题从根上就不存在了。
BOM:Node 上无害,别处未测
字节序标记(BOM)是三个字节 EF BB BF,某些 Windows 编辑器会写在 UTF-8 文件开头。「加载密钥前先把它剥掉」是很常见的建议。在 Node v25.8.2 上它没有任何影响:带 BOM 前缀的 PEM,无论作为字符串还是作为以这三个字节开头的 Buffer,都成功解析。
这个结论的适用范围要划清楚。它只在 Node v25.8.2 上量过。 Java、Python 以及其他解析器本文没有测,也不对它们的行为下任何结论。如果你在调 Java 服务,BOM 对你来说是个悬而未决的问题,而不是已经被排除的问题。
BOM 确实会搞坏别的东西,那条密钥建议多半就是这么联想出来的。对带 BOM 前缀的字符串调 JSON.parse 是一个真实且有据可查的失败,UTF-8 BOM 头:修复 JSON 解析报错与 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,header 是 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,而 -traditional 才是产出 BEGIN RSA PRIVATE KEY 文件的那个开关。大量教程至今仍把 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 产出的 key 对象和别的没有任何区别。于是就出现了这种局面:密钥结构合法、容器正确、OpenSSL 读得出来,签名调用还是失败。
判别的窍门是这条消息里有个数字。格式类报错谈的是解码器、sequence、key material,这一条谈的是位数。消息里出现尺寸,就别再盯着 PEM 看了。
1024 位密钥的来历通常是历史包袱:多年前按当时的默认值生成的一把密钥,或者一份因为小密钥生成得快而没人回头动过的测试固件。正确的修法是重新生成一对 2048 位或更长的密钥,而不是去够那个逃生开关,它关掉的是一道有存在理由的检查。
想确认尺寸是最后一个障碍,可以在 JWT 编码器与生成器 里用一把尺寸正确的新密钥去签同一份 payload。它签得出 token 而你的代码签不出来,那差异就在你的密钥上,不在你的 claim 或配置上。
8. 一条可复用的 RS256 密钥排查流程
按顺序跑下面这几步。每一步要么找到根因,要么砍掉一个分支。
- 读 header 行。
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 密钥生成器 里生成一对新密钥,让代码指向它,看错误还在不在。还在,说明 bug 在你的加载代码里而不在密钥文件里,你把原文件重排多少遍都没用。消失了,说明原文件有问题,而且你现在手上有一把能用的密钥可以拿来做 diff。
- 算法和尺寸留到最后查。 确认 header 写的是
RS256,确认密钥至少 2048 位,见第 7 节。
第 5 步是最容易被跳过、也最省时间的一步。一把干净的参照密钥,能把含混的「这密钥不好使」变成一个关于「哪一侧坏了」的是非题。
常见问题
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 写出来的是带 BEGIN PRIVATE KEY header 的 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 用转义形式,加载时用 .replace(/\\n/g, '\n') 还原;要么写成一行 base64,启动时解码。第二种更稳,因为它压根没有转义约定,配置加载器也就无从搞错。
RS256 可以用 1024 位的密钥吗?
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 而 key 不是私钥时抛出的。通常那个值是早先某次配置留下来的 HS256 式密钥字符串。随机字符串属于 HS256 和 JWT 密钥生成器 的地盘,RS256 要的是密钥对,不是一个 secret。
结语
这类 bug 费时间,不是因为它难。同一条报错字符串在 Node 上覆盖七种成因,Java 的消息指向 ASN.1 而真正的答案是「容器搞错了」,这个话题下转载得最多的那条格式建议还恰好是反的。你读不出答案,只能靠排除:header 行、OpenSSL 解析、库矩阵、看不见的字符、已知可用的密钥。
两个习惯能防止它重演。一是把每个服务需要哪种容器写下来,就写在密钥存储里那把密钥的旁边,因为这个约束住在库里,不住在密钥里。二是在开发环境里常备一对已知可用的密钥,纯粹当对照组用,这样任何密钥故障的第一个问题,一分钟内就能有个是或否的答案。
至于这些密钥在能正常加载之后应该怎么签发、怎么轮换、怎么限定作用域,见 JWT 安全最佳实践:攻击手法与防御指南(2026)。