Refresh Token
这一课你将能做到
- 说明「短 access + refresh 续期」的分工与安全收益,写出
grant_type=refresh_token 的完整请求
- 按客户端类型选对 refresh 存储位置:后端 DB/Redis、移动端系统钥匙串,绝不长期放前端 localStorage
- 实现 rotation(轮换)与重放检测:旧 refresh 再次出现 = 泄露信号,应撤销整条授权
为什么要学
access_token 的寿命是一道两难题:设 7 天,被偷了就是 7 天敞开的大门;设 5 分钟,用户每隔几分钟重新登录,产品没法用。
Refresh token 是标准答案,分工明确:
- access_token:日常开门用,寿命短(几分钟到几十分钟),泄露窗口小;
- refresh_token:只干一件事——去授权服务器换新 access_token,寿命长(天级)但用途单一,不能直接调业务 API。
这套思想你也用在本站的自研 JWT 方案里:backend 签发短寿命 JWT,配合 refresh(或滑动续期)保证体验——第 10 课会专门对比两条轨。而对接第三方 IdP 或自建授权服务器时,refresh 的协议细节(请求形状、轮换、撤销)必须按标准来实现,不能「长寿 access 一发了之」。
面试高频:「为什么 access token 要短」「refresh token 泄露了怎么办」「rotation 是什么」——本课全部给到可背诵、可落地的程度。
先建立图像
登录成功(授权码流换到 token)
├─ access_token 15 分钟 ──► 调业务 API(常过期,泄露损失小)
└─ refresh_token 7~30 天 ─► 仅用于 POST /token 换新 access
│
开启 rotation 时:响应同时返回新 refresh
│
旧 refresh 立即作废
│
若作废的旧 refresh 又被拿来用
└─► 重放!撤销该用户该 Client 的全部授权
按客户端类型选存储位置:
| 客户端 | refresh 放哪 | 原因 |
|---|
| 机密后端 | 服务端 DB/Redis(加密存储) | 可绑定用户与客户端,易撤销 |
| SPA | 不要长期放 localStorage | XSS 一锅端;优先放 BFF/后端 |
| 移动 App | iOS Keychain / Android Keystore | 系统级加密保护 |
跟着做
步骤 1:看清续期请求的形状
# 中文注释:用 refresh 换新 access;按 IdP 要求带 client_id/secret 或客户端标识
curl -i -X POST https://auth.example.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=THE_REFRESH_TOKEN" \
-d "client_id=coderoadmap-web" \
-d "client_secret=${CLIENT_SECRET}"
期望响应包含新的 access_token、expires_in;若开启 rotation,还有新的 refresh_token——此时旧的立即作废,代码里必须更新存储。
步骤 2:前端感知过期的标准流程(BFF 思路)
浏览器只持本站短会话(HttpOnly cookie)
→ 调 API 收到 401
→ 前端调 /auth/refresh(BFF 接口)
→ BFF 用库存 refresh 调 IdP /token
→ 成功:发新本站 JWT,前端重试原请求
→ refresh 也失效:清会话,引导重新走登录
关键点:IdP 的 refresh 永远留在服务端,浏览器只见本站会话。这与本站自研 JWT 的刷新接口是同一个设计模式。
步骤 3:实现 rotation 与重放检测(Java 伪码可落地)
// 中文注释:refresh 用 Redis 存储;key 带 jti;轮换 = 删旧发新
public String[] rotateRefresh(String userId, String presentedJti) {
String stored = redis.get("refresh:" + userId + ":" + presentedJti);
if (stored == null) {
// 旧票已作废却再次出现 → 疑似泄露:撤销该用户全部授权
revokeAllGrants(userId);
throw new SecurityException("refresh 重放,已撤销全部授权,请重新登录");
}
redis.delete("refresh:" + userId + ":" + presentedJti); // 轮换:旧票即焚
String newAccess = jwtUtil.sign(userId, loadRoles(userId)); // 15 分钟
String newRefresh = randomToken(); // 7 天
redis.setex("refresh:" + userId + ":" + jti(newRefresh),
Duration.ofDays(7).toSeconds(), userId);
return new String[]{newAccess, newRefresh};
}
步骤 4:对照本站自研方案的刷新接口
# 中文注释:本站 JWT 登录的续期走自家接口,思想与 OAuth refresh 相同
curl -i -X POST http://localhost:8080/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken": "本地Redis中登记的refresh串"}'
OAuth refresh:与 IdP 之间的协议续期,跨应用边界,grant 可被用户在 IdP 侧撤销
本站自研 JWT:backend 自己签发自己验,Redis 登记可撤销,逻辑同源但不需要协议
共同纪律:短 access + 可撤销 refresh + 日志打码
步骤 5:登出与撤销清单
用户点登出 / 改密 / 解绑 GitHub:
① 删除本站会话(cookie / 本站 JWT 失效标记)
② 调 IdP 的 revocation 端点(若支持)作废 refresh
③ 清掉服务端库存 refresh(Redis 删 key)
④ 审计日志只记 jti 或前后缀打码,绝不记全文
# 中文注释:向 IdP 主动撤销 refresh;第三方「解绑授权」同理
curl -i -X POST https://auth.example.com/revoke \
-d "token=THE_REFRESH_TOKEN" \
-d "client_id=coderoadmap-web" \
-d "client_secret=${CLIENT_SECRET}"
步骤 6:设计一组时长并写进笔记
access = 15 分钟(短,泄露窗口小)
refresh = 7 天 + rotation(体验与安全的平衡点)
7 天无任何续期动作 → refresh 自然过期 → 重新走授权码登录
讲清楚要点
- access 的泄露面等于它的寿命,所以生产环境宜短 access + refresh 续期,而不是发一枚超长 access 图省事。
- refresh 的能力等于「无限续票」,存储与传输要求高于任何业务字段:加密存储、仅走 HTTPS、不进日志、不进前端。
- SPA 的 refresh 应留在 BFF/后端:浏览器只持短会话 cookie;XSS 偷不到不存在于页面的东西。
- rotation 把「泄露检测」变成了协议能力:旧票重放 = 强信号,触发全量撤销。不开启轮换时,至少要支持主动撤销。
- 登出、改密、解绑必须走撤销链路:只删浏览器 cookie 而不清服务端 refresh,等于「门锁换了钥匙串还在小偷手里」。
- 本站密码登录已有自己的会话续期逻辑,不要硬塞 OAuth refresh 概念;联邦轨才与 IdP 的 refresh 打交道——两条轨各用各的续期机制,互不混淆。
你可能会懵的地方
-
没有 refresh 能不能做 OAuth?
能。refresh 是「可选」的:access 过期后重新走一次授权码即可。很多 IdP 对公共客户端默认不发 refresh,需要按文档开启。
-
refresh token 是 JWT 吗?
通常是不透明随机串(安全性依赖服务端存储比对),不要假设能本地解码、不要本地判断过期——有效性以授权服务器的回答为准。
-
自动刷新会不会和并发请求打架?
会。页面同时发 5 个请求都 401,就可能触发 5 次刷新;配合 rotation 时,第 2 次开始全用旧票 → 误判重放 → 全量撤销。解法:同一用户加锁/单飞(single-flight),刷新期间其余请求排队等待新 token。
-
Client Credentials 有 refresh 吗?
通常没有——机器身份直接再申请一枚新 access 即可,没有终端用户参与,不适用「用户委托」的续期模型。
-
refresh 有效期设多长合适?
看产品:金融类 1 天甚至更短;一般 Web 应用 7-30 天。配合 rotation 与「长期不活跃即过期」策略,比单纯拉长更安全。
错法对照
| 错法 | 问题 | 正解 |
|---|
| access 设 30 天「省得刷新」 | 泄露窗口 30 天 | 短 access(15 分钟级)+ refresh |
| refresh 存 localStorage | XSS 一偷一个准 | 服务端 / 系统钥匙串 |
| 日志打印完整 refresh | 日志泄露=授权泄露 | 打码或只记 jti/hash |
| rotation 后仍接受旧 refresh | 重放检测失效,偷票者畅通 | 旧票重放 → 撤销全部授权 |
| 前端 401 就清会话弹登录 | 体验差且浪费合法会话 | 先走 refresh 续期,失败再登录 |
卡点急救
- 现象:续期请求报
invalid_grant。按概率排查:refresh 已被 rotation 作废(还在用旧票);refresh 过期;client_id/secret 不匹配;grant 已被用户在 IdP 侧撤销。前三者重新登录即可,第四种要引导用户重新授权。
- 现象:开启 rotation 后用户被随机「全量登出」。典型原因是并发刷新打架:多个请求同时拿同一枚旧 refresh 换新,后者被判重放。上单飞刷新:全局同一时刻只放行一个刷新请求,其余等待复用结果。
- 现象:前端收到 401 直接弹登录,从不尝试续期。拦截器逻辑写反了。顺序:401 → 调 refresh 接口 → 成功则替换 token 并重放原请求 → 失败才清会话跳登录。注意「重放原请求」只对幂等或用户可接受的重试场景做。
面试常问
-
为什么要短 access + 长 refresh,而不是一枚长 access?
access 是每次请求都外发的凭证,暴露面大,必须短命控制泄露窗口;refresh 只在续期时出现一次,暴露面小、用途单一(换新 access),可以长一些。两者配合兼顾安全与体验。
-
refresh token 泄露了怎么办?
短期:立即撤销该 refresh 与其派生的全部 access(撤销端点/删 Redis);根因修复:确认日志、存储、传输环节;长期:启用 rotation——偷票者与正常用户必有一方先用到已作废的旧票,重放检测会让全量授权被撤销,攻击即暴露。
-
rotation 的原理和代价?
每次续期发放新 refresh 并立即作废旧票;旧票再次出现即视为泄露信号,撤销整条授权。代价是需要处理并发刷新(单飞)与「合法旧票在途请求」的竞态,实现上要小心但值得。
自测三问
- 写出 refresh 换票的
grant_type 与至少两个必带参数。
- 为什么 SPA 不能长期存 refresh?推荐架构是什么?
- rotation 下「旧 refresh 再次出现」应如何处置?为什么?
小结一句话
Refresh = 短 access 的安全续命索:短 access 控暴露面,refresh 专职续期;存服务端、开轮换、可撤销、日志打码。
延伸