JWT 与 OAuth2:无状态认证实战
先说人话:认证解决的是「你是谁」,授权解决的是「你能干什么、我能不能替你去拿别人的东西」。JWT 管前者,OAuth2 管后者——把它们混为一谈是这一篇最常见也最贵的误解。
传统做法是登录后服务端记一笔账(Session),浏览器只拿一张小票(JSESSIONID)。可服务一多就出问题:这次请求打到 A 节点,下次打到 B 节点,B 根本不认识你。JWT 换了个记账方向——把身份写进一张带防伪条的令牌,交给客户端随身带着走,服务端不再记账,只验防伪条。代价也很实在:这张令牌在过期前一直有效,想强退就得自己拉黑名单。
先给几个词一句话解释(全文都会用到):
- 令牌(token):一张自证的凭证字符串,服务端看它就知道你是谁,不必回数据库
- 签名 / 验签:用密钥对内容算一道摘要「盖章」;校验时用同一把密钥重算一遍比对,改一个字就对不上
- Base64URL:一种编码(把二进制变成能放进 URL 的可读字符),不是加密——任何人都能还原
- Claim(声明):Payload 里的一个个字段,比如
sub是谁、exp几点过期 - Bearer(持有即有效):HTTP 认证方案的名字,意思就是「谁拿着这张票谁能进门」
- 授权码(code):OAuth2 里那张一次性的取物单,几秒就作废,本身不能当钥匙用
这一整篇可以浓缩成酒店的一套流程。Session 是你每次进房间都要前台查一遍登记簿(服务端记账,安全但慢、难扩容);JWT 是发给你一张印着房号和姓名、背面有防伪全息条的塑封卡(门卫扫一眼就放行,可卡丢了在有效期内没人拦得住,只能挂失进黑名单)。而 OAuth2 讲的是另一件事——你想让外卖 App 帮你开保险柜取东西,前台不会把房卡给它,而是给它一张一次性取物单,让它去后台找经理换钥匙(第 12 节会把这条类比走完七步)。

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

学完这一篇,你应该能回答三个问题:
- 一条 JWT 被改了 Payload 里的一个字符,服务端是在哪一步、靠什么发现异常的?
- 用户「随机 401」该先怀疑算法、还是先怀疑各实例的密钥不一致?怎么用最便宜的方式验证?
- 第三方登录时,为什么
client_secret绝对不能出现在前端代码里?少了state会打开哪个漏洞?
单机应用里,Session 是最好用的方案:登录后服务端在内存或 Redis 里存一份会话,浏览器拿一个 JSESSIONID Cookie。可一旦上了集群,问题就来了:用户这次请求打到 A 节点,下次打到 B 节点,B 节点根本不认识这个会话。
| 维度 | Session | JWT |
|---|---|---|
| 状态存储 | 服务端(内存 / Redis) | 客户端(令牌本身) |
| 水平扩展 | 需要会话共享(粘性会话或 Redis) | 天然无状态,任意节点可校验 |
| 注销 / 强退 | 简单:删掉会话即可 | 困难:令牌在过期前一直有效,需黑名单 |
| 失效控制 | 实时可控 | 只能等过期,或维护黑名单/版本号 |
| 每次请求开销 | 一次存储查询 | 一次签名校验(无 IO) |
| 跨域友好度 | Cookie 受同源限制 | 放在 Header,天然跨域 |
一句话取舍:Session 好控、难扩展;JWT 好扩展、难撤销。 前后端分离 + 微服务场景下,JWT 的无状态特性通常更划算。

一个真实的 JWT 长这样,就是三个 Base64URL 段用 . 连接:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6ImFsaWNlIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImV4cCI6MTczMDAwMDkwMH0.9xQ2...把它拆开看:Header.Payload.Signature。前两段只是 Base64URL 编码,任何人都能解码,我们把每段还原成 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 里放密码、身份证号、完整手机号等敏感信息——它只是"防篡改"(有签名),不是"防偷看"(无加密)。
把它摊平就是下面这张解剖图:三段各司其职,前两段给所有人看,第三段才是防伪条——每一格都能对上刚才那句「编码不是加密」:

照着图再走一遍规律:把 Header 和 Payload 用点号拼起来,对整串算一次摘要得到 Signature;验签就是用同一把密钥重算一遍摘要再比对。接下来 2.1 节回答一个更基础的问题——这道防伪条到底防住了什么、又防不住什么。
签名保证内容没有被篡改:只要有人改动 Payload 里的 sub 或 roles,签名立刻对不上。但它不保证内容保密,也不保证令牌不被复制——谁拿到令牌,谁就能冒充。这就是为什么 JWT 必须配 HTTPS,且过期时间要短。
签名算法分两大类,选错了会在微服务场景下踩坑:
| 算法 | 类型 | 密钥 | 适用场景 |
|---|---|---|---|
| HS256 | 对称(HMAC) | 加签与验签共用同一密钥 | 单体应用、单服务自己签发自己校验 |
| RS256 | 非对称(RSA) | 私钥签发、公钥验签 | 授权方与多方资源方分离,公钥可公开分发 |
关键区别:HS256 的密钥一旦泄露,任何人都能伪造令牌;RS256 只有授权服务器持有私钥,资源服务器只用公钥验签,即使资源服务器被攻破也无法伪造令牌。微服务 / 开放平台优先 RS256。
下面用 JJWT 手写一套签发与校验工具类,先加依赖:
<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>工具类完整实现:
@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 再手动比对时间——那是重造轮子且容易漏掉签名校验。
JWT 的 Payload 里,一部分字段是标准定义的(RFC 7519),一部分是业务自定义的。滥用会带来安全和兼容问题:
| 声明 | 含义 | 建议 |
|---|---|---|
iss | 签发者 | 多系统时用来区分令牌来源 |
sub | 主体 | 放用户 id,不要放用户名 |
exp | 过期时间 | 必须设置,业务令牌越短越安全 |
iat | 签发时间 | 用于计算是否需刷新 |
jti | 唯一 id | 注销 / 防重放的黑名单键 |
roles / userId | 自定义业务字段 | 只放授权必需的、非敏感的数据 |
绝不要放进 JWT 的清单——密码或哈希、身份证号、完整手机号/邮箱、密钥、任何"泄露了会直接造成损失"的信息。JWT 是明文可见的。
单 token 的死结是:设短了用户频繁掉线,设长了被盗风险大。业界标准解法是双 Token:
accessToken:短(如 15 分钟),每次业务请求携带,泄露了影响窗口也小。refreshToken:长(如 7 天),只用来换取新的 accessToken,不参与业务请求。

刷新流程的四个关键点:
- access 过期,服务端返回 401,前端拦截器捕获后静默调用
/auth/refresh。 - 服务端校验 refreshToken 有效且未被撤销,签发一对全新的 access + refresh。
- 轮换(rotation):旧 refreshToken 立即失效。这样即使 refreshToken 被盗,攻击者用过一次后真正的用户就会失败,从而触发告警。
- 注销:把 refreshToken 的
jti写入 Redis 黑名单,TTL 设为令牌剩余有效期;校验时先查黑名单。
@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 安全性的核心。
黑名单、令牌有效期、密钥位置——这几件事最终都要落在配置里。下面这台生成器把「无状态认证最少要配哪几行」做成勾选:先只勾「Redis」得到能存黑名单的最小片段,再叠加日志、Actuator 与多环境 profile,对照上面那段 refresh 代码逐行找它的配置来源:
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 }
Actuator 的 exposure.include: '*' 会把 /actuator/env 一起打开——jwt.secret 在那里是明文。生成出来的 actuator 片段请手工改成白名单,并避开 env 与 configprops。
有了令牌,还需要一个过滤器把它翻译成 Spring Security 认识的身份。用 OncePerRequestFilter 保证每次请求只执行一次:
@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 的公共接口也会被拦。正确做法是保持匿名、放行到链尾,交给授权过滤器裁决。
上面那段过滤器代码里,最容易被忽略的是失败路径:过期令牌不会在这里报错,而是被「翻译」成匿名身份继续往下走。下面这台单步台把一条过期令牌在过滤器里的完整走位拆成逐帧——注意第 ③ 步抛出异常、第 ⑤ 步又把它悄悄抹平:
String header = request.getHeader("Authorization");if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = jwtUtils.parse(token); ...放入 SecurityContext... } catch (JwtException e) { SecurityContextHolder.clearContext(); }}chain.doFilter(request, response);| Authorization | Bearer eyJhbGci…(已过期) |
| 攻击 | 原理 | 防御 |
|---|---|---|
| 算法混淆 / none 攻击 | 攻击者把 alg 改成 none,或把 RS256 换成 HS256 用公钥当密钥 | 服务端固定期望算法,校验 alg 头,禁用 none |
| 密钥泄露 | secret 硬编码进代码或提交进 Git | 密钥走配置中心/环境变量,定期轮换 |
| 重放 | 截获有效令牌反复使用 | 短过期 + jti 黑名单,敏感操作加一次性 nonce |
| XSS 窃取 | 令牌存 localStorage,被恶意脚本读走 | 存 HttpOnly Cookie(但需重开 CSRF),或严格 CSP |
| 令牌伪造 | 弱密钥被爆破出签名 | 密钥足够长(HS256 ≥ 256 位),优先 RS256 |
攻击面表里每一行都可能倒在链路的不同关卡。把一条令牌从签发到抵达业务代码要过的关卡画成流程图:签字、时间、撤销、授权四道关,缺一道就是一条事故路径——点哪一关,就对应表里的哪一种攻击:
最重要的认知:OAuth2 是"授权协议",JWT 是"令牌格式",二者不是一回事。 OAuth2 解决的是"如何让第三方应用在用户授权下访问资源",它颁发的令牌可以是 JWT,也可以是一串不透明字符串。
授权码流程(Authorization Code)是最安全的模式,适合有后端的应用,七步走完:
- 前端把用户重定向到授权服务器,带上
client_id、redirect_uri、scope、state。 - 用户在授权页登录并同意授权。
- 授权服务器回调
redirect_uri,带上一次性 code 和原样返回的state。 - 后端用
code+client_secret向授权服务器换取 token(此步在服务端进行,client_secret不暴露给浏览器)。 - 后端拿到 accessToken(可能还有 refresh 和 idToken)。
- 后端用 accessToken 调资源服务器拉取用户信息(如 GitHub
/user)。 - 后端把用户信息映射成本系统的用户,签发本系统的会话或 JWT——至此用第三方登录完成了本站认证。
让第三方登录接进本系统的关键配置项:
| 配置项 | 作用 | 示例 |
|---|---|---|
| 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 |
时钟偏移导致令牌校验失败。 集群中不同机器的系统时间差了几分钟,A 机签发的令牌在 B 机被判定为"尚未生效"或"已过期"。解决:部署时启用 NTP 同步,或在 JJWT 中配置 clockSkewSeconds 容忍少量偏差。
分布式下密钥必须统一。 多实例各自用不同的 secret,用户在 A 实例登录拿到的令牌,到 B 实例验签直接失败,表现为"随机 401"。密钥要么集中配置分发,要么用 RS256 让所有节点共享公钥。
「随机 401」这个词表里最贵的坑,值得一条动图钉住——注意第 ⑤ 帧那个「重启又好了」的假象,它每天都在骗人:

看动图时对照上面那条坑的修法:要么把所有实例的 jwt.secret 集中分发,要么换 RS256 让私钥只留在签发方、各节点共享公钥——「重启就好了」从来不是修好了,只是请求又落回了签发它的那个实例。
别把 token 当 session 存进 Redis,又想要无状态。 如果你每请求都去 Redis 查一次会话,那你得到的只是"用 Redis 模拟的 Session",白白丢掉了 JWT 无状态的好处。黑名单是必要的例外,但它只存"撤销名单",不是每个令牌都存。
前九节都在「讲」,这一节开始「摸」。先补一张开篇没给的生活类比,它决定了你对 JWT 的第一直觉对不对:
JWT 像一张塑封的工作证:正面印着照片、姓名、部门(Payload),背面有一道防伪全息条(Signature)。门卫不需要打电话回总部确认——他只要扫一眼防伪条是否完好,就敢放你进去。这也解释了 JWT 的两条铁律:① 证件是给人看的,所以不能印机密(Base64URL 只是塑封,不是保险箱);② 谁捡到证件谁能进门,所以有效期要短、丢了要立刻挂失(黑名单)。
先把第二节的三段结构变成能按的按钮。下面这个实验会真的把 header.payload.signature 拼出来、再拆开验给你看:
跑完这两步你会得到一个关键认知:签发和校验用的是同一份密钥、同一套算法、相反的方向。签名 = 用密钥把前两段的摘要「盖章」;验签 = 用同一密钥重算一遍摘要,比对章是否一致。服务端从来不查数据库就知道你是谁——这正是第一节表格里「每次请求开销:一次签名校验(无 IO)」的由来。
紧接着两个实验是安全链路上最常出事的地方,务必切到指定参数看:
第八节的坑讲的是「理论上的翻车」,现在把它们逐个演出来。注意这三段实验的失败点完全不同,生产上对应三种不同的告警:
最后补一段异常视角。令牌校验失败会以什么形态出现在响应里?取决于你的异常解析器接管到哪一层:
三个失败现场演完了,换命令行再敲一遍。下面这台控制台连着浏览器里的同一个内核——先 whoami 确认匿名身份,再 lab jwt 走一遍签发、校验与篡改,最后 lab sec deny 看 401 与 403 的分岔:
一对值得对比着敲的命令是 lab jwt verify 与 lab jwt tamper——同一个接口、同一段代码,一个回显身份,一个在验签处炸掉;这正是第七节攻击面表第一行「算法混淆 / 篡改」防线的现场版。
access 令牌的生命周期是 JWT 里唯一一个「纯取舍」的参数:设短了用户抱怨,设长了攻击者喜欢。下面这个沙盘把它做成一根可以拉的弹簧,三档之间同时看四组指标——这是本节所有理论的落地点:
用户感知:401 几乎无感(refresh 提前 2 分钟续期即可)refresh 调用量 QPS:0.9被盗令牌可用窗口:≤ 15 分钟Redis 黑名单写入:1.1k key/小时审计口径:单点强退最长延迟 15 分钟
数字是示意的,但两条结论是真的——过期时间决定的是「被盗后的损失窗口」,不是「用户体验」,体验靠双 Token 与轮换解决;一旦你想让长令牌可撤销,就必须把每个 jti 都存起来,此时你已经回到了 Session 模型(第九节第三个坑)。
再把这条「客户端自带身份」的链路画回真实请求上——下面这张图就是第 6 节过滤器代码在整条链里的位置,盯住「客户端携带 Bearer 调用」和「JWT 过滤器解析校验」这两格:令牌由客户端主动带上,服务端不必为每个请求去查一次「这人是谁」(第五节的黑名单只查「这张票挂失过没有」,是例外而非记账):

Session 像图书馆的借书登记台——书(身份)在你手上,但馆里那本登记簿才是权威,管理员随时能划掉一行(强退),代价是每个读者进门都得让管理员翻一次簿子(查存储)。JWT 像自带防伪标签的门票——检票员只看票不看簿子,人流再大也不堵门(好扩展),可这张票一旦被抄走,在有效期内没人拦得住你(难撤销)。
第八节给了七步清单,这里把它动化。先记一句类比,整条流程就不会背反:
授权码模式像酒店前台替你开保险柜:你把房卡号(client_id)报给第三方 App,App 并不能凭它打开任何东西;前台让你本人去大堂屏幕输密码同意(用户在授权服务器登录并同意),然后塞给 App 一张一次性取物单(code);App 拿着取物单去后台找经理(后端带 client_secret 换 token),经理核对身份后才把真正的钥匙(accessToken)交给它。全程没有任何人把你的房卡(账号密码)递给第三方——这就是「授权」而非「认证」的含义。

对着动图把最容易问倒的三个细节钉死:
| 环节 | 常被追问 | 答案 |
|---|---|---|
state | 少传了会怎样 | 回调可被伪造(CSRF):攻击者诱导你用他的授权结果登录成他的账号 |
code | 为什么要「一次性 + 走浏览器」 | code 泄露的损失窗口只有几秒,且没有 client_secret 换不出 token |
| 第 4 步 | 为什么不能让前端直接拿 code 换 token | 那样 client_secret 必须写进 JS,人人可见;这一步只能在服务端做 |
Spring Boot 侧对应的配置写法(Boot 3 / Spring Security 6,属性名必须逐字照抄):
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——见下一节的速查表。
这一篇名词密度高,最容易混的就是「格式」与「协议」、「凭证」与「范围」。来玩一局:先点左边,再点它在整条链路里的角色——配错了当场告诉你为什么:
先来一道热身题,考的是第二节那条最容易记反的边界:
再来一道综合题,把第五节轮换、第七节攻击面和第十节三段实验串起来:
下面每一行的「报错原文」都可以整段复制去搜索,别意译、别缩写。新手在这三个地方最容易卡住:验签失败、令牌过期、密钥被人看见。
| 报错原文(片段) | 真实原因 | 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 HS256 | Keys.hmacShaKeyFor() 要求密钥字节数 ≥ 32,随手写的 "secret123" 太短 | 生成 32 字节以上随机串:openssl rand -base64 48,放进配置而不是代码 | 本篇第三节工具类 · 第一档练习 |
invalid_grant / code_to_token_exchange_failed(换 token 那一步失败) | code 已被用过(它是一次性的)、已过期,或 redirect_uri 与授权请求里那条不完全一致 | 每次登录都重新走一遍授权;把两处 redirect_uri 复制到文本对比工具逐字符核对 | 本篇第十二节时间轴 |
GitHub 回调页显示 redirect_uri_mismatch | OAuth App 设置里登记的回调地址与应用实际使用的地址不一致(协议、端口、结尾斜杠都算差异) | 在 GitHub App 设置里原样添加 http://localhost:8080/login/oauth2/code/github,本地调试也要登记 | 本篇第十二节配置块 |
| 同一个用户「有时能用、随机 401」,重启后又好了 | 多实例的 secret 不一致(各节点配置源不同),令牌只在签发它的那个节点验得过 | 集中分发密钥,或改用 RS256:私钥只在签发方,各资源节点只持公钥 | 本篇第九节第二个坑 · #37 JWT 与 OAuth2 前置的安全篇 |
刷新接口偶发 刷新令牌已失效,用户被踢下线 | 轮换机制生效了:旧 refresh 已被拉黑却被重放——可能是同一页面开了两份 localStorage 各自持有一个旧值,也可能是真被窃取 | 保留这条告警并按 jti 审计;前端把 refresh 收敛到单一存储位置,并且并发 401 只触发一次刷新 | 本篇第五节轮换 · jwt 实验 refresh 参数 |
这一节里出现频率最高的不是过期,而是「signature does not match」+「随机 401」这对组合——九成是多实例密钥不统一或配置优先级打架造成的,先看第十一节 prop 实验和上面的沙盘,别再怀疑算法本身。
速查表第一行那个 SignatureException 已经见过很多次了,但生产上还有一对更迷惑人的组合——过期令牌遇上漏配的授权规则,异常被伪装成 500。下面这段是真实堆栈,先别看答案:
用户在页面上放了两小时,点「取消订单」返回 500,日志里既没有 401 也没有 ExpiredJwtException,只有一句 NullPointerException。同一个接口重新登录后立刻正常——先把凶手行找出来。
目标:30 行代码跑通「签发 → 校验 → 篡改 → 过期」四种结局,并留下能对得上的日志证据。
第一步,建一个最小 Maven 工程(pom.xml 只要这三个依赖,Java 17):
<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>第二步,写这个类(src/main/java/com/example/jwt/JwtDemo.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); }}运行 main,预期输出(四条分支各命中一种结局,顺序可能因排版略有差异):
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验收清单:① 你能指出第 ② 行是第三段变了还是第二段变了导致失败(答案:第二段变、第三段没变,所以重算出的摘要与带来的摘要不符);② 把 issue 的密钥换成 31 字节,程序直接在 Keys.hmacShaKeyFor 处抛 IllegalArgumentException: Key length must be at least 256 bits for HS256——你就复现了速查表第 5 条;③ 说得出 ③ 与 ④ 两种失败在生产上分别对应哪种告警。
每次只改一处,观察结论立刻翻转:
- 把
parse里的.verifyWith(KEY)换成一个只差最后一个字符的密钥。你会观察到:所有原本正常的令牌全部变成SignatureException,而应用启动、其他接口一切如常——这正是「多实例 secret 不一致 → 随机 401」的最小复现。修法见第九节第二个坑。 - 把
issue(Duration.ofMinutes(15))改成Duration.ofSeconds(-1)作为唯一的 access 令牌,然后用第十一节沙盘验证:你会观察到 refresh 调用量从 0.9 QPS 飙到 3.4 QPS,用户侧「一直要我重新登录」。这就是「过期时间不是体验问题,是损失窗口问题」的反证。 - 手工构造一个 Header 为
{"alg":"none","typ":"JWT"}且不带第三段的令牌(Base64URL 编码后拼成header.payload.)发给你的接口。你会观察到:现代 JJWT 的parseSignedClaims直接拒绝并报算法相关错误;但若你曾手写「先解 payload 再自己比时间」的代码,它会当成合法令牌放行——这就是第七节算法混淆攻击面的成因。 - 给
JwtAuthFilter的catch (JwtException e)里加一行throw new ServletException(e)。你会观察到:公共接口(permitAll)也一并变成 500/401——第六节那个「坑」你只要一行代码就能亲手制造。
提示:做完第 1 条回头看 prop 实验的「谁覆盖谁」,两边结论应当完全对得上。
给自己写一个「令牌体检」小 CLI(纯 main 方法即可,不必开 Web),以后任何一条 JWT 都能三秒钟看清它的底细。
需求:
- 输入一个令牌字符串,输出三段各自解码后的 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)时高亮提示
验收清单:① 用第一档生成的令牌跑一遍,exp 间隔应打印 15 分钟;② 把令牌中间任意一个字符改掉再跑,工具必须报 SignatureException 而不是崩栈;③ 故意传入一个不含 exp 的令牌,体检报告要把这条标为高危;④ 传入 alg=none 构造的令牌,工具必须在验签之前就拒绝并说明原因;⑤ 全流程不修改业务代码一行。
不看上文,说出 JWT 三段各自的内容与作用,并解释「为什么改了 Payload 就一定过不了验签」——答案要落在「服务端会用同一密钥重算摘要」这句话上。
SignatureException: JWT signature does not match 在生产上有哪三类诱因?其中哪一类在多实例部署下表现为「随机 401」?
为什么说「Base64URL 是编码不是加密」?如果密钥不小心提交进了 Git,攻击者拿到源码目录之外还能做什么?
双 Token 里的「轮换(rotation)」解决了什么单靠短过期时间解决不了的问题?旧 refresh 被重放时你希望看到什么日志?
OAuth2 授权码模式的第 4 步为什么必须在服务端完成?state 参数少了会打开哪个漏洞?
三段明文一张防伪证——签发盖章、验签重算;短的管损失窗口、长的管在线体验;授权码是取物单,secret 永不进浏览器。
本节只须记住三句话——JWT 是防篡改的明文令牌,别放敏感信息;双 Token 靠轮换和黑名单解决"长期在线"与"可撤销"的矛盾;OAuth2 是授权协议、JWT 是令牌格式,先想清楚要解决的是"认证"还是"授权给第三方"。这三句话理清,你就能在 Session、JWT、OAuth2 之间做出正确选择。