JWT 签名密钥是关键安全材料。如果攻击者获得对称密钥,可能为所有信任该密钥的验证方伪造看似有效的令牌;如果非对称私钥泄露,接受对应公钥的依赖方也会受到影响。应在本地或获批准的密钥管理流程中生成密钥,禁止进入源码和日志,并把验证设计成策略,而不是简单调用解码函数。
核心要点
- JWT 是声明容器;JWS 为签名表示提供完整性和签名方认证,JWE 才提供加密。Base64URL 是编码,不是保密。
- HMAC 密钥必须来自 CSPRNG。
32 字节不等于32 个字符;编码改变表示,不改变熵。 - 固定算法允许列表,并按信任契约校验
iss、aud、exp、nbf、iat、typ和业务声明。 - 有效签名只证明持有已配置的验证密钥,不证明主体已获准访问某租户、对象或操作。
- 将
kid、发行方元数据和 JWKS URL 视为不可信选择器,只从允许列表、固定来源和受监控渠道解析密钥;验证时不能抓取任意 URL。 - 轮换重叠期必须受令牌生命周期约束,并支持撤销或会话失效;记录密钥 ID 和校验结果,但不要记录令牌或密钥。
JWT、JWS 与加密
签名 JWT 通常使用 JWS 紧凑序列化:
base64url(header) + "." + base64url(payload) + "." + base64url(signature)
HS256 会对编码后的 header 和 payload 使用共享密钥计算 HMAC。正确配置验证后,签名可以发现篡改;但它不会隐藏载荷,也不会单独完成授权。
不要把 HMAC 密钥称为加密密钥。不要在令牌通过签名、发行方、受众、时间和策略校验前信任 role、tenant_id 或 scope。授权仍属于处理目标资源的服务。
算法与密钥选择
| 算法族 | 密钥材料 | 验证方分发 | 典型考量 |
|---|---|---|---|
| HS256/HS384/HS512 | 一个共享 HMAC 密钥 | 每个验证方都知道密钥 | 简单,但任何验证方也能签名 |
| RS256/PS256 | RSA 私钥/公钥 | 验证方只接收公钥 | 可隔离签名职责 |
| ES256 | EC 私钥/公钥 | 验证方只接收公钥 | 签名较小,依赖曲线和库支持 |
| EdDSA | Ed25519 或其他支持的 EdDSA 密钥 | 验证方只接收公钥 | 现代选择,但需确认全链路支持 |
应基于实际信任边界和库支持选择。HS256 切换到 RS256 不是直接的安全升级:密钥格式、验证配置、声明、部署和轮换都必须重新测试。
HMAC 密钥长度与熵
HMAC JWT 算法至少使用符合算法要求的随机材料:
| 算法 | 最少随机材料 |
|---|---|
| HS256 | 32 字节(256 位) |
| HS384 | 48 字节(384 位) |
| HS512 | 64 字节(512 位) |
这里指随机材料的字节数,不是手动输入 32 个字符的密码。十六进制会将一个字节编码为两个字符;无填充 Base64URL 大约每三个字节使用四个字符。不要使用人类密码、应用名称、UUID 文本、时间戳、可预测字符串的哈希或口令短语作为 HMAC 密钥。
在本地生成密钥
使用操作系统提供的 CSPRNG,避免把生产密钥输出到共享终端、CI 日志、Shell 历史、崩溃报告或聊天记录:
# HS256 使用 32 个随机字节;应通过批准的密钥流程保存。
openssl rand -base64 32
# HS512 使用 64 个随机字节。
openssl rand -base64 64
# 32 个随机字节的十六进制表示。
openssl rand -hex 32
Base64 可能包含 +、/ 和 =,密钥存储格式必须保留它们。无填充 Base64URL 更便于环境变量传输,但不会增加熵。
Node.js
import { randomBytes } from "node:crypto";
export function generateHmacSecret(byteLength = 32) {
if (![32, 48, 64].includes(byteLength)) {
throw new RangeError("use 32, 48, or 64 random bytes");
}
return randomBytes(byteLength).toString("base64url");
}
Python
import base64
import secrets
def generate_hmac_secret(byte_length: int = 32) -> str:
if byte_length not in {32, 48, 64}:
raise ValueError("use 32, 48, or 64 random bytes")
return base64.urlsafe_b64encode(secrets.token_bytes(byte_length)).rstrip(b"=").decode()
Go
package keygen
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
func GenerateHMACSecret(byteLength int) (string, error) {
if byteLength != 32 && byteLength != 48 && byteLength != 64 {
return "", fmt.Errorf("use 32, 48, or 64 random bytes")
}
key := make([]byte, byteLength)
if _, err := rand.Read(key); err != nil {
return "", fmt.Errorf("generate key: %w", err)
}
return base64.RawURLEncoding.EncodeToString(key), nil
}
这些示例只生成材料,不负责存储。应把结果直接交给获批准的密钥管理器或 KMS 流程,并防止自动化回显密钥。
验证策略
解码器不应根据令牌 header 自主决定信任。验证器应固定算法集合、预期发行方和受众:
import jwt from "jsonwebtoken";
export function verifyAccessToken(token, key) {
return jwt.verify(token, key, {
algorithms: ["HS256"],
issuer: "https://issuer.example",
audience: "api.example",
// 时钟容差应小且明确写入部署策略。
clockTolerance: 5,
});
}
具体选项随库不同。应在明确时钟偏差下验证 exp 和 nbf,要求契约需要的声明,并拒绝意外 token 类型。不要接受 alg: none,不要把 RSA 公钥当 HMAC 密钥,也不要允许令牌选择未受信任的验证算法。
通过密码学验证后,还要授权主体访问租户、对象、字段和操作。有效的 sub 或 scope 声明不能替代当前授权决策。
安全存储与用途隔离
在获批准的密钥管理器、KMS、HSM 或等价控制面中存储签名材料。只允许需要它的服务读取,审计访问,并隔离开发、测试、预发布和生产密钥。访问令牌、刷新令牌、邮件链接、密码重置和其他 MAC 用途使用不同密钥。
避免把密钥放入会提交到仓库的 .env、容器层、镜像、调试转储、遥测或浏览器 bundle。环境变量只有在部署平台保护其生命周期和访问控制时,才可作为注入方式。
轮换、kid 与撤销
轮换计划应明确状态:
- 让验证方获得新公钥,或为其配置新的对称密钥;
- 切换签名方使用新密钥,并写入稳定的
kid; - 在有限重叠期内同时验证两把密钥;
- 超过最大令牌生命周期、撤销事件或事故策略后停止接受旧密钥;
- 按保留要求移除并销毁旧密钥。
对称密钥轮换要求每个验证方同时获得两把密钥,爆炸半径更大。非对称方案应通过认证、允许列表和固定来源的 JWKS 或配置渠道发布公钥。kid 只是查找提示;未知 ID 应拒绝,必须防止密钥类型混淆,不能把它变成任意 URL 或文件路径。
JWT 往往是 bearer 凭据,可能直到过期前都有效。需要立即失效时,应维护服务端会话状态、令牌族、撤销列表或密钥级应急方案。轮换本身不是通用撤销机制。
常见错误
| 错误 | 风险 | 控制措施 |
|---|---|---|
| 使用短密码或应用名称 | 离线猜测和令牌伪造 | 使用 CSPRNG 生成随机字节 |
| 用字符数代替位数 | 实际熵不足 | 在编码前计算随机字节 |
信任令牌的 alg |
算法混淆或降级 | 服务端固定允许列表 |
根据 kid/iss 抓取任意 JWKS URL |
SSRF 和密钥替换 | 使用允许列表和固定元数据源 |
| 把 Base64URL 当加密 | 敏感声明暴露 | 最小化声明;有依据时设计 JWE |
| 所有用途/环境共用密钥 | 泄露影响范围扩大 | 分离密钥和访问策略 |
| 记录令牌或生成的密钥 | 凭据泄露 | 脱敏并限制审计数据 |
| 永久重试旧密钥 | 撤销令牌仍可接受 | 限制重叠期并执行过期/撤销 |
泄露响应
如果签名密钥可能泄露:
- 标记密钥已泄露并停止用它签发;
- 生成新密钥,通过受控渠道更新验证方;
- 拒绝已泄露的
kid,或撤销受影响会话; - 按需要使刷新令牌族和敏感会话失效;
- 调查源码、日志、CI、主机、依赖和访问记录;
- 保存证据,记录受影响发行方、受众、时间窗口和令牌类别。
不要声称换钥会删除已经复制的令牌,或撤销已经发生的外部副作用。事故控制和授权复核仍然必要。
常见问题
HS256、HS384 和 HS512 需要多少随机字节?
分别至少使用 32、48 和 64 个随机字节,并遵循算法和库要求。应计算 Base64 或十六进制编码前的熵;可打印字符长度不是同一指标。
可以使用 UUID 作为 HMAC 密钥吗?
不要把 UUID 文本当作默认 JWT 密钥。其熵和生成属性可能不满足算法要求,且可识别格式容易造成误用。应直接用 CSPRNG 生成密钥材料。
Base64 和 Hex 哪个更安全?
编码都不会增加安全性。两者都可以表示相同的随机字节,应选择密钥存储和配置链路能够完整保留、不截断且不错误转义的形式。
JWT 密钥应该多久轮换?
没有通用的 30 天或 90 天规则。应根据令牌生命周期、泄露检测、发行方规模、运维恢复、合规要求和重新认证成本确定。怀疑泄露时立即轮换。
签名 JWT 会加密吗?
不会。JWS 签名提供完整性和签名方密钥认证,载荷仍然可读。只有在单独设计 JWE 威胁模型和密钥生命周期后,才使用加密。
一手来源
- RFC 7515:JSON Web Signature
- RFC 7518:JSON Web Algorithms
- RFC 7519:JSON Web Token
- RFC 8725:JSON Web Token 最佳当前实践
- OWASP:JSON Web Token for Java Cheat Sheet
- NIST SP 800-57 Part 1 Rev. 5:密钥管理
总结
安全 JWT 的起点是正确生成和控制签名密钥,但远不止于此。应根据实际信任边界选择算法,固定策略并校验必要声明,把授权留在令牌之外,保护密钥全生命周期,并让轮换和泄露响应可测试。不要用在线生成器或方便的字符串替代有文档的密钥管理流程。