单 Token 透明刷新:一个被放弃的方案及其思考过程
本文是《JWT 认证方案深度对比:单 Token 扩展刷新 vs 双 Token 验证》的续篇。记录了我在尝试优化单 Token 刷新体验时的完整思考过程——从发现问题、设计方案、实现代码,到最终发现安全缺陷并放弃方案。希望这个"失败"的过程对其他开发者有所帮助。
起因:一次审计引发的思考
在做系统审计时,我发现一个问题:当 Token 过期返回 401 时,审计日志无法记录是谁在调用接口。
正常请求:GET /api/orders → 200 → 审计日志:用户 A 访问了订单接口 ✅
过期请求:GET /api/orders → 401 → 审计日志:某个请求被拒绝了(谁?)❌
这让我开始思考:为什么其他网站的登录状态能保持那么久?业界成熟的 Token 方案是怎么做的?
由此,我产生了一个想法:能不能让中间件在 Token 过期时自动刷新,而不是返回 401?
方案设计:透明刷新
现有流程的"痛点"
单 Token 方案的刷新流程是:
客户端 → 过期 Token → 中间件 → 401
客户端 → 调用 /refresh → 获得新 Token → 重试原请求 → 200
这个流程有两个让我不满意的地方:
/refresh接口必须放在中间件的 skip 列表里(因为它接收的是过期 Token)- 客户端需要实现 401 → 刷新 → 重试的逻辑
我的方案
将刷新逻辑内置到认证中间件中:
authMiddleware, _ := jwt.New(&jwt.HertzJWTMiddleware{
Timeout: 15 * time.Minute,
MaxRefresh: 7 * 24 * time.Hour,
EnableTransparentRefresh: true, // 新增:透明刷新
})
当 Token 过期但仍在 MaxRefresh 窗口内时,中间件自动生成新 Token,继续处理请求,新 Token 通过响应头/Cookie 返回给客户端。
客户端 → 过期 Token → 中间件 → 自动刷新 → 200 + 新 Token
实现代码
func (mw *HertzJWTMiddleware) middlewareImpl(ctx context.Context, c *app.RequestContext) {
claims, err := mw.GetClaimsFromJWT(ctx, c)
if err != nil {
// Token 过期,尝试透明刷新
if mw.EnableTransparentRefresh && mw.MaxRefresh > 0 && mw.isExpiredTokenError(err) {
if refreshedClaims, refreshErr := mw.tryTransparentRefresh(ctx, c); refreshErr == nil {
claims = refreshedClaims
err = nil
}
}
if err != nil {
mw.unauthorized(ctx, c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, ctx, c))
return
}
}
// ... 正常处理
}
我甚至实现了完整的测试覆盖,提交了 PR #32。一切看起来很完美。
发现致命问题:安全边界被破坏
提交代码后的那天晚上,我躺在床上突然意识到一个严重的问题。
短过期时间的意义
JWT 设置短 exp(如 15 分钟)不仅仅是为了"让 Token 过期",更重要的是提供一个安全控制点:
原始流程(有控制点):
Token 过期 → 401 → 客户端调用 /refresh → 服务端可以在这里拒绝
↑
封禁用户?吊销 Token?
这里可以做任何检查
透明刷新(控制点消失):
Token 过期 → 中间件自动刷新 → 继续处理请求
↑
没有任何拦截机会
透明刷新把 exp 从 15 分钟实质上拉长到了 MaxRefresh(7 天)。短过期时间形同虚设——你无法在 MaxRefresh 期间让一个 Token 失效,因为每次过期都会被自动续期。
对比双 Token 方案
| 特性 | 双 Token (OAuth 2.0) | 单 Token 透明刷新 |
|---|---|---|
| Access Token 实际有效期 | 15 分钟(真正的短期) | 7 天(名义 15 分钟) |
| 刷新时的控制点 | /token 端点可以拒绝 | 没有控制点 |
| 封禁用户生效时间 | 最多 15 分钟 | 最多 7 天 |
| Token 泄露影响 | 15 分钟 | 7 天 |
尝试修复:滑动窗口
发现问题后,我尝试用"滑动窗口"来修复:
RefreshSlidingWindow: 1 * time.Hour, // 只有超过 1 小时才允许透明刷新
但这个方案有两个问题:
1. 语义矛盾
窗口内(刚过期)→ 拒绝刷新,返回 401 窗口外(过期很久)→ 允许刷新
这意味着 Token 刚过期时反而不能刷新,要等足够久才能刷新——完全违反直觉。
2. orig_iat 更新后 MaxRefresh 失效
滑动窗口模式下会重置 orig_iat,这导致 MaxRefresh 的窗口也被重置——Token 理论上可以无限续期,回到了原来的问题。
滑动窗口并没有解决根本问题,只是换了一种方式暴露了同样的安全缺陷。
重新审视:那些"痛点"真的是问题吗?
冷静下来后,我重新审视了最初的两个"痛点":
"痛点"1:/refresh 必须放在 skip 列表里
这不是问题,这是正确的设计。
/refresh 接口接收的就是过期 Token,它不应该通过认证中间件。这和 OAuth 2.0 的 /token 端点不走 Bearer 认证是同一个道理——刷新端点本身就是安全边界的一部分,它需要跳过常规认证才能执行自己的验证逻辑(检查 orig_iat + MaxRefresh)。
"痛点"2:客户端需要处理 401 → 刷新 → 重试
这也不是问题,这是业界标准做法。
我们在其他网站体验到的"长期登录",并不是服务端在透明刷新——而是客户端(浏览器/App)在背后默默处理了这个流程。无论是 OAuth 2.0 双 Token、还是单 Token + MaxRefresh,客户端都需要处理刷新逻辑,区别只是实现方式。
真正的问题:审计
回到最初的出发点——审计日志在 401 时丢失调用者身份——这个问题根本不需要透明刷新来解决。
JWT 的 payload 只是 Base64 编码,不是加密的。Token 过期了,claims 仍然是可读的。真正的解决方案是:即使返回 401,也把能解析出的 claims 存到 context 里供审计使用。
func (mw *HertzJWTMiddleware) middlewareImpl(ctx context.Context, c *app.RequestContext) {
claims, err := mw.GetClaimsFromJWT(ctx, c)
if err != nil {
// 即使 Token 无效,也尝试存储 claims 用于审计
if claims != nil {
c.Set("JWT_PAYLOAD", claims)
}
mw.unauthorized(ctx, c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, ctx, c))
return
}
// ... 正常处理
}
改动很小,不破坏任何安全边界,完美解决了审计需求。
业界长会话方案总结
在这次探索中,我也调研了业界主流的长会话维持方案:
| 方案 | 典型场景 | 做法 | 刷新由谁处理 |
|---|---|---|---|
| OAuth 2.0 双 Token | 大多数 SaaS | Access Token 短期 + Refresh Token 长期 | 客户端 |
| 服务端 Session | 传统 Web | httpOnly Cookie + 服务端存储,滑动过期 | 服务端 |
| BFF 模式 | 现代 SPA | 后端网关代理刷新,前端无感知 | BFF 网关 |
| 单 Token + MaxRefresh | hertz-contrib/jwt | 一个 Token + orig_iat 管理刷新窗口 | 客户端 |
共同点: 所有安全的方案都保留了"刷新时的控制点"——服务端可以在刷新环节拒绝续期。透明刷新之所以不安全,正是因为它删掉了这个控制点。
总结与反思
时间线
- 发现审计问题 → 审计日志在 401 时丢失调用者身份
- 产生想法 → 能不能在中间件里自动刷新?
- 实现方案 → 透明刷新,提交 PR
- 发现安全缺陷 → 短过期时间失去意义,Token 无法被撤销
- 尝试修复 → 滑动窗口,但治标不治本
- 放弃方案 → 关闭 PR 和 Issue
- 回归本质 → 审计问题用更简单的方式解决
教训
- 不要把"不方便"当成"设计缺陷"。
/refresh放在 skip 里看起来不够优雅,但这恰恰是正确的安全设计。 - 安全边界的存在是有原因的。在优化用户体验之前,先想清楚你正在移除什么安全保障。
- 先找准真正的问题。我的出发点是审计,但在思考过程中偏向了"优化刷新体验",最终发现审计问题有更简单的解法。
- "失败"的方案也有价值。这次探索加深了我对 JWT 安全模型的理解,也希望能帮其他开发者避免同样的弯路。
相关资源
- 前文:JWT 认证方案深度对比:单 Token 扩展刷新 vs 双 Token 验证
- 项目地址:hertz-contrib/jwt
- 已关闭的 PR:feat: add transparent token refresh in middleware
- Hertz 框架:cloudwego/hertz
优秀的工程设计不是追求"看起来优雅"的方案,而是在理解安全约束的前提下,找到最简单有效的解法。
「关于我的技术经历和成长,欢迎访问我的 个人网站」