签名与验证 JWT

JWT 是三段 base64url——头部、载荷、签名——前两段持有令牌的任何人都能读。解码一个令牌告诉你它声称什么;只有第三段告诉你该不该相信它。本文讲的就是第三段:签名如何生成、谁能生成、验证方如何检查,以及“已验证”在哪个确切的点上不再意味着“可信”。每一步你都可以在JWT 签名与验证器里亲手跑一遍,它在你的浏览器本地签名与验证。

解码不等于验证

关于 JWT 最常见的误解,也是最危险的误解:读一个令牌就等于检查了它。不是这样。头部和载荷是 base64url——一种编码,不是加密——所以拦截到令牌的人、在日志里找到它的人、从浏览器 localStorage 里复制它的人,都能在几秒内解码它,完全不需要密钥。里面的声明(为谁签发、何时过期、声称什么角色)只是断言,而断言很廉价。

签名存在的意义,正是因为声明很廉价。它是头部、载荷与一把只有签发方持有的密钥之间的密码学绑定:改动载荷中的一个字符,签名就不再匹配。验证就是重算这套数学——用签发方的密钥,对收到的头部和载荷重新计算签名,再做比较。如果匹配,令牌与签发方签发时完全一致;如果不匹配,一定有东西被改动过,令牌再合理也一文不值。

对称与非对称:谁能铸造令牌

用 HS256,签发的密钥就是验证的密钥。这让 HMAC 快速、简单,在一个服务内部完全够用——你的 API 自己签发令牌、自己检查,密钥从不出大楼。一旦第二方需要验证,这个模型就破了:把密钥交给他们,就等于把铸造令牌的能力交给他们,因为验证与签名是同一个操作。HMAC 密钥泄露不是验证问题,而是伪造问题。

非对称算法把这两种能力分开了。RS256、ES256 和 EdDSA 用私钥签名、用公钥验证,所以签发方可以公开自己的公钥——以 JWKS 的形式——全世界的验证方都能检查令牌,而没有任何一方握有伪造它的能力。正是这种分离让 JWT 能跨服务工作:你的 API 信任来自身份提供方的令牌,因为它信任该提供方发布的密钥,而不是因为它与对方共享密钥。代价是复杂性——密钥格式、轮换、kid 匹配——而本站的签名与验证器正是为了把这些讲具体而存在的。

算法混淆攻击

经典的 JWT 攻击不需要攻破任何密码学——只需要一个信任令牌自己命名算法的验证方。攻击者取一个合法的 RS256 令牌,它的公钥是公开的(公钥本来就是公开的),再用 HS256 对同样的载荷重新签名,把公钥本身当作 HMAC 密钥。一个从头部读取 alg 再据此选择验证方法的验证方,现在会用 HMAC “验证”这个伪造令牌,把公钥当作密钥——而公钥是公开的。

防御是结构性的,不是建议性的:验证方必须钉住自己接受的算法,密钥所属的族必须与算法所属的族匹配——RSA 密钥永远不能验证 HMAC 令牌,密钥也永远不能验证 RSA 令牌。一个跨族重新解释密钥的验证方,无论密码学多正确,在设计上就是坏的。正因如此,本站的验证器会带着明确警告拒绝跨族密钥,而不是尝试检查:拒绝本身就是功能。

JWKS 与 kid 轮换在实践中的样子

身份提供方不会只发一把永恒的密钥;它发布一个密钥集——JWKS,通常位于 /.well-known/jwks.json——轮换时先新增一把,宽限期过后再移除旧的。每个令牌的头部都带一个 kid,指名签发它的密钥,于是验证方取回密钥集、挑出匹配的密钥、完成验证。用旧密钥签发的令牌在旧密钥被移除前一直有效,这正是轮换安全而不是“一刀切”的原因。

出问题的地方在操作细节。验证方必须缓存 JWKS(每次请求都取一次,等于自己制造故障),处理 kid 未知的情况——通常是缓存还没看到的轮换,所以失败前先重新获取——并且永远不要接受来自不可信 URL 的密钥集:密钥集的可信程度取决于它经过的通道,这就是 JWKS 端点要放在 HTTPS 后面、本站的JWKS 构建器把每个 kid 派生为密钥本身的 RFC 7638 摘要的原因,这样 id 就无法脱离密钥被单独伪造。

exp、nbf、iat——以及时钟问题

签名证明令牌被签发过;它不说明令牌现在还该不该被接受。那是时间声明的工作:exp(何时失效)、nbf(何时生效)、iat(何时签发)。只验签名、不看时钟的验证方会永远接受一个令牌——包括一年前被盗的那一个。实际的答案是短有效期:访问令牌以分钟计,长期信任交给刷新令牌,撤销则靠不再重新签发。

时钟并不一致,所以验证方会留一点宽容——对 exp 和 nbf 通常是一两分钟的容差——但容差是对漂移的容忍,不是忽略过期的许可证。时钟差几分钟的设备签发的令牌,会被自己的服务器拒绝;iat 在未来的令牌是伪造信号,不是舍入误差。同样的时钟纪律也适用于工具:验证器对照你能看到的时钟检查过期,并直言令牌已过期,而不是只说“无效”。

“已验证”能证明什么、不能证明什么

已验证的令牌只证明三件事,不多不少:头部与载荷与签发时完全一致、签名方持有密钥、令牌在有效期内。它不证明签发方就是你以为的那一个——那取决于密钥从哪里来,而从攻击者控制的 URL 获取的密钥,会把攻击者的令牌验证得天衣无缝。它不证明声明是真的——签名认证的是陈述,不是陈述背后的事实。它也不证明令牌没有被盗:任何人出示的有效令牌都会被接受,这正是令牌要短命、要限定范围的原因。

所以信任的决定在令牌之外。你的 API 信任一个令牌,是因为它通过 HTTPS 从身份提供方的 well-known URL 获取了密钥、因为 iss 与它配置的提供方匹配、因为 aud 与它自己的服务匹配。验证是最后一道检查,不是第一道:密钥来源、签发方、受众,然后才是签名、然后是时间。一个只报告“有效”而不给上下文的工具,说的是数学上的实话,关于信任却什么也没说——这正是本站的验证器要在验证结果旁边把话说清的原因。

常见问题

没有密钥能验证令牌吗?

不能——验证需要签发方的公钥(HMAC 则需要共享密钥)。解码不需要密钥,而这正是要避免的混淆:解码出来的令牌是可读的,不是已验证的。如果签发方发布了 JWKS,就从 well-known URL 获取它,按令牌的 kid 匹配;本站的验证器会替你做这件事。

为什么我的 RS256 令牌用密钥验证失败?

因为密钥和 RSA 密钥属于不同的族,健全的验证方拒绝混用。RS256 令牌用签发方的 RSA 公钥验证——PEM、JWK 或 JWKS——绝不用文本密钥。用密钥验证会是算法混淆攻击:公钥是公开的,如果 HMAC 验证接受它作为密钥,任何人都能伪造令牌。

JWT 应该活多久?

访问令牌以分钟计,而不是以天计:5–15 分钟是常见范围,长期会话交给刷新令牌。短有效期把被盗令牌的危害限制在过期前的窗口里,撤销也变成“不再重新签发”,而不是维护黑名单。活上几周的令牌,是工具更差的会话 Cookie。

来源