Skip to content
返回博客
安全

RS256 私钥格式报错:同一条错误,七种根因

RS256 私钥格式报错:容器选错、header 行缩进、公钥当私钥,Node 报的是同一条 DECODER 错误。PKCS#1 与 PKCS#8 实测排查表,附免费在线密钥生成器。

13 分钟

RS256 私钥格式报错:同一条错误,七种根因

RS256 私钥格式报错几乎从不说出自己的成因。在 Node v25.8.2 上,下面每一种错误都产出完全相同的一行:

code:    ERR_OSSL_UNSUPPORTED
message: error:1E08010C:DECODER routines::unsupported

触发它的是五件互不相干的事:本该放 PEM 密钥的地方放了 OpenSSH 容器(container)、-----BEGIN 那一行被缩进、把公钥递给了签名方、没人反转义的字面 \n 序列、以及文件在传输途中丢掉了换行。第 2 节里实测出来的清单一共七条。而每次都是同样这一行字,所以你拿错误文本去搜,只会掉进别人那条讲着别人根因的帖子里。

先把问题劈成两半:

第一种情况,三十秒自检:

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 cryptov25.8.2error:1E08010C:DECODER routines::unsupported
Go crypto/x509go1.26.1 darwin/arm64x509: failed to parse private key (use ParsePKCS1PrivateKey instead for this key format)
Java PKCS8EncodedKeySpec1.8.0_162InvalidKeySpecException: 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::unsupportedNode v25.8.2七种可能成因,见下方列表
error:07880109:common libcrypto routines::interrupted or cancelledNode 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 sequenceJava 1.8.0_162把 PKCS#1 喂给了 PKCS8EncodedKeySpec
secretOrPrivateKey must have a valuejsonwebtoken 源码key 参数是 falsy 值,且 alg 不是 none
secretOrPrivateKey is not valid key materialjsonwebtoken 源码私钥和对称密钥都构造不出来
secretOrPrivateKey must be a symmetric key when using ${header.alg}jsonwebtoken 源码algHS 开头,但这把 key 不是对称密钥
secretOrPrivateKey must be an asymmetric key when using ${header.alg}jsonwebtoken 源码alg 匹配 RSPSES,但这把 key 不是私钥
secretOrPrivateKey has a minimum key size of 2048 bits for ${header.alg}jsonwebtoken 源码RSPS,密钥不足 2048 位,且 allowInsecureKeySizes 没开

那五条 secretOrPrivateKey 字符串取自 jsonwebtoken master 分支的 sign.js,是读源码读来的,没有在本机跑过,所以触发条件请按「源码是这么写的」来理解,而不是「在这台机器上复现过」。其中的 ${header.alg} 是源码里的模板占位符,运行时你看到的是自己那个算法名,所以带着花括号去搜这条字面字符串,什么都搜不到。

触发 DECODER routines::unsupported 的七种方式

七种全部在 Node v25.8.2 的 crypto.createPrivateKey() 上复现过,七种给出的 code 与 message 完全一致:

  1. OpenSSH 容器。文件以 -----BEGIN OPENSSH PRIVATE KEY----- 开头,它根本不是 PEM 密钥结构。
  2. -----BEGIN 行被缩进,或者 -----END 行被缩进。正文行不在此列,精确边界见第 5 节。
  3. 整份 PEM 前面多了空白字符。前面多一个空没事,多一个空就完了。
  4. 换行被整个抹掉,header、base64 与 footer 挤在同一行上。
  5. 该给私钥的地方给了公钥。
  6. 字面的反斜杠加 n 没有被反转义,单行环境变量存密钥就是这个形态。
  7. 分隔线的短横数量不对,或者 beginend 写成了小写。

七条里两条是容器问题,四条是文本被搞坏,还有一条纯属拿错了东西。这条消息没法告诉你是哪一种,所以最快的路子是逐项排除,而不是盯着报错读。

反过来看 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_CANCELLEDerror: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#1PKCS#8OpenSSH报错是否自解释
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 返回的块 TypeEC 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 2048PKCS#8,header 是 BEGIN PRIVATE KEY
openssl genrsa -traditional -out k.pem 2048PKCS#1
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048PKCS#8
openssl genpkey -algorithm ED25519PKCS#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 属于 RSPS 系列、密钥不足 2048 位、且没开 allowInsecureKeySizes。这道检查是库自己加的,不是运行时的。Node v25.8.2 解析一把 1024 位的 RSA 密钥毫无怨言,modulusLength: 1024 产出的 key 对象和别的没有任何区别。于是就出现了这种局面:密钥结构合法、容器正确、OpenSSL 读得出来,签名调用还是失败。

判别的窍门是这条消息里有个数字。格式类报错谈的是解码器、sequence、key material,这一条谈的是位数。消息里出现尺寸,就别再盯着 PEM 看了。

1024 位密钥的来历通常是历史包袱:多年前按当时的默认值生成的一把密钥,或者一份因为小密钥生成得快而没人回头动过的测试固件。正确的修法是重新生成一对 2048 位或更长的密钥,而不是去够那个逃生开关,它关掉的是一道有存在理由的检查。

想确认尺寸是最后一个障碍,可以在 JWT 编码器与生成器 里用一把尺寸正确的新密钥去签同一份 payload。它签得出 token 而你的代码签不出来,那差异就在你的密钥上,不在你的 claim 或配置上。

8. 一条可复用的 RS256 密钥排查流程

按顺序跑下面这几步。每一步要么找到根因,要么砍掉一个分支。

  1. 读 header 行。 head -1 key.pem,再去对第 3 节那张表。这一步告诉你容器是什么、文件是不是加密的、以及它是不是一把永远也用不了的 OpenSSH 密钥。
  2. 让 OpenSSL 去解析它。 RSA 用 openssl rsa -in key.pem -noout -text | head -1,任意算法用 openssl pkey -in key.pem -noout。成功意味着这些字节是一把合法密钥,问题在库那一侧;失败意味着文件已经坏了,直接进第 4 步。
  3. 在矩阵里查你那个库的那一行。 第 4 节。如果你在 Java 上拿着 PKCS#1 文件,或者在 Go 上调错了解析函数,到这里就结束了。
  4. 去看那些看不见的字符。 head -c 32 key.pem | xxd 显示开头几个字节,一眼就能同时抓出 BOM、前导空格和被缩进的分隔线。然后按第 5 节确认 -----BEGIN-----END 两行都顶格。
  5. 拿一把已知可用的密钥做二分。RSA 密钥生成器 里生成一对新密钥,让代码指向它,看错误还在不在。还在,说明 bug 在你的加载代码里而不在密钥文件里,你把原文件重排多少遍都没用。消失了,说明原文件有问题,而且你现在手上有一把能用的密钥可以拿来做 diff。
  6. 算法和尺寸留到最后查。 确认 header 写的是 RS256,确认密钥至少 2048 位,见第 7 节。

第 5 步是最容易被跳过、也最省时间的一步。一把干净的参照密钥,能把含混的「这密钥不好使」变成一个关于「哪一侧坏了」的是非题。

常见问题

BEGIN RSA PRIVATE KEYBEGIN 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 属于 RSPSES 而 key 不是私钥时抛出的。通常那个值是早先某次配置留下来的 HS256 式密钥字符串。随机字符串属于 HS256 和 JWT 密钥生成器 的地盘,RS256 要的是密钥对,不是一个 secret。

结语

这类 bug 费时间,不是因为它难。同一条报错字符串在 Node 上覆盖七种成因,Java 的消息指向 ASN.1 而真正的答案是「容器搞错了」,这个话题下转载得最多的那条格式建议还恰好是反的。你读不出答案,只能靠排除:header 行、OpenSSL 解析、库矩阵、看不见的字符、已知可用的密钥。

两个习惯能防止它重演。一是把每个服务需要哪种容器写下来,就写在密钥存储里那把密钥的旁边,因为这个约束住在库里,不住在密钥里。二是在开发环境里常备一对已知可用的密钥,纯粹当对照组用,这样任何密钥故障的第一个问题,一分钟内就能有个是或否的答案。

至于这些密钥在能正常加载之后应该怎么签发、怎么轮换、怎么限定作用域,见 JWT 安全最佳实践:攻击手法与防御指南(2026)

标签: jwt rsa pem openssl debugging security