JWT 与 OAuth2:无状态认证实战

bee2026-10-0868 分钟0 次阅读
拆开 JWT 的三段结构、手写签发与校验、双 Token 刷新方案,再把 OAuth2 授权码流程一次讲通——前后端分离项目的标准认证方案。
1 / 147
小节
〇、30 秒看懂
2 / 147

先说人话:认证解决的是「你是谁」,授权解决的是「你能干什么、我能不能替你去拿别人的东西」。JWT 管前者,OAuth2 管后者——把它们混为一谈是这一篇最常见也最贵的误解。

3 / 147

传统做法是登录后服务端记一笔账(Session),浏览器只拿一张小票(JSESSIONID)。可服务一多就出问题:这次请求打到 A 节点,下次打到 B 节点,B 根本不认识你。JWT 换了个记账方向——把身份写进一张带防伪条的令牌,交给客户端随身带着走,服务端不再记账,只验防伪条。代价也很实在:这张令牌在过期前一直有效,想强退就得自己拉黑名单。

4 / 147

先给几个词一句话解释(全文都会用到):

5 / 147
  • 令牌(token):一张自证的凭证字符串,服务端看它就知道你是谁,不必回数据库
  • 签名 / 验签:用密钥对内容算一道摘要「盖章」;校验时用同一把密钥重算一遍比对,改一个字就对不上
  • Base64URL:一种编码(把二进制变成能放进 URL 的可读字符),不是加密——任何人都能还原
  • Claim(声明):Payload 里的一个个字段,比如 sub 是谁、exp 几点过期
  • Bearer(持有即有效):HTTP 认证方案的名字,意思就是「谁拿着这张票谁能进门」
  • 授权码(code):OAuth2 里那张一次性的取物单,几秒就作废,本身不能当钥匙用
6 / 147
类比

这一整篇可以浓缩成酒店的一套流程。Session 是你每次进房间都要前台查一遍登记簿(服务端记账,安全但慢、难扩容);JWT 是发给你一张印着房号和姓名、背面有防伪全息条的塑封卡(门卫扫一眼就放行,可卡丢了在有效期内没人拦得住,只能挂失进黑名单)。而 OAuth2 讲的是另一件事——你想让外卖 App 帮你开保险柜取东西,前台不会把房卡给它,而是给它一张一次性取物单,让它去后台找经理换钥匙(第 12 节会把这条类比走完七步)。

7 / 147
架构图
图 · Session(服务端记)vs JWT(客户端带)
图 · Session(服务端记)vs JWT(客户端带)
8 / 147

上面这张对照图就是本篇的主轴:左边好控难扩,右边好扩难控,第五节的双 Token 和第十一节的沙盘都是在补右边的短板。下面这张动图则先把 OAuth2 那条「取物单」链路放这儿,读到第 12 节再回来对照:

9 / 147
原理动画
动图 · OAuth2 授权码模式七步走
动图 · OAuth2 授权码模式七步走
10 / 147

学完这一篇,你应该能回答三个问题:

11 / 147
  • 一条 JWT 被改了 Payload 里的一个字符,服务端是在哪一步、靠什么发现异常的?
  • 用户「随机 401」该先怀疑算法、还是先怀疑各实例的密钥不一致?怎么用最便宜的方式验证?
  • 第三方登录时,为什么 client_secret 绝对不能出现在前端代码里?少了 state 会打开哪个漏洞?
12 / 147
小节
一、为什么是 JWT:先看 Session 的死穴
13 / 147

单机应用里,Session 是最好用的方案:登录后服务端在内存或 Redis 里存一份会话,浏览器拿一个 JSESSIONID Cookie。可一旦上了集群,问题就来了:用户这次请求打到 A 节点,下次打到 B 节点,B 节点根本不认识这个会话。

14 / 147
对照表
维度SessionJWT
状态存储服务端(内存 / Redis)客户端(令牌本身)
水平扩展需要会话共享(粘性会话或 Redis)天然无状态,任意节点可校验
注销 / 强退简单:删掉会话即可困难:令牌在过期前一直有效,需黑名单
失效控制实时可控只能等过期,或维护黑名单/版本号
每次请求开销一次存储查询一次签名校验(无 IO)
跨域友好度Cookie 受同源限制放在 Header,天然跨域
15 / 147

一句话取舍:Session 好控、难扩展;JWT 好扩展、难撤销。 前后端分离 + 微服务场景下,JWT 的无状态特性通常更划算。

16 / 147
小节
二、JWT 结构解剖:三段,不是加密
17 / 147
架构图
图 1 · 无状态认证链路
图 1 · 无状态认证链路
18 / 147

一个真实的 JWT 长这样,就是三个 Base64URL 段用 . 连接:

19 / 147
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6ImFsaWNlIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImV4cCI6MTczMDAwMDkwMH0.9xQ2...
20 / 147

把它拆开看:Header.Payload.Signature。前两段只是 Base64URL 编码,任何人都能解码,我们把每段还原成 JSON:

21 / 147
代码对照
代码json
// 第一段 Header:声明算法与类型{ "alg": "HS256", "typ": "JWT" }// 第二段 Payload:真正承载的数据(声明){  "sub": "1001",          // 主体,通常放用户 id  "name": "alice",  "roles": ["ROLE_USER"],  "iat": 1730000000,       // 签发时间  "exp": 1730000900,       // 过期时间(15 分钟后)  "jti": "a1b2c3d4"        // 令牌唯一 id,用于黑名单}// 第三段 Signature:对前两段的签名HMACSHA256(base64Url(header) + "." + base64Url(payload), secret)
解读

警告:Base64URL 是编码,不是加密。 把令牌贴到任何在线解码器,Payload 里的内容一览无余。因此绝不能在 JWT 里放密码、身份证号、完整手机号等敏感信息——它只是"防篡改"(有签名),不是"防偷看"(无加密)。

22 / 147

把它摊平就是下面这张解剖图:三段各司其职,前两段给所有人看,第三段才是防伪条——每一格都能对上刚才那句「编码不是加密」:

23 / 147
架构图
图 · JWT 三段解剖:两段明文,一段防伪
图 · JWT 三段解剖:两段明文,一段防伪
24 / 147

照着图再走一遍规律:把 Header 和 Payload 用点号拼起来,对整串算一次摘要得到 Signature;验签就是用同一把密钥重算一遍摘要再比对。接下来 2.1 节回答一个更基础的问题——这道防伪条到底防住了什么、又防不住什么。

25 / 147
小节
2.1 签名到底防的是什么
26 / 147

签名保证内容没有被篡改:只要有人改动 Payload 里的 sub 或 roles,签名立刻对不上。但它不保证内容保密,也不保证令牌不被复制——谁拿到令牌,谁就能冒充。这就是为什么 JWT 必须配 HTTPS,且过期时间要短。

27 / 147
小节
三、签名与校验:HS256 还是 RS256
28 / 147

签名算法分两大类,选错了会在微服务场景下踩坑:

29 / 147
对照表
算法类型密钥适用场景
HS256对称(HMAC)加签与验签共用同一密钥单体应用、单服务自己签发自己校验
RS256非对称(RSA)私钥签发、公钥验签授权方与多方资源方分离,公钥可公开分发
30 / 147

关键区别:HS256 的密钥一旦泄露,任何人都能伪造令牌;RS256 只有授权服务器持有私钥,资源服务器只用公钥验签,即使资源服务器被攻破也无法伪造令牌。微服务 / 开放平台优先 RS256。

31 / 147

下面用 JJWT 手写一套签发与校验工具类,先加依赖:

32 / 147
xml
<dependency>    <groupId>io.jsonwebtoken</groupId>    <artifactId>jjwt-api</artifactId>    <version>0.12.5</version></dependency><dependency>    <groupId>io.jsonwebtoken</groupId>    <artifactId>jjwt-impl</artifactId>    <version>0.12.5</version>    <scope>runtime</scope></dependency><dependency>    <groupId>io.jsonwebtoken</groupId>    <artifactId>jjwt-jackson</artifactId>    <version>0.12.5</version>    <scope>runtime</scope></dependency>
33 / 147

工具类完整实现:

34 / 147
代码对照
代码java
@Componentpublic class JwtUtils {    private final SecretKey key;          // HS256 密钥,从配置读取,至少 256 位    public JwtUtils(@Value("${jwt.secret}") String secret) {        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));    }    /** 签发:标准声明 + 自定义声明 */    public String issue(Long userId, List<String> roles, Duration ttl) {        Instant now = Instant.now();        return Jwts.builder()                .subject(String.valueOf(userId))                .claim("roles", roles)                .id(UUID.randomUUID().toString())      // jti,用于黑名单                .issuedAt(Date.from(now))                .expiration(Date.from(now.plus(ttl)))                .signWith(key, Jwts.SIG.HS256)                .compact();    }    /** 校验:验签 + 验过期,失败抛异常 */    public Claims parse(String token) {        return Jwts.parser()                .verifyWith(key)                .build()                .parseSignedClaims(token)     // 签名不对或已过期都会抛 JwtException                .getPayload();    }}
解读
  • signWith(key, HS256) 决定算法;换 RS256 时用 RSAPrivateKey 签发、RSAPublicKey 验签。
  • parseSignedClaims 一步完成验签与过期校验,不要自己解析 Payload 再手动比对时间——那是重造轮子且容易漏掉签名校验。
35 / 147
小节
四、标准声明与自定义声明
36 / 147

JWT 的 Payload 里,一部分字段是标准定义的(RFC 7519),一部分是业务自定义的。滥用会带来安全和兼容问题:

37 / 147
对照表
声明含义建议
iss签发者多系统时用来区分令牌来源
sub主体放用户 id,不要放用户名
exp过期时间必须设置,业务令牌越短越安全
iat签发时间用于计算是否需刷新
jti唯一 id注销 / 防重放的黑名单键
roles / userId自定义业务字段只放授权必需的、非敏感的数据
38 / 147
要点

绝不要放进 JWT 的清单——密码或哈希、身份证号、完整手机号/邮箱、密钥、任何"泄露了会直接造成损失"的信息。JWT 是明文可见的。

39 / 147
小节
五、双 Token 方案:短令牌 + 长刷新
40 / 147

单 token 的死结是:设短了用户频繁掉线,设长了被盗风险大。业界标准解法是双 Token:

41 / 147
  • accessToken:短(如 15 分钟),每次业务请求携带,泄露了影响窗口也小。
  • refreshToken:长(如 7 天),只用来换取新的 accessToken,不参与业务请求。
42 / 147
原理动画
动图 · 双 Token 生命周期
动图 · 双 Token 生命周期
43 / 147

刷新流程的四个关键点:

44 / 147
  1. access 过期,服务端返回 401,前端拦截器捕获后静默调用 /auth/refresh。
  2. 服务端校验 refreshToken 有效且未被撤销,签发一对全新的 access + refresh。
  3. 轮换(rotation):旧 refreshToken 立即失效。这样即使 refreshToken 被盗,攻击者用过一次后真正的用户就会失败,从而触发告警。
  4. 注销:把 refreshToken 的 jti 写入 Redis 黑名单,TTL 设为令牌剩余有效期;校验时先查黑名单。
45 / 147
代码对照
代码java
@Servicepublic class TokenService {    private final JwtUtils jwtUtils;    private final StringRedisTemplate redis;    /** 刷新:校验 + 轮换,旧 refresh 立即作废 */    public TokenPair refresh(String refreshToken) {        Claims claims = jwtUtils.parse(refreshToken);          // 验签 + 验过期        String jti = claims.getId();        if (Boolean.TRUE.equals(redis.hasKey("jwt:blacklist:" + jti))) {            throw new BusinessException("刷新令牌已失效");        // 已被轮换或注销        }        long remain = claims.getExpiration().getTime() - System.currentTimeMillis();        redis.opsForValue().set("jwt:blacklist:" + jti, "1", remain, TimeUnit.MILLISECONDS);        Long userId = Long.valueOf(claims.getSubject());        List<String> roles = claims.get("roles", List.class);        return new TokenPair(                jwtUtils.issue(userId, roles, Duration.ofMinutes(15)),                jwtUtils.issue(userId, roles, Duration.ofDays(7)));    }}
解读
  • 黑名单的 TTL 必须等于令牌剩余有效期,否则 Redis 会无限膨胀。
  • 轮换后旧 refresh 立即进黑名单,一次刷新用完即弃,这是双 Token 安全性的核心。
46 / 147

黑名单、令牌有效期、密钥位置——这几件事最终都要落在配置里。下面这台生成器把「无状态认证最少要配哪几行」做成勾选:先只勾「Redis」得到能存黑名单的最小片段,再叠加日志、Actuator 与多环境 profile,对照上面那段 refresh 代码逐行找它的配置来源:

47 / 147
生成器
生成器把无状态认证的配置勾全application.yml2 / 5
先只勾 redis 看黑名单与轮换需要的最小片段;logging 让验签失败与黑名单命中在日志里可查;actuator 暴露指标前先想好暴露面;profile 演示同一份配置怎么按 dev / prod 分开——生产那份的 jwt.secret 请从环境变量注入
产物
server:
  port: 8080

spring:
  application:
    name: demo-service
  data:
    redis:
      host: ${REDIS_HOST:127.0.0.1}
      port: 6379
      timeout: 3000ms
      lettuce:
        pool: { max-active: 16, max-idle: 8, min-idle: 2, max-wait: 2000ms }

logging:
  level:
    root: INFO
    com.example.demoservice: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: DEBUG   # 打 SQL 与参数
  file:
    name: logs/app.log
  logback:
    rollingpolicy: { max-file-size: 50MB, max-history: 14 }
勾了这些,代价与理由在这里
redisBoot 3 默认不启用池;要用 lettuce.pool.* 必须引 commons-pool2。
logging级别可按包精细控制;logging.level.root=DEBUG 会把三方库全打爆,别在生产这么干。
48 / 147
警告

Actuator 的 exposure.include: '*' 会把 /actuator/env 一起打开——jwt.secret 在那里是明文。生成出来的 actuator 片段请手工改成白名单,并避开 env 与 configprops。

49 / 147
小节
六、JWT 过滤器:把令牌接回安全链
50 / 147

有了令牌,还需要一个过滤器把它翻译成 Spring Security 认识的身份。用 OncePerRequestFilter 保证每次请求只执行一次:

51 / 147
代码对照
代码java
@Componentpublic class JwtAuthFilter extends OncePerRequestFilter {    private final JwtUtils jwtUtils;    public JwtAuthFilter(JwtUtils jwtUtils) {        this.jwtUtils = jwtUtils;    }    @Override    protected void doFilterInternal(HttpServletRequest request,                                    HttpServletResponse response,                                    FilterChain chain) throws ServletException, IOException {        String header = request.getHeader("Authorization");        if (header != null && header.startsWith("Bearer ")) {            String token = header.substring(7);            try {                Claims claims = jwtUtils.parse(token);          // 验签 + 验过期                Long userId = Long.valueOf(claims.getSubject());                List<String> roles = claims.get("roles", List.class);                var authorities = roles.stream()                        .map(SimpleGrantedAuthority::new)                        .toList();                var auth = new UsernamePasswordAuthenticationToken(userId, null, authorities);                SecurityContextHolder.getContext().setAuthentication(auth);   // 放入上下文            } catch (JwtException e) {                SecurityContextHolder.clearContext();           // 无效令牌:保持匿名            }        }        chain.doFilter(request, response);      // 继续走链    }}
解读
  • OncePerRequestFilter 保证异步 / 内部转发时不重复执行。
  • 解析失败时只清空上下文、不抛异常,让后续的授权过滤器按"匿名用户"处理——这样公共接口仍可访问。
  • 这个过滤器通过上一篇的 addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) 挂上链。

坑:不要因为令牌无效就在这里直接 return 并写 401 响应——那样连 permitAll 的公共接口也会被拦。正确做法是保持匿名、放行到链尾,交给授权过滤器裁决。

52 / 147

上面那段过滤器代码里,最容易被忽略的是失败路径:过期令牌不会在这里报错,而是被「翻译」成匿名身份继续往下走。下面这台单步台把一条过期令牌在过滤器里的完整走位拆成逐帧——注意第 ③ 步抛出异常、第 ⑤ 步又把它悄悄抹平:

53 / 147
单步调试台
单步台一条过期令牌在过滤器里的完整走位1 / 6
从 ① 走到 ⑥:第 ③ 步抛出异常、第 ⑤ 步却悄悄消失——看懂这一步,就懂为什么错的写法会把 401 变成 500
被调试的代码
1String header = request.getHeader("Authorization");
2if (header != null && header.startsWith("Bearer ")) {
3 String token = header.substring(7);
4 try {
5 Claims claims = jwtUtils.parse(token);
6 ...放入 SecurityContext...
7 } catch (JwtException e) {
8 SecurityContextHolder.clearContext();
9 }
10}
11chain.doFilter(request, response);
此刻的变量
AuthorizationBearer eyJhbGci…(已过期)
调用栈
—
1过滤器每请求执行一次,先从请求头取 Authorization。注意这里只是读,还没决定信不信。
54 / 147
小节
七、常见攻击面与防御
55 / 147
对照表
攻击原理防御
算法混淆 / none 攻击攻击者把 alg 改成 none,或把 RS256 换成 HS256 用公钥当密钥服务端固定期望算法,校验 alg 头,禁用 none
密钥泄露secret 硬编码进代码或提交进 Git密钥走配置中心/环境变量,定期轮换
重放截获有效令牌反复使用短过期 + jti 黑名单,敏感操作加一次性 nonce
XSS 窃取令牌存 localStorage,被恶意脚本读走存 HttpOnly Cookie(但需重开 CSRF),或严格 CSP
令牌伪造弱密钥被爆破出签名密钥足够长(HS256 ≥ 256 位),优先 RS256
56 / 147

攻击面表里每一行都可能倒在链路的不同关卡。把一条令牌从签发到抵达业务代码要过的关卡画成流程图:签字、时间、撤销、授权四道关,缺一道就是一条事故路径——点哪一关,就对应表里的哪一种攻击:

57 / 147
交互图解
流程令牌要过几关才见到业务代码1 / 6
从 ① 点到 ⑥:每一关拒绝时的样子都不同,先分清「身份不成立」和「权限不够」再谈怎么修
→
→
→
→
→
① 签发:密钥盖戳
登录成功后用密钥对 header.payload 算摘要,写出三段令牌。第 ③ 节讲过密钥长度要求:HS256 至少 256 位,太短直接被拒绝。
全部看懂了四道关的失败各有专属异常:验签失败找密钥与篡改,时间失败查时钟与过期,黑名单命中查重放与轮换,走到 403 才轮到角色配置。
58 / 147
小节
八、OAuth2 授权码流程:别和 JWT 搞混
59 / 147

最重要的认知:OAuth2 是"授权协议",JWT 是"令牌格式",二者不是一回事。 OAuth2 解决的是"如何让第三方应用在用户授权下访问资源",它颁发的令牌可以是 JWT,也可以是一串不透明字符串。

60 / 147

授权码流程(Authorization Code)是最安全的模式,适合有后端的应用,七步走完:

61 / 147
  1. 前端把用户重定向到授权服务器,带上 client_id、redirect_uri、scope、state。
  2. 用户在授权页登录并同意授权。
  3. 授权服务器回调 redirect_uri,带上一次性 code 和原样返回的 state。
  4. 后端用 code + client_secret 向授权服务器换取 token(此步在服务端进行,client_secret 不暴露给浏览器)。
  5. 后端拿到 accessToken(可能还有 refresh 和 idToken)。
  6. 后端用 accessToken 调资源服务器拉取用户信息(如 GitHub /user)。
  7. 后端把用户信息映射成本系统的用户,签发本系统的会话或 JWT——至此用第三方登录完成了本站认证。
62 / 147

让第三方登录接进本系统的关键配置项:

63 / 147
对照表
配置项作用示例
client_id应用标识Iv1.abc123
client_secret应用密钥(仅后端持有)****
authorization-uri用户跳转授权页的地址https://github.com/login/oauth/authorize
token-uri用 code 换 token 的地址https://github.com/login/oauth/access_token
user-info-uri拉取用户信息的地址https://api.github.com/user
redirect-uri授权后的回调地址https://app.example.com/login/oauth2/code/github
scope申请的权限范围read:user user:email
64 / 147
内核实验
TeaVM拦截即验证:过滤器与代理的协作未启动
思考 JWT 校验为什么必须发生在业务方法之前
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
65 / 147
小节
九、三个最容易翻车的坑
66 / 147
坑

时钟偏移导致令牌校验失败。 集群中不同机器的系统时间差了几分钟,A 机签发的令牌在 B 机被判定为"尚未生效"或"已过期"。解决:部署时启用 NTP 同步,或在 JJWT 中配置 clockSkewSeconds 容忍少量偏差。

67 / 147
坑

分布式下密钥必须统一。 多实例各自用不同的 secret,用户在 A 实例登录拿到的令牌,到 B 实例验签直接失败,表现为"随机 401"。密钥要么集中配置分发,要么用 RS256 让所有节点共享公钥。

68 / 147

「随机 401」这个词表里最贵的坑,值得一条动图钉住——注意第 ⑤ 帧那个「重启又好了」的假象,它每天都在骗人:

69 / 147
原理动画
动图 · 随机 401 是怎么发生的
动图 · 随机 401 是怎么发生的
70 / 147

看动图时对照上面那条坑的修法:要么把所有实例的 jwt.secret 集中分发,要么换 RS256 让私钥只留在签发方、各节点共享公钥——「重启就好了」从来不是修好了,只是请求又落回了签发它的那个实例。

71 / 147
坑

别把 token 当 session 存进 Redis,又想要无状态。 如果你每请求都去 Redis 查一次会话,那你得到的只是"用 Redis 模拟的 Session",白白丢掉了 JWT 无状态的好处。黑名单是必要的例外,但它只存"撤销名单",不是每个令牌都存。

72 / 147
决策
决策SPA 项目里,JWT 到底该存 localStorage 还是 HttpOnly Cookie?
73 / 147
小节
十、把 JWT 摊在手上拆一遍
74 / 147

前九节都在「讲」,这一节开始「摸」。先补一张开篇没给的生活类比,它决定了你对 JWT 的第一直觉对不对:

75 / 147
类比

JWT 像一张塑封的工作证:正面印着照片、姓名、部门(Payload),背面有一道防伪全息条(Signature)。门卫不需要打电话回总部确认——他只要扫一眼防伪条是否完好,就敢放你进去。这也解释了 JWT 的两条铁律:① 证件是给人看的,所以不能印机密(Base64URL 只是塑封,不是保险箱);② 谁捡到证件谁能进门,所以有效期要短、丢了要立刻挂失(黑名单)。

76 / 147
小节
10.1 签发与校验:同一段字符串的两种命运
77 / 147

先把第二节的三段结构变成能按的按钮。下面这个实验会真的把 header.payload.signature 拼出来、再拆开验给你看:

78 / 147
内核实验
TeaVM亲手签一张 JWT,再亲手验它未启动
先用「签发」看三段怎么长出来,再用「校验」看服务端到底比对了什么
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
79 / 147

跑完这两步你会得到一个关键认知:签发和校验用的是同一份密钥、同一套算法、相反的方向。签名 = 用密钥把前两段的摘要「盖章」;验签 = 用同一密钥重算一遍摘要,比对章是否一致。服务端从来不查数据库就知道你是谁——这正是第一节表格里「每次请求开销:一次签名校验(无 IO)」的由来。

80 / 147

紧接着两个实验是安全链路上最常出事的地方,务必切到指定参数看:

81 / 147
内核实验
TeaVM你的令牌是在哪一层被认出来的未启动
选「过滤器顺序全景」看清 Bearer 令牌在哪一跳被换成 SecurityContext;再选「401 与 403 的区别」搞懂「没登录」和「没权限」的分界
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
82 / 147
内核实验
TeaVMjwt.secret 到底由谁说了算未启动
选「谁覆盖谁」看命令行参数、环境变量、application.yml 的压制顺序——线上「随机 401」有相当一部分是这个原因:某个实例的 secret 被别人覆盖了
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
83 / 147
小节
10.2 篡改、过期、刷新:三种失败各不相同
84 / 147

第八节的坑讲的是「理论上的翻车」,现在把它们逐个演出来。注意这三段实验的失败点完全不同,生产上对应三种不同的告警:

85 / 147
内核实验
TeaVM改一个字符会怎样未启动
选「篡改载荷」:把 roles 里的 USER 偷偷改成 ADMIN,观察验签在哪一步炸掉——这就是第七节「算法混淆」防线的原理
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
86 / 147
内核实验
TeaVM过期不是失败,是设计未启动
选「过期」:exp 一过,服务端连业务代码都不用进就被拒了;顺带理解为什么 accessToken 只有 15 分钟
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
87 / 147
内核实验
TeaVM双 Token 轮换全过程未启动
选「双令牌刷新」:旧 refresh 换到新 pair 的瞬间就被拉黑,攻击者手里的旧令牌只能作废一次
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
88 / 147

最后补一段异常视角。令牌校验失败会以什么形态出现在响应里?取决于你的异常解析器接管到哪一层:

89 / 147
内核实验
TeaVM401 响应体是怎么拼出来的未启动
选「@ControllerAdvice 兜底」看统一返回结构;再选「没人接管 → 500」——如果你的 JWT 过滤器抛异常没接住,前端看到的就是一句裸 500
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
90 / 147

三个失败现场演完了,换命令行再敲一遍。下面这台控制台连着浏览器里的同一个内核——先 whoami 确认匿名身份,再 lab jwt 走一遍签发、校验与篡改,最后 lab sec deny 看 401 与 403 的分岔:

91 / 147
内核控制台
92 / 147
提示

一对值得对比着敲的命令是 lab jwt verify 与 lab jwt tamper——同一个接口、同一段代码,一个回显身份,一个在验签处炸掉;这正是第七节攻击面表第一行「算法混淆 / 篡改」防线的现场版。

93 / 147
小节
十一、沙盘:过期时间这根弹簧
94 / 147

access 令牌的生命周期是 JWT 里唯一一个「纯取舍」的参数:设短了用户抱怨,设长了攻击者喜欢。下面这个沙盘把它做成一根可以拉的弹簧,三档之间同时看四组指标——这是本节所有理论的落地点:

95 / 147
沙盘
沙盘access 令牌该活多久
运行结果
用户感知:401 几乎无感(refresh 提前 2 分钟续期即可)
refresh 调用量 QPS:0.9
被盗令牌可用窗口:≤ 15 分钟
Redis 黑名单写入:1.1k key/小时
审计口径:单点强退最长延迟 15 分钟
业界常见的平衡点:够短以限制泄露损失,够长以不打扰正常操作。
96 / 147
说明

数字是示意的,但两条结论是真的——过期时间决定的是「被盗后的损失窗口」,不是「用户体验」,体验靠双 Token 与轮换解决;一旦你想让长令牌可撤销,就必须把每个 jti 都存起来,此时你已经回到了 Session 模型(第九节第三个坑)。

97 / 147

再把这条「客户端自带身份」的链路画回真实请求上——下面这张图就是第 6 节过滤器代码在整条链里的位置,盯住「客户端携带 Bearer 调用」和「JWT 过滤器解析校验」这两格:令牌由客户端主动带上,服务端不必为每个请求去查一次「这人是谁」(第五节的黑名单只查「这张票挂失过没有」,是例外而非记账):

98 / 147
架构图
图 · 无状态认证链路
图 · 无状态认证链路
99 / 147
类比

Session 像图书馆的借书登记台——书(身份)在你手上,但馆里那本登记簿才是权威,管理员随时能划掉一行(强退),代价是每个读者进门都得让管理员翻一次簿子(查存储)。JWT 像自带防伪标签的门票——检票员只看票不看簿子,人流再大也不堵门(好扩展),可这张票一旦被抄走,在有效期内没人拦得住你(难撤销)。

100 / 147
小节
十二、OAuth2 授权码模式:第三方登录的时间轴
101 / 147

第八节给了七步清单,这里把它动化。先记一句类比,整条流程就不会背反:

102 / 147
类比

授权码模式像酒店前台替你开保险柜:你把房卡号(client_id)报给第三方 App,App 并不能凭它打开任何东西;前台让你本人去大堂屏幕输密码同意(用户在授权服务器登录并同意),然后塞给 App 一张一次性取物单(code);App 拿着取物单去后台找经理(后端带 client_secret 换 token),经理核对身份后才把真正的钥匙(accessToken)交给它。全程没有任何人把你的房卡(账号密码)递给第三方——这就是「授权」而非「认证」的含义。

103 / 147
原理动画
动图 · OAuth2 授权码模式七步走
动图 · OAuth2 授权码模式七步走
104 / 147

对着动图把最容易问倒的三个细节钉死:

105 / 147
对照表
环节常被追问答案
state少传了会怎样回调可被伪造(CSRF):攻击者诱导你用他的授权结果登录成他的账号
code为什么要「一次性 + 走浏览器」code 泄露的损失窗口只有几秒,且没有 client_secret 换不出 token
第 4 步为什么不能让前端直接拿 code 换 token那样 client_secret 必须写进 JS,人人可见;这一步只能在服务端做
106 / 147

Spring Boot 侧对应的配置写法(Boot 3 / Spring Security 6,属性名必须逐字照抄):

107 / 147
代码对照
代码yaml
spring:  security:    oauth2:      client:        registration:          github:            client-id: ${GITHUB_CLIENT_ID}          # 走环境变量,绝不硬编码            client-secret: ${GITHUB_CLIENT_SECRET}            scope: read:user, user:email             # 只申请必需的权限        provider:          github:            authorization-uri: https://github.com/login/oauth/authorize            token-uri: https://github.com/login/oauth/access_token            user-info-uri: https://api.github.com/user
解读

提示:redirect-uri 不在这里配,而是由 Spring Security 按约定生成 /login/oauth2/code/{registrationId},你需要把它原样填进 GitHub 的 OAuth App 设置里。两边差一个斜杠就是 redirect_uri_mismatch——见下一节的速查表。

108 / 147

这一篇名词密度高,最容易混的就是「格式」与「协议」、「凭证」与「范围」。来玩一局:先点左边,再点它在整条链路里的角色——配错了当场告诉你为什么:

109 / 147
配对闯关
闯关六个词配它们在这条链路里的角色已配对 0/6 · 配错 0
JWT 和 OAuth2 是两种东西,code 与 client_secret 也别混——两列都打乱了,别靠位置猜
先点左边一个
110 / 147
小节
十三、随堂自测
111 / 147

先来一道热身题,考的是第二节那条最容易记反的边界:

112 / 147
随堂自测
随堂自测JWT 第三段 Signature 到底保证了什么?
先自己选一个,选中立刻告诉你对不对
113 / 147

再来一道综合题,把第五节轮换、第七节攻击面和第十节三段实验串起来:

114 / 147
随堂自测
随堂自测刷新接口发现某个 refreshToken 的 jti 已在 Redis 黑名单里。下列说法正确的是?
先自己选一个,选中立刻告诉你对不对
115 / 147
小节
十四、常见报错速查
116 / 147

下面每一行的「报错原文」都可以整段复制去搜索,别意译、别缩写。新手在这三个地方最容易卡住:验签失败、令牌过期、密钥被人看见。

117 / 147
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
io.jsonwebtoken.security.SignatureException: JWT signature does not match locally computed signature. Validity cannot be determined.三类原因之一:① Payload 被改过一个字符;② 校验用的 secret 与签发时不是同一份(多实例各自不同、或某实例的配置被环境变量覆盖);③ 前端把 Bearer 前缀之外的空格带上了先在服务端打印签发与校验两侧的密钥指纹(MessageDigest 取 SHA-256 摘要,不要打印原文)比对;再确认所有实例的 jwt.secret 完全一致本篇第十节 prop 实验 · #38 Spring Security 基础
io.jsonwebtoken.ExpiredJwtException: JWT expired N seconds ago. Current time: ... Expiration: ...令牌确实过期(正常业务),或集群机器时钟偏移超过有效期前端拦截 401 后走 refresh;服务端加 NTP 同步,并在 JJWT 解析端容忍少量偏差(clockSkewSeconds)本篇第九节第一个坑 · 第十一节沙盘
把令牌贴到 Base64 解码网站,明文里看到了手机号/邮箱/内部字段Payload 从来就不是加密的,Base64URL 谁都能还原;密钥若写在代码里并被打包进 jar,还能被反编译后自行签发立刻做两件事:① 从 JWT 里删掉这些字段,改为只放 id、由服务端查库;② 密钥移出 Git 历史(用配置中心/环境变量),并轮换一次本篇第四节要点 · #41 打包部署与安全
抓包看到请求头里 alg: "none" 的令牌居然通过了校验用了老的、不校验算法的解析方式,或自己手写解析只比对 payload 不管签名——这是经典的算法混淆攻击面固定期望算法(JJWT 用 parseSignedClaims + verifyWith(key)),显式拒绝 none;升级 jjwt 依赖版本本篇第七节攻击面表
java.lang.IllegalArgumentException: Key length must be at least 256 bits for HS256Keys.hmacShaKeyFor() 要求密钥字节数 ≥ 32,随手写的 "secret123" 太短生成 32 字节以上随机串:openssl rand -base64 48,放进配置而不是代码本篇第三节工具类 · 第一档练习
invalid_grant / code_to_token_exchange_failed(换 token 那一步失败)code 已被用过(它是一次性的)、已过期,或 redirect_uri 与授权请求里那条不完全一致每次登录都重新走一遍授权;把两处 redirect_uri 复制到文本对比工具逐字符核对本篇第十二节时间轴
GitHub 回调页显示 redirect_uri_mismatchOAuth App 设置里登记的回调地址与应用实际使用的地址不一致(协议、端口、结尾斜杠都算差异)在 GitHub App 设置里原样添加 http://localhost:8080/login/oauth2/code/github,本地调试也要登记本篇第十二节配置块
同一个用户「有时能用、随机 401」,重启后又好了多实例的 secret 不一致(各节点配置源不同),令牌只在签发它的那个节点验得过集中分发密钥,或改用 RS256:私钥只在签发方,各资源节点只持公钥本篇第九节第二个坑 · #37 JWT 与 OAuth2 前置的安全篇
刷新接口偶发 刷新令牌已失效,用户被踢下线轮换机制生效了:旧 refresh 已被拉黑却被重放——可能是同一页面开了两份 localStorage 各自持有一个旧值,也可能是真被窃取保留这条告警并按 jti 审计;前端把 refresh 收敛到单一存储位置,并且并发 401 只触发一次刷新本篇第五节轮换 · jwt 实验 refresh 参数
118 / 147
提示

这一节里出现频率最高的不是过期,而是「signature does not match」+「随机 401」这对组合——九成是多实例密钥不统一或配置优先级打架造成的,先看第十一节 prop 实验和上面的沙盘,别再怀疑算法本身。

119 / 147

速查表第一行那个 SignatureException 已经见过很多次了,但生产上还有一对更迷惑人的组合——过期令牌遇上漏配的授权规则,异常被伪装成 500。下面这段是真实堆栈,先别看答案:

120 / 147
报错急救
报错急救java.lang.NullPointerException
过期令牌为什么变成了一句 500:凶手在你自己的包里

用户在页面上放了两小时,点「取消订单」返回 500,日志里既没有 401 也没有 ExpiredJwtException,只有一句 NullPointerException。同一个接口重新登录后立刻正常——先把凶手行找出来。

java.lang.NullPointerException: Cannot invoke "com.bee.order.domain.User.getId()" because "user" is null
at com.bee.order.web.OrderController.cancel(OrderController.java:41)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
at com.bee.order.security.JwtAuthFilter.doFilterInternal(JwtAuthFilter.java:71)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
121 / 147
小节
十五、动手练习
122 / 147
小节
第一档 · 照做
123 / 147

目标:30 行代码跑通「签发 → 校验 → 篡改 → 过期」四种结局,并留下能对得上的日志证据。

124 / 147

第一步,建一个最小 Maven 工程(pom.xml 只要这三个依赖,Java 17):

125 / 147
xml
<dependencies>    <dependency>        <groupId>io.jsonwebtoken</groupId>        <artifactId>jjwt-api</artifactId>        <version>0.12.5</version>    </dependency>    <dependency>        <groupId>io.jsonwebtoken</groupId>        <artifactId>jjwt-impl</artifactId>        <version>0.12.5</version>        <scope>runtime</scope>    </dependency>    <dependency>        <groupId>io.jsonwebtoken</groupId>        <artifactId>jjwt-jackson</artifactId>        <version>0.12.5</version>        <scope>runtime</scope>    </dependency></dependencies>
126 / 147

第二步,写这个类(src/main/java/com/example/jwt/JwtDemo.java):

127 / 147
java
package com.example.jwt;import io.jsonwebtoken.*;import io.jsonwebtoken.security.Keys;import java.nio.charset.StandardCharsets;import java.time.Duration;import java.time.Instant;import java.util.Date;import java.util.List;import java.util.UUID;import javax.crypto.SecretKey;public class JwtDemo {    // 生产环境请从环境变量读取;这里为演示写死,长度必须 ≥ 32 字节    private static final SecretKey KEY = Keys.hmacShaKeyFor(            "demo-secret-demo-secret-demo-secret-32b".getBytes(StandardCharsets.UTF_8));    private static String issue(Duration ttl) {        Instant now = Instant.now();        return Jwts.builder()                .subject("1001")                .claim("roles", List.of("ROLE_USER"))                .id(UUID.randomUUID().toString())                .issuedAt(Date.from(now))                .expiration(Date.from(now.plus(ttl)))                .signWith(KEY, Jwts.SIG.HS256)                .compact();    }    private static void parse(String label, String token, SecretKey verifyKey) {        try {            Claims c = Jwts.parser().verifyWith(verifyKey).build()                    .parseSignedClaims(token).getPayload();            System.out.println(label + " -> OK  sub=" + c.getSubject()                    + " roles=" + c.get("roles", List.class));        } catch (ExpiredJwtException e) {            System.out.println(label + " -> ExpiredJwtException: " + e.getMessage());        } catch (SignatureException e) {            System.out.println(label + " -> SignatureException: JWT signature does not match");        } catch (JwtException | IllegalArgumentException e) {            System.out.println(label + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage());        }    }    public static void main(String[] args) {        String good = issue(Duration.ofMinutes(15));        System.out.println("token segments = " + good.split("\\.").length);        parse("① 正常令牌      ", good, KEY);        String[] parts = good.split("\\.");        // 把第二段(Payload)末尾换一个字符,等同于改了 roles        String tamperedPayload = parts[1].substring(0, parts[1].length() - 1)                + (parts[1].endsWith("A") ? "B" : "A");        parse("② 篡改过的令牌  ", parts[0] + "." + tamperedPayload + "." + parts[2], KEY);        parse("③ 已过期的令牌  ", issue(Duration.ofSeconds(-1)), KEY);        // 换一把「只差最后一个字符」的密钥去验同一张令牌:模拟另一个实例的 secret        SecretKey otherKey = Keys.hmacShaKeyFor(                "demo-secret-demo-secret-demo-secret-32C".getBytes(StandardCharsets.UTF_8));        parse("④ 换一把密钥    ", good, otherKey);    }}
128 / 147

运行 main,预期输出(四条分支各命中一种结局,顺序可能因排版略有差异):

129 / 147
text
token segments = 3① 正常令牌       -> OK  sub=1001 roles=[ROLE_USER]② 篡改过的令牌   -> SignatureException: JWT signature does not match③ 已过期的令牌   -> ExpiredJwtException: JWT expired 1 milliseconds ago. ...④ 换一把密钥     -> SignatureException: JWT signature does not match
130 / 147

验收清单:① 你能指出第 ② 行是第三段变了还是第二段变了导致失败(答案:第二段变、第三段没变,所以重算出的摘要与带来的摘要不符);② 把 issue 的密钥换成 31 字节,程序直接在 Keys.hmacShaKeyFor 处抛 IllegalArgumentException: Key length must be at least 256 bits for HS256——你就复现了速查表第 5 条;③ 说得出 ③ 与 ④ 两种失败在生产上分别对应哪种告警。

131 / 147
小节
第二档 · 变体
132 / 147

每次只改一处,观察结论立刻翻转:

133 / 147
  1. 把 parse 里的 .verifyWith(KEY) 换成一个只差最后一个字符的密钥。你会观察到:所有原本正常的令牌全部变成 SignatureException,而应用启动、其他接口一切如常——这正是「多实例 secret 不一致 → 随机 401」的最小复现。修法见第九节第二个坑。
  2. 把 issue(Duration.ofMinutes(15)) 改成 Duration.ofSeconds(-1) 作为唯一的 access 令牌,然后用第十一节沙盘验证:你会观察到 refresh 调用量从 0.9 QPS 飙到 3.4 QPS,用户侧「一直要我重新登录」。这就是「过期时间不是体验问题,是损失窗口问题」的反证。
  3. 手工构造一个 Header 为 {"alg":"none","typ":"JWT"} 且不带第三段的令牌(Base64URL 编码后拼成 header.payload.)发给你的接口。你会观察到:现代 JJWT 的 parseSignedClaims 直接拒绝并报算法相关错误;但若你曾手写「先解 payload 再自己比时间」的代码,它会当成合法令牌放行——这就是第七节算法混淆攻击面的成因。
  4. 给 JwtAuthFilter 的 catch (JwtException e) 里加一行 throw new ServletException(e)。你会观察到:公共接口(permitAll)也一并变成 500/401——第六节那个「坑」你只要一行代码就能亲手制造。
134 / 147

提示:做完第 1 条回头看 prop 实验的「谁覆盖谁」,两边结论应当完全对得上。

135 / 147
小节
第三档 · 造一个
136 / 147

给自己写一个「令牌体检」小 CLI(纯 main 方法即可,不必开 Web),以后任何一条 JWT 都能三秒钟看清它的底细。

137 / 147

需求:

138 / 147
  • 输入一个令牌字符串,输出三段各自解码后的 JSON(Header / Payload / Signature 原文长度)
  • 输出标准声明检查表:有没有 exp、iat 与 exp 的间隔是多少、sub 是否为纯数字、是否存在疑似敏感字段(键名含 password / phone / idCard / email / secret)
  • 用给定密钥验签,明确区分三种结果:验签通过 / SignatureException / ExpiredJwtException,并支持 --skew=60 容忍 60 秒时钟偏移后重新判定
  • 检测并拒绝 alg=none 与非 HS256/RS256 的算法,打印警告
  • 可选:把 Payload 里出现的角色字段与本工具内置的白名单(ROLE_USER / ROLE_ADMIN)比对,发现越权字样(如 role=admin&isSuper=true)时高亮提示
139 / 147

验收清单:① 用第一档生成的令牌跑一遍,exp 间隔应打印 15 分钟;② 把令牌中间任意一个字符改掉再跑,工具必须报 SignatureException 而不是崩栈;③ 故意传入一个不含 exp 的令牌,体检报告要把这条标为高危;④ 传入 alg=none 构造的令牌,工具必须在验签之前就拒绝并说明原因;⑤ 全流程不修改业务代码一行。

140 / 147
小节
十六、要点自查
141 / 147
自检

不看上文,说出 JWT 三段各自的内容与作用,并解释「为什么改了 Payload 就一定过不了验签」——答案要落在「服务端会用同一密钥重算摘要」这句话上。

142 / 147
自检

SignatureException: JWT signature does not match 在生产上有哪三类诱因?其中哪一类在多实例部署下表现为「随机 401」?

143 / 147
自检

为什么说「Base64URL 是编码不是加密」?如果密钥不小心提交进了 Git,攻击者拿到源码目录之外还能做什么?

144 / 147
自检

双 Token 里的「轮换(rotation)」解决了什么单靠短过期时间解决不了的问题?旧 refresh 被重放时你希望看到什么日志?

145 / 147
自检

OAuth2 授权码模式的第 4 步为什么必须在服务端完成?state 参数少了会打开哪个漏洞?

146 / 147
口诀

三段明文一张防伪证——签发盖章、验签重算;短的管损失窗口、长的管在线体验;授权码是取物单,secret 永不进浏览器。

147 / 147
总结

本节只须记住三句话——JWT 是防篡改的明文令牌,别放敏感信息;双 Token 靠轮换和黑名单解决"长期在线"与"可撤销"的矛盾;OAuth2 是授权协议、JWT 是令牌格式,先想清楚要解决的是"认证"还是"授权给第三方"。这三句话理清,你就能在 Session、JWT、OAuth2 之间做出正确选择。