双token无感刷新需严格区分access_token与refresh_token的生命周期、校验逻辑及存储方式:access_token短期有效(如2小时)仅用于接口鉴权,refresh_token长期有效(如7天)存redis并绑定用户id与设备指纹,刷新时须校验、删除旧token并写入新token,否则将导致401频发或安全风险。

双 Token 无感刷新在 Gin 中不是靠“加个中间件就自动跑起来”,而是必须明确区分 access_token 和 refresh_token 的生命周期、校验逻辑与存储位置。否则你会遇到 token 刷新成功但后续请求仍 401、或 refresh_token 被反复滥用等典型故障。
为什么 AuthMiddleware 不能只校验 access_token
单纯检查 access_token 是否过期,会直接把活跃用户踢回登录页——哪怕他刚操作完不到 1 秒。真正要做的,是在中间件里同时支持两种路径:
- 正常请求:仅校验
access_token,有效则放行,无效则尝试用refresh_token续签 - 刷新请求(如
/auth/refresh):跳过access_token校验,只验证refresh_token是否存在、未被吊销、且仍在可续期窗口内
关键点在于:refresh_token 必须存 Redis(带 TTL),且 key 要能关联到用户 ID + 设备指纹(比如 User-Agent + IP 哈希),否则无法做单设备踢出。
ParseToken 必须区分两种 Token 类型
同一个 ParseToken 函数如果硬塞两种有效期、两种签发策略,很快会失控。建议拆成两个独立函数:
-
ParseAccessToken:只接受exp在未来且未超2h的 token;失败时不要直接返回 401,而是标记 “需刷新” 并继续走下一步 -
ParseRefreshToken:允许更长的exp(如 7 天),但必须额外校验 Redis 中是否存在对应 key,且 value 是该用户当前有效的refresh_tokenhash
别省事合并在一个函数里——access_token 验证失败 ≠ 拒绝请求,而 refresh_token 验证失败 = 立即 401。
刷新响应必须重写 Header/Cookie,不能只返回 JSON
前端发起刷新请求后,你返回 {"access_token": "xxx"} 是不够的。浏览器不会自动把新 token 写进 Authorization header 或 Cookie。常见错误做法:
- 只往 JSON body 里塞新
access_token,前端忘记手动更新 axios.defaults.headers.common['Authorization'] - 用
SetCookie写了refresh_token,但没设HttpOnly=true和SameSite=Strict,导致 XSS 泄露风险
正确做法是:在刷新成功响应中,同时用 context.Header("Authorization", "Bearer "+newAccessToken) 和 context.SetCookie("refresh_token", newRefreshToken, maxAge, "/", "your-domain.com", false, true) 双写。注意域名和安全标志必须匹配前端部署环境。
Redis 存 refresh_token 的 key 设计容易踩坑
很多实现直接用 "rt:" + userID 当 key,这会导致同一账号多端登录时,后一次登录覆盖前一次的 refresh_token,无法做到“踢下线”。更稳妥的方式是:
- key 为
"rt:" + userID + ":" + hash(userAgent+ip),每次登录生成唯一标识 - value 存结构化 JSON:
{"token_hash": "sha256(...)", "created_at": 1724028720, "expires_at": 1724633520} - 刷新时先查 Redis 是否存在该 key,再比对
token_hash,最后检查created_at是否在允许的“最大刷新窗口”内(比如access_token.ExpireTime * 2)
这个窗口才是决定“用户是否还活跃”的真实依据,不是靠客户端传来的任意时间戳。
最常被忽略的一点:refresh_token 一旦使用,就必须立即从 Redis 删除,并用新值覆盖——否则攻击者截获旧 refresh_token 仍可无限续期。











