JWT 签名密钥是关键安全材料。如果攻击者获得对称密钥,可能为所有信任该密钥的验证方伪造看似有效的令牌;如果非对称私钥泄露,接受对应公钥的依赖方也会受到影响。应在本地或获批准的密钥管理流程中生成密钥,禁止进入源码和日志,并把验证设计成策略,而不是简单调用解码函数。

核心要点

  • JWT 是声明容器;JWS 为签名表示提供完整性和签名方认证,JWE 才提供加密。Base64URL 是编码,不是保密。
  • HMAC 密钥必须来自 CSPRNG。32 字节 不等于 32 个字符;编码改变表示,不改变熵。
  • 固定算法允许列表,并按信任契约校验 issaudexpnbfiattyp 和业务声明。
  • 有效签名只证明持有已配置的验证密钥,不证明主体已获准访问某租户、对象或操作。
  • kid、发行方元数据和 JWKS URL 视为不可信选择器,只从允许列表、固定来源和受监控渠道解析密钥;验证时不能抓取任意 URL。
  • 轮换重叠期必须受令牌生命周期约束,并支持撤销或会话失效;记录密钥 ID 和校验结果,但不要记录令牌或密钥。

JWT、JWS 与加密

签名 JWT 通常使用 JWS 紧凑序列化:

text
base64url(header) + "." + base64url(payload) + "." + base64url(signature)

HS256 会对编码后的 header 和 payload 使用共享密钥计算 HMAC。正确配置验证后,签名可以发现篡改;但它不会隐藏载荷,也不会单独完成授权。

不要把 HMAC 密钥称为加密密钥。不要在令牌通过签名、发行方、受众、时间和策略校验前信任 roletenant_idscope。授权仍属于处理目标资源的服务。

算法与密钥选择

算法族 密钥材料 验证方分发 典型考量
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 历史、崩溃报告或聊天记录:

bash
# HS256 使用 32 个随机字节;应通过批准的密钥流程保存。
openssl rand -base64 32

# HS512 使用 64 个随机字节。
openssl rand -base64 64

# 32 个随机字节的十六进制表示。
openssl rand -hex 32

Base64 可能包含 +/=,密钥存储格式必须保留它们。无填充 Base64URL 更便于环境变量传输,但不会增加熵。

Node.js

javascript
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

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

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 自主决定信任。验证器应固定算法集合、预期发行方和受众:

javascript
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,
  });
}

具体选项随库不同。应在明确时钟偏差下验证 expnbf,要求契约需要的声明,并拒绝意外 token 类型。不要接受 alg: none,不要把 RSA 公钥当 HMAC 密钥,也不要允许令牌选择未受信任的验证算法。

通过密码学验证后,还要授权主体访问租户、对象、字段和操作。有效的 subscope 声明不能替代当前授权决策。

安全存储与用途隔离

在获批准的密钥管理器、KMS、HSM 或等价控制面中存储签名材料。只允许需要它的服务读取,审计访问,并隔离开发、测试、预发布和生产密钥。访问令牌、刷新令牌、邮件链接、密码重置和其他 MAC 用途使用不同密钥。

避免把密钥放入会提交到仓库的 .env、容器层、镜像、调试转储、遥测或浏览器 bundle。环境变量只有在部署平台保护其生命周期和访问控制时,才可作为注入方式。

轮换、kid 与撤销

轮换计划应明确状态:

  1. 让验证方获得新公钥,或为其配置新的对称密钥;
  2. 切换签名方使用新密钥,并写入稳定的 kid
  3. 在有限重叠期内同时验证两把密钥;
  4. 超过最大令牌生命周期、撤销事件或事故策略后停止接受旧密钥;
  5. 按保留要求移除并销毁旧密钥。

对称密钥轮换要求每个验证方同时获得两把密钥,爆炸半径更大。非对称方案应通过认证、允许列表和固定来源的 JWKS 或配置渠道发布公钥。kid 只是查找提示;未知 ID 应拒绝,必须防止密钥类型混淆,不能把它变成任意 URL 或文件路径。

JWT 往往是 bearer 凭据,可能直到过期前都有效。需要立即失效时,应维护服务端会话状态、令牌族、撤销列表或密钥级应急方案。轮换本身不是通用撤销机制。

常见错误

错误 风险 控制措施
使用短密码或应用名称 离线猜测和令牌伪造 使用 CSPRNG 生成随机字节
用字符数代替位数 实际熵不足 在编码前计算随机字节
信任令牌的 alg 算法混淆或降级 服务端固定允许列表
根据 kid/iss 抓取任意 JWKS URL SSRF 和密钥替换 使用允许列表和固定元数据源
把 Base64URL 当加密 敏感声明暴露 最小化声明;有依据时设计 JWE
所有用途/环境共用密钥 泄露影响范围扩大 分离密钥和访问策略
记录令牌或生成的密钥 凭据泄露 脱敏并限制审计数据
永久重试旧密钥 撤销令牌仍可接受 限制重叠期并执行过期/撤销

泄露响应

如果签名密钥可能泄露:

  1. 标记密钥已泄露并停止用它签发;
  2. 生成新密钥,通过受控渠道更新验证方;
  3. 拒绝已泄露的 kid,或撤销受影响会话;
  4. 按需要使刷新令牌族和敏感会话失效;
  5. 调查源码、日志、CI、主机、依赖和访问记录;
  6. 保存证据,记录受影响发行方、受众、时间窗口和令牌类别。

不要声称换钥会删除已经复制的令牌,或撤销已经发生的外部副作用。事故控制和授权复核仍然必要。

常见问题

HS256、HS384 和 HS512 需要多少随机字节?

分别至少使用 32、48 和 64 个随机字节,并遵循算法和库要求。应计算 Base64 或十六进制编码前的熵;可打印字符长度不是同一指标。

可以使用 UUID 作为 HMAC 密钥吗?

不要把 UUID 文本当作默认 JWT 密钥。其熵和生成属性可能不满足算法要求,且可识别格式容易造成误用。应直接用 CSPRNG 生成密钥材料。

Base64 和 Hex 哪个更安全?

编码都不会增加安全性。两者都可以表示相同的随机字节,应选择密钥存储和配置链路能够完整保留、不截断且不错误转义的形式。

JWT 密钥应该多久轮换?

没有通用的 30 天或 90 天规则。应根据令牌生命周期、泄露检测、发行方规模、运维恢复、合规要求和重新认证成本确定。怀疑泄露时立即轮换。

签名 JWT 会加密吗?

不会。JWS 签名提供完整性和签名方密钥认证,载荷仍然可读。只有在单独设计 JWE 威胁模型和密钥生命周期后,才使用加密。

一手来源

总结

安全 JWT 的起点是正确生成和控制签名密钥,但远不止于此。应根据实际信任边界选择算法,固定策略并校验必要声明,把授权留在令牌之外,保护密钥全生命周期,并让轮换和泄露响应可测试。不要用在线生成器或方便的字符串替代有文档的密钥管理流程。