几乎所有现代身份系统——OIDC 的 id_token、OAuth 2.1 的
access_token、服务间调用的 bearer
token、甚至前端保存的会话凭据——都在使用 JWT。但 “JWT”
这个词在不同上下文里往往指向不同的东西:有时是 RFC 7519
定义的 Claims 集合,有时是 RFC 7515 定义的签名结构
JWS,极少数情况下还指 RFC 7516 定义的加密结构
JWE。围绕它们的还有 RFC 7517 的 JWK 密钥表示,以及事实标准
JWKS
端点。这五样东西之间的关系,不少团队在落地时都没有完全理清,由此产生了诸如
alg=none 攻击、RS256/HS256 算法混淆、JKU 注入
SSRF、kid 注入
SQL、密钥无法热轮换等一系列真实存在的线上事故。
本文不是一篇”JWT 入门”。站内已有一篇偏基础的 JWT 基础介绍,以及一篇讨论整体 认证架构 的长文,读者可以先翻那两篇建立背景。这一篇专注于”工程与安全”这两层:标准之间的边界到底在哪里、字段为什么这样设计、每种签名算法在生产里究竟怎么选、攻击者会从哪些角度打穿你的校验逻辑、JWKS 与密钥轮换如何做到零停机、JWE 什么时候才真的需要上、以及 exp/clock skew/header 尺寸这些运维细节。
一、JWT、JWS、JWE、JWK、JWKS:五个规范的关系
把 JWT 相关的 RFC 摆在一起看,才能理解各自的职责边界:
| 规范 | 编号 | 定义了什么 |
|---|---|---|
| JWS(JSON Web Signature) | RFC 7515 | 带签名的数据容器,三段式
Header.Payload.Signature |
| JWE(JSON Web Encryption) | RFC 7516 | 加密的数据容器,五段式
Header.EncKey.IV.Ciphertext.Tag |
| JWK(JSON Web Key) | RFC 7517 | 公钥或对称密钥的 JSON 表示 |
| JWA(JSON Web Algorithms) | RFC 7518 | 注册了
alg、enc、kty
的取值 |
| JWT(JSON Web Token) | RFC 7519 | 一组 Claims(JSON 对象),外面包 JWS 或 JWE |
严格来说,“JWT” 只是 Claims 的 JSON 表示。平时我们看到的
eyJhbGciOi... 三段串,其载荷是 Claims、外壳是
JWS,完整称呼叫 “Signed JWT”。如果外壳换成 JWE,就是
“Encrypted JWT”。业界常说的 JWKS(JSON Web Key
Set)指的是一个 JWK Set 文档,RFC 7517 §5 定义了
{"keys":[...]} 这种 JSON 结构;而 OIDC
Discovery 再约定用 jwks_uri
去暴露这份文档。
1.1 为什么需要 “容器与载荷分离” 的设计
JWS/JWE 是通用的容器,载荷可以是任意字节。把 JWT Claims
放进 JWS,就得到了我们熟悉的令牌;把
CBOR、二进制协议缓冲区放进
JWS,也能用。这种分层的好处是:令牌格式的规范(iss/sub/aud/exp
的语义)和加密学的规范(签名如何计算、密钥如何表示)解耦。升级签名算法不影响
Claims 的约定,增加新 Claim 也不用改密码学层。
1.2 JWS 与 JWE 的本质区别
JWS 保护的是完整性与真实性:谁都能读,但改一个字节就会验签失败。JWE 保护的是机密性:没有解密密钥就读不到内容。二者完全不同,很多团队混淆了”签名后别人是不是能看到”的问题——答案是,Base64URL 不是加密,任何拿到 JWS 的人都能看到 payload。如果 Claims 里有敏感字段(手机号、邮箱、业务密级数据),用 JWS 是不行的,要上 JWE 或者干脆不要放进 token。
1.3 紧凑序列化 vs JSON 序列化
RFC 7515 其实定义了两种序列化:Compact Serialization(紧凑,三段点号分隔)和 JSON Serialization(每个部分是 JSON 对象字段,可以带多个签名)。OIDC、OAuth 场景几乎只用紧凑序列化,因为它能塞进 HTTP Header 和 URL。JSON 序列化在极少数多签名场景(例如 W3C Verifiable Credentials 的早期版本)会用,工程上 99% 的情况忽略它即可。
二、Header、Payload、Signature 字段细节
2.1 Header 必备字段
Header 是一个 JSON 对象,Base64URL 编码后作为第一段。关键字段:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2024-01"
}alg:签名算法,取值来自 JWA(RFC 7518)。必须与后端预期一致,决不能信任 header 声称的算法。typ:令牌类型,OIDC id_token 固定JWT;RFC 8725 推荐按照使用场景使用更具体的类型(例如 access_token 用at+jwt),避免跨场景混用。kid:key id,用于 JWKS 路由。不是 Claim——它在 Header,而不是 Payload。jku、x5u、jwk、x5c:直接内嵌或指向密钥,生产环境几乎永远不该信任,下文攻击章节会展开。cty:content type,当 payload 不是 JSON Claims 时使用。
2.2 Payload 中的标准 Claims
RFC 7519 注册了七个标准 Claims:
| Claim | 含义 | 取值 |
|---|---|---|
iss |
Issuer,签发者 | URL,必须与 JWKS 发现地址的发行者一致 |
sub |
Subject,主体 | 稳定的用户或服务标识 |
aud |
Audience,受众 | 字符串或字符串数组,校验方必须在其中 |
exp |
Expiration,过期时间 | NumericDate(Unix 秒) |
nbf |
Not Before,生效时间 | NumericDate,早于该时间拒绝 |
iat |
Issued At,签发时间 | NumericDate |
jti |
JWT ID,唯一标识 | 字符串,用于防重放与吊销 |
几个常被忽略的细节:
aud可以是字符串也可以是数组,校验时需要兼容两种形式。单字符串时,直接做等值比较;数组时,期望的受众必须出现在数组里。exp、nbf、iat是 NumericDate,即 Unix 秒(不是毫秒),用浮点表示也合法(RFC 7519 §2)。校验时务必用int或float64,不要用 JS 的Date直接比较毫秒。jti是实现”一次性 token”或黑名单的钩子,没做吊销体系的话可以不要,但要做吊销就必须有。sub的稳定性比可读性更重要。用邮箱当 sub 会在用户改邮箱时把系统搞烂,建议用不可变的内部 user id。
2.3 Signature 计算
对
Base64URL(Header) + "." + Base64URL(Payload)
这个字符串做 MAC 或签名,把结果 Base64URL
编码作为第三段。注意:
- 签名的输入是原始字符串而不是它们解码后的字节。原因是 JSON 对象的序列化没有唯一表示(字段顺序、空格都可能不同),对原始 base64 字符串签名能保证双方算出的摘要一致。
- HMAC 类算法的签名与对称密钥长度有硬性要求:HS256 密钥至少 256 bit,HS384 至少 384 bit,HS512 至少 512 bit。用短密钥签署 HS256 是一个常见初学者错误。
- RSA/ECDSA 的签名长度是定值,Ed25519 是 64 字节固定长度。
三、签名算法选型
JWA 注册了一长串 alg
取值,生产上真正值得用的只有四类:HS256、RS256、ES256、EdDSA。下面逐一分析它们的性能、密钥长度、适用场景和典型坑点。
3.1 HS256(HMAC-SHA256):共享密钥的双刃剑
HS256 的签名是 HMAC-SHA256,密钥是对称的,意味着能验签的主体也能签发。这个性质决定了它只适合单一信任域:比如同一个服务自签自验 session token、一组完全同构的网关共享密钥。一旦需要把 token 发给第三方、或者多租户场景下需要区分签发者,HS256 立刻就不合适——你不能把对称密钥发给客户端,否则客户端能伪造任何 token。
性能上 HS256 是四种算法里最快的,签名和验签都是微秒量级(现代 CPU 单核百万 ops/s)。令牌尺寸也小,签名 32 字节(Base64 后约 43 字符)。
坑点:
- 密钥长度不足:
Bearer文档里贴个"secret"做密钥,实际上只有 6 字节。HS256 要求至少 32 字节,低于这个长度的库会报错(比如 jose、jjwt),有些库不会——不报错的更危险。 - 密钥入仓库:把 HS256 密钥写在代码里、配置文件里、Docker 镜像 env 里都是灾难。轮换一次就得重新部署所有服务。
- 与 RS256 混用的算法混淆漏洞:见第四章。
3.2 RS256(RSASSA-PKCS1-v1_5 with SHA-256):最通用的非对称方案
RS256 用 RSA 私钥签名、公钥验签。公钥可以放在 JWKS 端点,所有下游都能离线验签,私钥只在签发方持有。OIDC、大部分企业 IdP 默认都是 RS256。
性能上 RS256 的签名很慢(2048 bit 下约 1-2 ms 每次,4096 bit 下 5-10 ms),验签相对快(零点几毫秒)。但它的令牌尺寸大:2048 bit 密钥的签名是 256 字节,Base64URL 后 342 字符;4096 bit 是 684 字符。跨站带 cookie、经过多个反代时很容易踩到 header size 上限。
坑点:
- PKCS1-v1_5 是传统填充,PSS 是更现代的选择。JWA 也有 PS256(RSASSA-PSS)。新项目如果必须 RSA,优先选 PS256。
- 密钥长度建议至少 2048 bit,高安全场景 3072 或 4096 bit。RSA-1024 不要再用了。
- 和 HS256 的算法混淆:攻击者把 header
alg改成 HS256,用你公开的 RSA 公钥(PEM 字符串)作为 HMAC 密钥伪造签名——见第四章。
3.3 ES256(ECDSA using P-256 and SHA-256):RSA 的现代替代
ECDSA 基于椭圆曲线,密钥短、签名快、令牌小。P-256 曲线的签名是 64 字节(r||s 各 32 字节),Base64URL 后 86 字符,比 RS256 小四倍。
性能上 ES256 的签名比 RSA-2048 快一个数量级(几十微秒),验签比 RSA 稍慢(约 0.5-1 ms)——这个权衡恰好匹配”IdP 集中签发、大量服务验签”的场景:签发端吞吐更高。
坑点:
- ECDSA 对随机源极其敏感:如果同一密钥用相同的 nonce 签了两条不同消息,私钥会被直接算出来(Sony PS3 事故的原因)。库必须用 RFC 6979 的确定性 nonce,或保证高质量随机源。用户不需要自己处理,但不要自己实现 ECDSA。
- JWA 规定 ES256 的签名是
r || s拼接(固定 64 字节),不是 ASN.1 DER 编码。某些库(openssl 命令行)默认输出 DER,要手动转换,否则验签失败。 - P-256 是最常用的曲线;P-384(ES384)、P-521(ES512)用得少。ES256K(secp256k1)是 RFC 8812 为区块链场景新增的,普通业务不用。
3.4 EdDSA(Ed25519):新项目的首选
EdDSA 基于 Edwards 曲线,性能比 ECDSA 还要好:签名、验签都在几十微秒,签名 64 字节,密钥 32 字节,都是定长,没有 ASN.1 的坑。更关键的是 Ed25519 不依赖随机源——它用消息哈希派生 nonce,天然免疫 ECDSA 的 nonce 复用攻击。
坑点基本没有,真要挑毛病的话:
- RFC 8037 定义了
alg: EdDSA,crv: Ed25519。但并不是所有库都支持——Java 在 15 之前需要 BouncyCastle,Node.js 12 之前也不行,部分老版本的 OIDC IdP(例如 2022 年之前的 Keycloak)也不支持。落地前确认整个调用链的库版本。 - 硬件安全模块(HSM)对 Ed25519 的支持比 RSA/ECDSA 要晚,合规场景需要硬件托管私钥时可能受限。
3.5 性能与尺寸一览
在典型的 x86_64 现代服务器(单核)上的量级概览:
| 算法 | 签名耗时 | 验签耗时 | 签名大小 | 公钥大小 |
|---|---|---|---|---|
| HS256 | ~1 μs | ~1 μs | 32 B | 32 B(对称) |
| RS256-2048 | ~1 ms | ~50 μs | 256 B | 294 B |
| RS256-4096 | ~8 ms | ~150 μs | 512 B | 550 B |
| PS256-2048 | ~1 ms | ~50 μs | 256 B | 294 B |
| ES256 | ~50 μs | ~150 μs | 64 B | 91 B |
| EdDSA | ~30 μs | ~80 μs | 64 B | 32 B |
具体数字因 CPU、库实现、批量处理方式而异,但数量级上 HMAC > EdDSA ≈ ECDSA-sign > RSA-sign;验签侧 RSA-verify > EdDSA ≈ ECDSA-verify > HMAC(HMAC 最快)。
3.6 选型决策树
- 单一信任域、服务自签自验 → HS256(密钥用 KMS 托管)。
- 需要公钥分发、兼容老系统 → RS256(2048 bit 起步,优先 PS256)。
- 需要公钥分发、尺寸敏感、追求性能 → ES256。
- 新项目无历史包袱、库支持充足 → EdDSA(Ed25519)。
一句话原则:如果你在犹豫,就选 EdDSA;如果调用方库不支持 EdDSA,就选 ES256;只在兼容历史系统时才用 RS256;除非自签自验,否则不要用 HS256。
四、攻击面深度分析
这一章是本文的重点之一。JWT 的规范设计在安全性上存在若干历史包袱,叠加各家库实现的细微差异,攻击面比表面看上去大得多。
4.1 alg=none 攻击
RFC 7515 §3.6 允许
alg: none,这种情况下签名段为空字符串。最早的一批
Java/Node 库默认把 none
当作”合法算法”处理,只要 header 声明
none,就把验签跳过。攻击者于是可以把任何 token
改成:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiJ9.
解码即
{"alg":"none","typ":"JWT"}.{"sub":"admin"}.,然后服务器竟然放行。
正确姿势是在服务端硬编码允许的算法白名单,显式拒绝
none:
token, err := jwt.Parse(raw, func(t *jwt.Token) (interface{}, error) {
if t.Method.Alg() != "RS256" {
return nil, fmt.Errorf("unexpected alg: %s", t.Method.Alg())
}
return pubKey, nil
})大部分现代库(jose、golang-jwt v5、pyjwt
2.x)默认已经禁用
none,但工程上仍应在业务代码里再锁一层——防御深度原则。
4.2 算法混淆攻击(RS256 → HS256)
这是历史上最著名的 JWT 漏洞,2015 年由 Auth0 披露。攻击流程:
- 目标系统使用 RS256 签名,公钥是
N。 - 攻击者把 header 改成
{"alg":"HS256"}。 - 用目标的 RSA 公钥的 PEM 字符串(整段
-----BEGIN PUBLIC KEY-----...)作为 HMAC 密钥,计算 HS256 签名。 - 发送到服务端。
问题出在服务端验签代码经常写成”根据 header 里的 alg
选择算法,然后用预加载的密钥验签”。如果密钥加载函数返回的是同一个
key 对象,HMAC 验签就会拿 RSA 公钥当 HMAC
密钥——而公钥是公开的,攻击者自然能算出正确的 HMAC。
修复方式:
func validator(t *jwt.Token) (interface{}, error) {
switch t.Method.(type) {
case *jwt.SigningMethodRSA:
// OK
default:
return nil, fmt.Errorf("refuse alg %s", t.Header["alg"])
}
return rsaPublicKey, nil
}关键是先判断算法家族,再返回对应类型的密钥。不要写通用的”根据 alg 返回 key”逻辑。
4.3 JKU/X5U 注入:从签名校验到 SSRF
jku 是 header 字段,指向一个 JWKS 的
URL。如果库盲目相信它,会发生这种事:
- 攻击者伪造 token,header 里写
"jku": "https://attacker.com/jwks.json"。 - 服务端 fetch 这个 URL,拿到攻击者提供的公钥。
- 用这个公钥验签——验签通过,因为 token 正是攻击者用配对私钥签的。
- 服务端放行。
更糟的是,即便 JWKS URL 要求 HTTPS
且有证书校验,jku 还能被用来做
SSRF:jku: http://169.254.169.254/latest/meta-data/
直接读云厂商元数据。
防御:生产环境永远不要用
jku、x5u、jwk、x5c
作为密钥来源。这些字段只在特定”信任 header
内密钥”的协议里合理(例如某些物联网场景),通用 Web
场景应当完全忽略它们。库层面 pyjwt、jose 默认就不处理
jku,但一些老 Node 库会处理。
实现侧应当:
// 只相信配置里的 JWKS URL,忽略 header.jku
jwksURL := mustEnv("IDP_JWKS_URL")
keyfunc := fetchJWKS(jwksURL)4.4 kid 注入:SQL 与路径穿越
kid 是字符串,很多实现会把它当作密钥查询的
key:
- SQL
版:
SELECT key FROM keys WHERE kid = '{kid}'——经典 SQL 注入。 - 文件系统版:
os.ReadFile("/etc/keys/" + kid + ".pem")——经典路径穿越。 - 内存 map 版:相对安全,但若 kid 带有特殊字符且被错误地日志序列化,可能触发日志注入。
更隐蔽的变种:攻击者把 kid 设成
../../../dev/null,服务器读到空文件,把空字节当
HMAC 密钥,然后用空密钥伪造 HMAC 签名——通过。
防御要点:
kid必须在白名单内(通常是 JWKS 返回的keys[*].kid),不在白名单直接拒。- 不要把
kid作为任何存储系统的键而不过滤。 - 把
kid的字符集限制在[A-Za-z0-9_\-\.],长度 ≤ 128。
if !kidPattern.MatchString(kid) {
return nil, errors.New("invalid kid")
}
key, ok := keySet.LookupKeyID(kid)
if !ok {
return nil, errors.New("unknown kid")
}4.5 其他值得了解的攻击
- Null byte 截断:某些旧解析器遇到
\x00会提前结束,攻击者可以在 Payload 前半构造合法字段,后半塞入被截断的污染数据。现代库基本不受影响,但自己解析 JSON 时要警惕。 - Key Confusion between RS256 and ECDSA:类似 HS256/RS256,如果同一份密钥在两种算法下可读取,也可能被混淆。
- Padding oracle(JWE 早期 A128CBC-HS256):RFC 7518 初版的 CBC+HMAC 实现有 padding oracle 风险,新项目应使用 AES-GCM。
- Cross-JWT confusion:同一公钥用于签发
id_token 和 access_token,如果受众校验不严,access_token
可能被当作 id_token 使用(反之亦然)。RFC 8725 推荐用
typ区分(at+jwt/id+jwt)。
RFC 8725(JWT Best Current Practices)把以上攻击归纳成了 12 条建议,生产系统值得对照逐条 checklist。
五、JWKS 端点与密钥路由
5.1 JWKS 的标准结构
OIDC Discovery 约定
/.well-known/openid-configuration 里有
jwks_uri 字段,指向一个返回如下结构的端点:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "key-2024-01",
"n": "xGOr-H7A-....",
"e": "AQAB"
},
{
"kty": "EC",
"use": "sig",
"alg": "ES256",
"kid": "key-2024-07",
"crv": "P-256",
"x": "MKBCTNIc....",
"y": "4Etl6SRW2..."
},
{
"kty": "OKP",
"use": "sig",
"alg": "EdDSA",
"kid": "key-2023-07",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo"
}
]
}字段说明:
kty:密钥类型,RSA/EC/OKP/oct。OKP是 Octet Key Pair,Ed25519/Ed448/X25519/X448 用。use:sig签名、enc加密。严格的实现会拒绝用use:enc的密钥去验签。alg:该密钥对应的算法,验签时应与 token header 的 alg 一致。kid:密钥 id,路由用。
5.2 kid 路由与缓存
验签流程简化为:
- 解析 token header,拿到
kid与alg。 - 在本地 JWKS 缓存中按
kid查找匹配的 JWK。 - 若缓存未命中,重新拉取 JWKS 端点刷新缓存。
- 用找到的公钥 + alg 白名单验签。
缓存策略建议:
- 设置
max-age(例如 10 分钟)+ 软刷新:后台定期拉,不阻塞验签。 - 设置上限 TTL(例如 24 小时),超过则强制刷新。
- 支持”kid 未命中时触发一次同步刷新”,但要限流,避免被伪造 kid 打穿上游 IdP。
type JWKSCache struct {
url string
mu sync.RWMutex
keys map[string]*jwk.Key
lastFetch time.Time
refreshTTL time.Duration
hardTTL time.Duration
refreshOnce singleflight.Group
}
func (c *JWKSCache) Get(ctx context.Context, kid string) (*jwk.Key, error) {
c.mu.RLock()
k, ok := c.keys[kid]
age := time.Since(c.lastFetch)
c.mu.RUnlock()
if ok && age < c.refreshTTL {
return k, nil
}
if ok && age < c.hardTTL {
go c.refresh(context.Background())
return k, nil
}
if err := c.refresh(ctx); err != nil && !ok {
return nil, err
}
c.mu.RLock()
defer c.mu.RUnlock()
k, ok = c.keys[kid]
if !ok {
return nil, fmt.Errorf("kid %q not found", kid)
}
return k, nil
}
func (c *JWKSCache) refresh(ctx context.Context) error {
_, err, _ := c.refreshOnce.Do("refresh", func() (interface{}, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", c.url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("jwks %d", resp.StatusCode)
}
set, err := jwk.ParseReader(resp.Body)
if err != nil {
return nil, err
}
m := make(map[string]*jwk.Key, set.Len())
for it := set.Keys(context.Background()); it.Next(context.Background()); {
pair := it.Pair()
key := pair.Value.(jwk.Key)
m[key.KeyID()] = &key
}
c.mu.Lock()
c.keys = m
c.lastFetch = time.Now()
c.mu.Unlock()
return nil, nil
})
return err
}几个工程细节:
singleflight防止启动时多个 goroutine 同时拉 JWKS 打爆 IdP。- 旧 key 在 refresh 失败时保留,避免一次网络抖动导致全量验签失败。
- kid 未命中时的刷新要限流(例如每 kid 每分钟最多一次),否则攻击者构造大量随机 kid 就能把你 JWKS 端点打爆。
5.3 密钥轮换(Zero-Downtime Rotation)
轮换的核心是”签发方使用新 key 签,但 JWKS 同时发布新旧两把 key”,让还在流通的旧 token 能被验签。完整流程:
- T0:仅
key-old,所有 token 用它签。 - T1:生成
key-new,JWKS 同时暴露key-old和key-new,签发方仍用key-old签(让下游先拉到新 JWKS)。 - T2:等待足够时间(> JWKS 缓存最大 TTL,例如 30
分钟)让所有验签方都已经看到
key-new。 - T3:切换签发方到
key-new。此时新 token 用key-new签,旧 token 仍能用key-old验签。 - T4:等待所有旧 token 过期(> access_token 最大寿命,通常 1 小时)。
- T5:JWKS 下线
key-old,轮换完成。
一个常见的反模式是”先切签发方,再更新 JWKS”——这会导致所有还没刷新 JWKS 缓存的验签方短时间内全部失败。
配置示例(简化版 Terraform 风格):
resource "idp_signing_key" "rsa_current" {
kid = "key-2024-07"
algorithm = "PS256"
state = "active"
created_at = "2024-07-01T00:00:00Z"
}
resource "idp_signing_key" "rsa_previous" {
kid = "key-2024-01"
algorithm = "PS256"
state = "retiring"
expires_at = "2024-08-01T01:00:00Z"
}
签发方只挑 state = active 的 key;JWKS
同时暴露 active 和
retiring;到期后由定时任务清理
retiring 的 key。
5.4 多 IdP 与多 Issuer
如果你的系统要接受来自多个 IdP 的 token(典型 B2B
SaaS),每个 IdP 都有独立的 JWKS。校验逻辑要根据 token 里的
iss 字段路由到对应的 JWKS,而不是所有 token
共用一个 JWKS 缓存。
type MultiJWKS struct {
issuers map[string]*JWKSCache
}
func (m *MultiJWKS) Validate(raw string) (*Claims, error) {
parsed, _ := jwt.ParseUnsafe(raw) // 仅解析不验签
iss := parsed.Claims.Issuer
cache, ok := m.issuers[iss]
if !ok {
return nil, fmt.Errorf("unknown issuer %q", iss)
}
key, err := cache.Get(ctx, parsed.Header.KID)
if err != nil {
return nil, err
}
return jwt.ParseAndVerify(raw, key, jwt.WithAlgorithm("RS256"))
}注意:“未知 issuer” 必须直接拒绝,而不是 fallback 到默认 JWKS——否则攻击者自建 IdP 就能签发 token。
六、JWE:什么时候真的需要加密 payload
JWE 的使用比 JWS 少得多,99% 的业务场景 JWS 就够了。但有几类场景确实需要 JWE:
- Payload 中包含无法剥离的敏感字段(例如病历 id、合规要求的用户属性),令牌会经过不可信的中间人(浏览器、第三方代理)。
- 闭源的 access_token(opaque 模式 + 内部解密)用于防止客户端对 token 结构做反向依赖,这种做法 Okta 在内部 token 里使用。
- JWT 需要作为 OAuth 2.0 Request Object 通过非加密信道分发(RFC 9101)。
6.1 JWE 的五段结构
JWE 紧凑序列化有五段,点号分隔:
Base64URL(ProtectedHeader).
Base64URL(EncryptedKey).
Base64URL(IV).
Base64URL(Ciphertext).
Base64URL(AuthTag)
- ProtectedHeader:
alg(密钥包装算法)+enc(内容加密算法)。 - EncryptedKey:被
alg包装后的 CEK(Content Encryption Key)。 - IV:初始化向量,AES-GCM 需要 12 字节。
- Ciphertext:用 CEK 加密后的 payload。
- AuthTag:AEAD 的认证标签,AES-GCM 是 16 字节。
6.2 算法选型
alg(密钥包装):
RSA-OAEP-256:最常用,接收方用 RSA 私钥解包。ECDH-ES+A256KW:基于 ECDH 的静态-临时密钥协商,令牌更小。dir:直接模式,双方预先共享 CEK,用于对称场景。RSA1_5:老的 PKCS1-v1_5 包装,有 Bleichenbacher 攻击,不要用。
enc(内容加密):
A256GCM:推荐。A128GCM:够用,尺寸稍小。A256CBC-HS512:CBC + HMAC 手工组合,RFC 7518 规定了实现,但容易出错,不推荐新项目使用。
生产新项目建议:RSA-OAEP-256 +
A256GCM 或 ECDH-ES+A256KW +
A256GCM。
6.3 Nested JWT:先签再加密
如果同时需要完整性、真实性和机密性,正确做法是先做
JWS 签名,然后把整个 JWS 作为 JWE 的 payload
加密。这叫 Nested JWT,header 里
cty: "JWT" 标识内部是 JWT。反过来(先 JWE 再
JWS)也能做,但复杂度更高,且无法对密钥托管做简单的职责分离。
七、生产运维细节
7.1 exp 与 clock skew
exp 校验看起来就一行
now < exp,但工程里要考虑客户端与服务端时钟不同步。生产建议:
- 允许 60 秒左右的
leeway:
now - 60 < exp。太小会误伤用户,太大会扩大重放窗口。 - 如果系统使用 Kerberos 或严格时钟同步(NTP),可以把 leeway 压到 5 秒。
- 不要直接相信客户端时间,所有时间运算用服务端
time.Now().UTC()。
const leeway = 60 * time.Second
if time.Now().UTC().After(claims.ExpiresAt.Add(leeway)) {
return errors.New("token expired")
}
if claims.NotBefore != nil && time.Now().UTC().Add(leeway).Before(*claims.NotBefore) {
return errors.New("token not yet valid")
}另一个坑:iat
不应该作为过期判据。某些历史代码会写
now - iat < max_age 来反向判断”这个 token
是不是太老”,但攻击者可以把 iat 改成未来时间(只要
exp 也同步改)绕过;而且库不一定对 iat
做强制校验。标准做法是只信 exp 与
nbf。
7.2 令牌尺寸:Cookie 与 Header 的硬限制
- HTTP 协议没有规定 header 总大小,但大部分服务器默认 8
KB(nginx
large_client_header_buffers、AWS ALB 16 KB、Cloudflare 8 KB)。 Cookie: session=...单个 Cookie 最大 4 KB(RFC 6265),浏览器对每个域名的 cookie 总数与总大小也有限(Chrome 约 180 个、4 KB 每个)。- base64 扩张比是 4/3,JSON payload 里再加上 RS256 签名,一个普通 access_token 很容易到 1 KB 以上。
为此:
- 不要把完整用户画像塞进 JWT。只放
sub、最小必要的 scope、一两个 role 枚举值。 - 需要复杂权限时,用 opaque token + introspection 或用 token reference(uuid)+ 服务端 session。
- 如果必须放角色列表,考虑用 bit flag 压缩(例如
"roles": "0x0F3A")。 act、cnf(RFC 8705/9449)这类高级字段只在需要时启用。
7.3 header size 撞墙的典型事故
生产上曾遇到过这样的故障:新增了一个 role,用户组扩展后
JWT 从 900 字节涨到 1.3 KB,Cookie header
总长度 1.5 KB,加上其他 header 逼近 nginx 的 4 KB
缓冲;Cloudflare Worker 又加了一层 header 注入,最终
upstream 返回
494 Request header too large,偶发性故障在周五晚上才被发现。
教训:
- JWT 尺寸要有监控,超过阈值(例如 1.2 KB)告警。
- 上线前在真实链路上测最长可能的 token(最多角色用户 + 最多 audience + 最多 claim)。
- 关键路径考虑走 Authorization header 而非 Cookie,header 上限通常大于 cookie 上限。
7.4 调试工具与可观测性
- jwt.io 的 debugger:离线 Base64 解码 + 在线验签。不要把生产私钥粘到网页里,它会留在浏览器本地(声称不发但你无法验证)。
jose-util/step jwt:命令行工具,适合 CI 或运维排错。openssl base64 -d+jq:无依赖调试:
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .- 日志侧永远不要记录完整 JWT。JWT
本质上是一个 bearer token,泄漏 = 身份泄漏。实在要记录,只记
kid、sub、jti、exp,或者把 token 做 SHA-256 后存摘要。
7.5 完整的 Go 验签函数
把前面的点合在一起,一个”可以直接上生产”的验签函数大致长这样:
type Validator struct {
issuer string
audience string
jwks *JWKSCache
allowedAlg map[string]bool
leeway time.Duration
}
func NewValidator(iss, aud, jwksURL string) *Validator {
return &Validator{
issuer: iss,
audience: aud,
jwks: newJWKSCache(jwksURL, 10*time.Minute, 24*time.Hour),
allowedAlg: map[string]bool{
"RS256": true, "PS256": true, "ES256": true, "EdDSA": true,
},
leeway: 60 * time.Second,
}
}
func (v *Validator) Validate(ctx context.Context, raw string) (*Claims, error) {
tok, err := jwt.ParseInsecure([]byte(raw))
if err != nil {
return nil, fmt.Errorf("malformed: %w", err)
}
hdr := tok.ProtectedHeaders()
alg := hdr.Algorithm().String()
if !v.allowedAlg[alg] {
return nil, fmt.Errorf("alg %q not allowed", alg)
}
kid := hdr.KeyID()
if !validKid.MatchString(kid) {
return nil, errors.New("invalid kid format")
}
key, err := v.jwks.Get(ctx, kid)
if err != nil {
return nil, fmt.Errorf("jwks: %w", err)
}
if keyAlg, _ := key.Algorithm(); keyAlg.String() != alg {
return nil, errors.New("alg mismatch with jwks")
}
if keyUse, _ := key.KeyUsage(); keyUse != "" && keyUse != "sig" {
return nil, errors.New("key not for signing")
}
verified, err := jwt.Parse(
[]byte(raw),
jwt.WithKey(jwa.SignatureAlgorithm(alg), key),
jwt.WithValidate(true),
jwt.WithIssuer(v.issuer),
jwt.WithAudience(v.audience),
jwt.WithAcceptableSkew(v.leeway),
)
if err != nil {
return nil, fmt.Errorf("verify: %w", err)
}
return mapClaims(verified), nil
}
var validKid = regexp.MustCompile(`^[A-Za-z0-9_\-\.]{1,128}$`)关键点:算法白名单、kid 格式校验、alg/kid 与 JWKS 一致性、iss/aud 校验、leeway。
八、工程坑点
把本文分散提到的坑点集中起来,作为落地 checklist:
- 算法白名单硬编码:服务端拒绝
none,拒绝 HMAC 家族(除非自签自验),拒绝未预期的算法。 - alg/kid 与 JWKS 一致性:token header 的 alg 必须与 JWKS 中该 kid 的 alg 一致;不一致即拒。
- 忽略 jku/x5u/jwk/x5c:除非业务明确需要且经过安全评审,默认忽略这些 header 字段。
- kid 严格校验:正则白名单 + 已知 kid 集合二重校验,拒绝任何会被用作文件路径或 SQL 参数的特殊字符。
- issuer 路由:多 IdP 场景下先按 iss 路由到对应 JWKS,未知 iss 直接拒,不要 fallback。
- audience 严格校验:aud 可能是字符串或数组,两种都要正确处理;多 aud 下匹配自己的业务标识。
- leeway 不要超过 5 分钟:时钟偏移允许值过大会放大重放窗口。60 秒是通行选择。
- iat 不能当过期判据:只用 exp 和 nbf。
- JWT 尺寸监控:设置告警阈值(例如 1 KB/1.5 KB),避免 header 撞墙。
- 日志不要写完整 token:只记摘要或少量 Claim。
- JWKS 缓存要有降级:刷新失败时保留旧 key,不要让一次网络抖动引发全站 401。
- kid 未命中时的刷新限流:避免伪造 kid 打爆 IdP。
- 密钥轮换要有 overlap 期:签发切换要等所有下游拉到新 JWKS,下线旧 key 要等所有旧 token 过期。
- 敏感字段不放进 JWS:放了就等于公开;真要放用 JWE。
- JWT 用于 access token 时的 typ:参考
RFC 9068 使用
at+jwt,区分 id_token 与 access_token。 - 不要用 RSA-1024 与 RSA1_5:密钥强度和填充算法都已经落后,分别升级到 ≥2048 与 OAEP。
- ECDSA 签名格式:JWS 要求 r||s 拼接,不是 DER;跨语言互操作要注意。
- HS256 密钥强度:至少 32 字节高熵随机值,用 KMS/Vault 托管,不要写到代码里。
- 刷新 token 与 access token 算法可以不同:refresh token 通常只由 IdP 自己验签,可以用 HS256;access token 要广播给多个服务,必须用非对称。
- 不信任客户端时间:所有时间判断用服务端 UTC 时钟。
九、选型建议
按场景给出明确的选型:
9.1 新项目(云原生微服务)
- 令牌类型:access_token 用 JWS(不加密)、id_token 用 JWS、refresh_token 用 opaque(服务端 session)。
- 签名算法:EdDSA(Ed25519)。若某些服务库不支持,退到 ES256。
- 密钥托管:AWS KMS / GCP KMS / HashiCorp Vault Transit,IdP 不直接持有私钥。
- JWKS 缓存:10 分钟软 TTL + 24 小时硬 TTL + singleflight。
- exp:access_token 15 分钟、id_token 1 小时。
- 轮换周期:90 天一次签名密钥轮换(RFC 8555 建议)、事故时可随时灰度新密钥。
9.2 B2B SaaS(多租户 + 外部 IdP 联邦)
- 签名算法:RS256 / PS256(兼容性好),跨组织时 EdDSA 支持度不齐。
- JWKS:按 issuer 路由,每个租户独立 cache。
- aud 必须严格校验租户专属值,避免跨租户 token 穿越。
- 合规要求高时,对敏感 Claims 做 JWE Nested JWT。
9.3 纯内部服务(单信任域)
- 签名算法:HS256,密钥托管在 KMS,每 90 天轮换。
- 不需要 JWKS 端点,直接 KMS Sign/Verify API。
- token 尺寸小、签发验签都快,适合高 QPS 网关场景。
9.4 移动端 / 浏览器(前端持有 token)
- access_token 用 JWS 即可,avoid 敏感字段。
- id_token 的 sub 使用稳定的 pairwise identifier(OIDC pairwise subject),避免在第三方客户端泄漏用户关联。
- refresh_token 在浏览器端用 HttpOnly + Secure + SameSite=Strict 的 cookie;移动端用 OS Keychain。
- 强烈建议配合 PKCE(参考 OAuth 2.1 与 PKCE 一章)。
9.5 高合规 / 金融场景
- 签名:PS256(PSS 填充,有形式化证明的可证安全),硬件 HSM 托管私钥。
- 加密:Nested JWT(JWS + JWE with RSA-OAEP-256 + A256GCM),审计日志记录所有 kid 路由与验签失败事件。
- 轮换:30 天强制轮换,支持紧急轮换(1 小时内完成)。
十、参考资料
- RFC 7515 — JSON Web Signature (JWS):https://datatracker.ietf.org/doc/html/rfc7515
- RFC 7516 — JSON Web Encryption (JWE):https://datatracker.ietf.org/doc/html/rfc7516
- RFC 7517 — JSON Web Key (JWK):https://datatracker.ietf.org/doc/html/rfc7517
- RFC 7518 — JSON Web Algorithms (JWA):https://datatracker.ietf.org/doc/html/rfc7518
- RFC 7519 — JSON Web Token (JWT):https://datatracker.ietf.org/doc/html/rfc7519
- RFC 8037 — CFRG ECDH and EdDSA for JOSE:https://datatracker.ietf.org/doc/html/rfc8037
- RFC 8725 — JSON Web Token Best Current Practices:https://datatracker.ietf.org/doc/html/rfc8725
- RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens:https://datatracker.ietf.org/doc/html/rfc9068
- RFC 9101 — OAuth 2.0 JWT-Secured Authorization Request (JAR):https://datatracker.ietf.org/doc/html/rfc9101
- OpenID Connect Discovery 1.0:https://openid.net/specs/openid-connect-discovery-1_0.html
- Auth0 “Critical Vulnerabilities in JSON Web Token Libraries” (2015):算法混淆攻击最早披露。
- 站内相关:JWT 基础概念、OAuth 2.1 与 PKCE、认证架构总览
下一篇:Session、Refresh Token 与吊销体系
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO
B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。
【身份与访问控制工程】Session、Refresh Token 与吊销体系
JWT 的无状态签发解决了分布式认证的扩展性,但也把吊销这件事推回到了工程师面前。一个短期 access token 配长期 refresh token 的混合架构,在 Google、Auth0、Keycloak、AWS Cognito 的实现里趋同收敛,但细节差异能决定系统在被攻击时是多丢一个账号还是多丢一百万。本文把 refresh token rotation、reuse detection、family-based abort、OIDC back-channel logout、Redis 黑名单、Bloom filter 加速、批量吊销场景拆开讲清楚。
【身份与访问控制工程】Keycloak 工程拆解:Realm、Client、Flow 与扩展机制
从 Quarkus runtime、Infinispan 缓存、数据库 schema,到 Authentication Flow 引擎、SPI 扩展点、multi-site 部署与常见工程坑点,拆解 Keycloak 的真实工程形态与选型边界。
【身份与访问控制工程】CIAM 架构:面向 B2B / B2C SaaS 的身份平台
系统梳理 CIAM(Customer Identity and Access Management)的场景差异、数据模型、隐私合规与工程坑点,覆盖 B2C 社交登录、B2B 企业 SSO/SCIM、B2B2C 组织模型,以及全球多区域部署与选型建议。