土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】JWT、JWS、JWE、JWKS 一次讲透

文章导航

分类入口
architecturesecurity
标签入口
#JWT#JWS#JWE#JWK#JWKS#RS256#ES256#EdDSA#alg=none#key-rotation#OIDC

目录

几乎所有现代身份系统——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 结构与 JWKS 密钥路由

一、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 注册了 algenckty 的取值
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"
}

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,唯一标识 字符串,用于防重放与吊销

几个常被忽略的细节:

2.3 Signature 计算

Base64URL(Header) + "." + Base64URL(Payload) 这个字符串做 MAC 或签名,把结果 Base64URL 编码作为第三段。注意:

三、签名算法选型

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 字符)。

坑点:

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 上限。

坑点:

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 集中签发、大量服务验签”的场景:签发端吞吐更高。

坑点:

3.4 EdDSA(Ed25519):新项目的首选

EdDSA 基于 Edwards 曲线,性能比 ECDSA 还要好:签名、验签都在几十微秒,签名 64 字节,密钥 32 字节,都是定长,没有 ASN.1 的坑。更关键的是 Ed25519 不依赖随机源——它用消息哈希派生 nonce,天然免疫 ECDSA 的 nonce 复用攻击。

坑点基本没有,真要挑毛病的话:

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 选型决策树

一句话原则:如果你在犹豫,就选 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 披露。攻击流程:

  1. 目标系统使用 RS256 签名,公钥是 N
  2. 攻击者把 header 改成 {"alg":"HS256"}
  3. 用目标的 RSA 公钥的 PEM 字符串(整段 -----BEGIN PUBLIC KEY-----...)作为 HMAC 密钥,计算 HS256 签名。
  4. 发送到服务端。

问题出在服务端验签代码经常写成”根据 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。如果库盲目相信它,会发生这种事:

  1. 攻击者伪造 token,header 里写 "jku": "https://attacker.com/jwks.json"
  2. 服务端 fetch 这个 URL,拿到攻击者提供的公钥。
  3. 用这个公钥验签——验签通过,因为 token 正是攻击者用配对私钥签的。
  4. 服务端放行。

更糟的是,即便 JWKS URL 要求 HTTPS 且有证书校验,jku 还能被用来做 SSRF:jku: http://169.254.169.254/latest/meta-data/ 直接读云厂商元数据。

防御:生产环境永远不要用 jkux5ujwkx5c 作为密钥来源。这些字段只在特定”信任 header 内密钥”的协议里合理(例如某些物联网场景),通用 Web 场景应当完全忽略它们。库层面 pyjwt、jose 默认就不处理 jku,但一些老 Node 库会处理。

实现侧应当:

// 只相信配置里的 JWKS URL,忽略 header.jku
jwksURL := mustEnv("IDP_JWKS_URL")
keyfunc := fetchJWKS(jwksURL)

4.4 kid 注入:SQL 与路径穿越

kid 是字符串,很多实现会把它当作密钥查询的 key:

更隐蔽的变种:攻击者把 kid 设成 ../../../dev/null,服务器读到空文件,把空字节当 HMAC 密钥,然后用空密钥伪造 HMAC 签名——通过。

防御要点:

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 其他值得了解的攻击

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"
    }
  ]
}

字段说明:

5.2 kid 路由与缓存

验签流程简化为:

  1. 解析 token header,拿到 kidalg
  2. 在本地 JWKS 缓存中按 kid 查找匹配的 JWK。
  3. 若缓存未命中,重新拉取 JWKS 端点刷新缓存。
  4. 用找到的公钥 + alg 白名单验签。

缓存策略建议:

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
}

几个工程细节:

5.3 密钥轮换(Zero-Downtime Rotation)

轮换的核心是”签发方使用新 key 签,但 JWKS 同时发布新旧两把 key”,让还在流通的旧 token 能被验签。完整流程:

  1. T0:仅 key-old,所有 token 用它签。
  2. T1:生成 key-new,JWKS 同时暴露 key-oldkey-new,签发方仍用 key-old 签(让下游先拉到新 JWKS)。
  3. T2:等待足够时间(> JWKS 缓存最大 TTL,例如 30 分钟)让所有验签方都已经看到 key-new
  4. T3:切换签发方到 key-new。此时新 token 用 key-new 签,旧 token 仍能用 key-old 验签。
  5. T4:等待所有旧 token 过期(> access_token 最大寿命,通常 1 小时)。
  6. 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 同时暴露 activeretiring;到期后由定时任务清理 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:

6.1 JWE 的五段结构

JWE 紧凑序列化有五段,点号分隔:

Base64URL(ProtectedHeader).
Base64URL(EncryptedKey).
Base64URL(IV).
Base64URL(Ciphertext).
Base64URL(AuthTag)

6.2 算法选型

alg(密钥包装):

enc(内容加密):

生产新项目建议:RSA-OAEP-256 + A256GCMECDH-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,但工程里要考虑客户端与服务端时钟不同步。生产建议:

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 做强制校验。标准做法是只信 expnbf

7.2 令牌尺寸:Cookie 与 Header 的硬限制

为此:

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,偶发性故障在周五晚上才被发现。

教训:

7.4 调试工具与可观测性

echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

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:

  1. 算法白名单硬编码:服务端拒绝 none,拒绝 HMAC 家族(除非自签自验),拒绝未预期的算法。
  2. alg/kid 与 JWKS 一致性:token header 的 alg 必须与 JWKS 中该 kid 的 alg 一致;不一致即拒。
  3. 忽略 jku/x5u/jwk/x5c:除非业务明确需要且经过安全评审,默认忽略这些 header 字段。
  4. kid 严格校验:正则白名单 + 已知 kid 集合二重校验,拒绝任何会被用作文件路径或 SQL 参数的特殊字符。
  5. issuer 路由:多 IdP 场景下先按 iss 路由到对应 JWKS,未知 iss 直接拒,不要 fallback。
  6. audience 严格校验:aud 可能是字符串或数组,两种都要正确处理;多 aud 下匹配自己的业务标识。
  7. leeway 不要超过 5 分钟:时钟偏移允许值过大会放大重放窗口。60 秒是通行选择。
  8. iat 不能当过期判据:只用 exp 和 nbf。
  9. JWT 尺寸监控:设置告警阈值(例如 1 KB/1.5 KB),避免 header 撞墙。
  10. 日志不要写完整 token:只记摘要或少量 Claim。
  11. JWKS 缓存要有降级:刷新失败时保留旧 key,不要让一次网络抖动引发全站 401。
  12. kid 未命中时的刷新限流:避免伪造 kid 打爆 IdP。
  13. 密钥轮换要有 overlap 期:签发切换要等所有下游拉到新 JWKS,下线旧 key 要等所有旧 token 过期。
  14. 敏感字段不放进 JWS:放了就等于公开;真要放用 JWE。
  15. JWT 用于 access token 时的 typ:参考 RFC 9068 使用 at+jwt,区分 id_token 与 access_token。
  16. 不要用 RSA-1024 与 RSA1_5:密钥强度和填充算法都已经落后,分别升级到 ≥2048 与 OAEP。
  17. ECDSA 签名格式:JWS 要求 r||s 拼接,不是 DER;跨语言互操作要注意。
  18. HS256 密钥强度:至少 32 字节高熵随机值,用 KMS/Vault 托管,不要写到代码里。
  19. 刷新 token 与 access token 算法可以不同:refresh token 通常只由 IdP 自己验签,可以用 HS256;access token 要广播给多个服务,必须用非对称。
  20. 不信任客户端时间:所有时间判断用服务端 UTC 时钟。

九、选型建议

按场景给出明确的选型:

9.1 新项目(云原生微服务)

9.2 B2B SaaS(多租户 + 外部 IdP 联邦)

9.3 纯内部服务(单信任域)

9.4 移动端 / 浏览器(前端持有 token)

9.5 高合规 / 金融场景

十、参考资料


上一篇SCIM 与账号生命周期:开通、变更、离职自动化

下一篇Session、Refresh Token 与吊销体系

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO

B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。

2026-04-21 · architecture / security

【身份与访问控制工程】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 加速、批量吊销场景拆开讲清楚。


By .