访问令牌验签通过之后,还要校验什么
订单接口和账单接口用同一个身份服务签发的 JWT,资源服务器都只配置了公钥验签。测试时发现,拿着发给账单接口的令牌,订单接口也照样放行;再一查,前端登录拿到的 ID Token 直接塞进
Authorization头,也能调通。
签名证明的是「这个令牌由持有私钥的一方签发,内容没有被改过」,它不回答「这个令牌是不是发给我的」「是不是访问令牌」「签发它的是不是我信任的那个身份服务」。这些问题要靠对声明(claims)和头部的逐项校验,而很多框架的默认配置只做了其中一部分。
本文用 Spring Security 7.1.1 的 JWT 解码器(底层是 Nimbus JOSE+JWT 10.9.1),自己签发一组令牌,对比默认配置与补齐校验后的结果。实验见文末。授权码流程本身的请求绑定见授权码、PKCE 与 state 各自防什么。
一、默认解码器校验了什么
NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();| 令牌 | 默认解码器 |
|---|---|
| 签发者、受众都正确 | 接受 |
| 受众是 billing-api | 接受 |
| 签发者是另一个身份服务(同一把密钥) | 接受 |
| 一个 ID Token(受众是网页应用) | 接受 |
| 30 秒前过期 | 接受 |
| 5 分钟前过期 | 拒绝 |
| 没有 exp | 接受 |
| 另一把密钥签名(kid 相同) | 拒绝 |
alg=none 的未签名令牌 | 拒绝 |
| HS256,用公钥字节当密钥 | 拒绝 |
默认解码器做好了两类检查:签名与算法(错误的密钥、未签名令牌、把公钥当 HMAC 密钥的算法混淆都被拒绝),以及时间(过期与生效时间,允许 60 秒的时钟偏差,所以 30 秒前过期的令牌还能通过)。它没有检查受众和签发者,开头的两个问题就出在这里。
签名只能证明令牌来自持有私钥的一方。如果同一个身份服务为多个 API 签发令牌,或者多个环境共用一套密钥,签名对所有这些令牌都成立;能把它们区分开的只有 aud 和 iss。
二、受众:令牌是发给谁的
受众是 billing-api:加上签发者、受众、at+jwt 类型校验 拒绝:An error occurred while attempting to decode the Jwt: The aud claim is not validaud 声明说明令牌的目标接收方。资源服务器必须检查自己在受众列表里,否则发给任何一个 API 的令牌都能在所有 API 上使用:一个权限较低的服务拿到用户的令牌,就能转手调用权限更高的服务。
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtIssuerValidator("https://idp.example"),
new JwtAudienceValidator("orders-api"),
new JwtTypeValidator("at+jwt")));受众的取值要和身份服务的配置约定好:有的身份服务把客户端 ID 放进 aud,有的用资源标识符(RFC 8707 的 resource 参数)。
三、类型:ID Token 不是访问令牌
一个 ID Token(受众是网页应用、带 nonce):默认解码器(typ=JWT)接受(aud=[web-app],iss=https://idp.example)ID Token 是 OpenID Connect 发给客户端的认证结果,告诉网页应用「用户是谁」;访问令牌是发给资源服务器的授权凭据。两者都是同一个身份服务签发的 JWT,签名、签发者、有效期都可能通过校验。区分它们靠两件事:受众(ID Token 的 aud 是客户端 ID,不是 API),以及类型。
RFC 9068 规定 JWT 格式的访问令牌使用 typ: at+jwt,资源服务器可以据此拒绝其他类型的 JWT。这里有一个意外的结果:
typ=at+jwt、其余全部正确的访问令牌:默认解码器 拒绝:An error occurred while attempting to decode the Jwt: the given typ value needs to be one of [JWT]Spring Security 7.1.1 的默认解码器只接受 typ 为 JWT 或没有 typ 的令牌,按 RFC 9068 签发的访问令牌反而被拒绝。如果身份服务签发 at+jwt,资源服务器要显式配置类型校验器(或者使用 JwtValidators.createAtJwtValidator());反过来,如果身份服务签发 typ: JWT,就无法靠类型区分 ID Token 和访问令牌,只能依赖受众。
四、签发者、过期与必填声明
签发者是另一个身份服务(同一把密钥):加上签发者、受众、at+jwt 类型校验 拒绝:… The iss claim is not valid
没有 exp:加上签发者、受众、at+jwt 类型校验 接受(aud=[orders-api],iss=https://idp.example)
没有 exp:再加上 setAllowEmptyExpiryClaim(false) 的时间校验器 拒绝:… exp is required- 签发者:多个身份服务、多个环境(测试与生产)共用密钥或密钥轮换时,
iss是区分它们的依据。从签发者地址自动发现配置时,框架通常会自动加上签发者校验;只配公钥时要自己加。 - exp:JWT 规范里
exp是可选的,默认的时间校验器在它缺失时放行。访问令牌应该总有过期时间,资源服务器可以要求它必须存在,防止签发端的配置错误产生永不过期的令牌。 - 时钟偏差:默认 60 秒。偏差越大,过期令牌能多用的时间越长;越小,服务器时钟不准时越容易误拒。
五、scope:按集合判断,不按子串
令牌里的 scope 是空格分隔的字符串,常见的错误是用字符串查找判断权限:
scope="orders.read orders.write":indexOf("orders.read") > 0 → false;按空格拆成集合精确匹配 → true
scope="profile orders.readonly":indexOf("orders.read") > 0 → true;按空格拆成集合精确匹配 → falseindexOf(...) > 0 同时犯了两个错:排在第一位的 scope 下标是 0,被漏判;orders.readonly 包含 orders.read 这个子串,被误判。按空格拆成集合再精确匹配才是对的;Spring Security 会把 scope 转成 SCOPE_orders.read 这样的权限,直接用它即可。
scope 表达的是「客户端被允许做哪一类操作」,不表达「这个用户能访问哪条数据」。即使网关已经校验了令牌和 scope,资源服务仍然要按令牌里的主体检查对象归属:用户 A 的令牌可以读订单,不代表可以读用户 B 的订单。
六、自包含令牌的撤销窗口
JWT 在资源服务器本地验签,不需要每次请求都问身份服务,代价是撤销不能立即生效:
撤销一个访问令牌之后:内省 active=false;资源服务器用 JWK 本地验签:接受(这一行来自授权码流程的实验。)令牌在过期之前一直有效。需要及时撤销的场景,可以缩短访问令牌的有效期、配合刷新令牌,或者对高风险操作改用内省;具体取舍见认证与系统集成。
七、检查清单
- 验签之外,校验
iss、aud、exp(要求存在)与时间偏差。 - 用
typ或受众区分访问令牌与 ID Token;身份服务签发at+jwt时,资源服务器显式接受它。 - 解码器只允许预期的签名算法,不接受
alg=none。 - scope 按集合精确匹配;对象级授权在资源服务里做。
- 知道本地验签的撤销窗口,按风险选择有效期或内省。
配套实验
- codesphere-labs/system-design/jwt-validation-checks:Spring Security 7.1.1、Nimbus JOSE+JWT 10.9.1、JDK 25.0.4.1:默认解码器与补齐签发者、受众、类型、exp 校验后的对比,算法混淆,scope 子串匹配(验证记录)
- codesphere-labs/system-design/oauth-code-flow-binding:撤销后内省与本地验签的对比、ID Token 交给只验签的解码器(验证记录)
参考资料