真正无感的刷新必须由后端在每次有效请求中主动判断、动态续期,并同步更新refreshtoken;应在ontokenvalidated回调中轻量评估续期,而非controller;refreshtoken与accesstoken应使用不同签名密钥。

不能只在前端定时调 /api/auth/refresh,否则用户切回页面或网络抖动时必然卡在 401。真正无感的刷新必须由后端在每次有效请求中主动判断、动态续期,并同步更新 RefreshToken。
OnTokenValidated 中做续期判断,而不是写在 Controller 里
很多人把续期逻辑全塞进 RefreshTokenController,结果权限校验走 AddJwtBearer 中间件,续期却在 Controller 层——导致 AccessToken 还剩 2 秒就过期,中间件已判定无效并返回 401,根本没机会走到续期代码。
- 必须在
services.AddAuthentication().AddJwtBearer()的options.Events.OnTokenValidated回调里做续期评估 - 这里能拿到
context.Principal(已解密的 Claims)、context.SecurityToken(原始JwtSecurityToken),可直接读exp和jti - 但注意:不要在该回调里查数据库或 Redis —— 会拖慢所有请求;只做轻量判断(比如剩余有效期
RefreshToken 必须滚动更新 + 即时失效旧值
如果每次刷新都返回同一个 RefreshToken 字符串,攻击者截获一次就能无限重放。真正的滚动刷新要求:每次成功续期,旧 RefreshToken 立即不可用。
- 生成新
RefreshToken时,用RandomNumberGenerator生成强随机字符串,而非复用原jti - 存储时键名建议为
refresh:{userId}:{newJti},过期时间设为 7 天;同时用 Lua 脚本原子性地DEL旧键(如refresh:{userId}:{oldJti}) - 数据库表中必须有
IsUsed字段,且验证通过后立刻执行UPDATE ... SET IsUsed = 1 WHERE jti = @jti AND IsUsed = 0,防止并发重复使用
RefreshToken 存 HttpOnly Cookie,别放 localStorage
把 RefreshToken 放 localStorage 是 XSS 高危操作——JS 可读可窃,等于把长期凭证明文交到前端手里。
- 服务端应通过
Set-Cookie返回,参数必须包含:HttpOnly(禁 JS 访问)、Secure(仅 HTTPS)、SameSite=Strict(防 CSRF) - 前端无需手动读取或携带它;浏览器会在每次请求自动附带该 Cookie,后端从
HttpContext.Request.Cookies["refresh_token"]拿即可 - 登录接口响应体仍可返回
accessToken(放响应体或 Authorization Header),但refreshToken字段应为空或省略,避免被日志/监控误采
ValidateToken 里必须校验 jti + IsUsed 状态
JWT 本身无状态,但 RefreshToken 机制一旦引入数据库或 Redis,就必须打破“纯无状态”幻想——jti 不是装饰字段,是防重放的唯一凭据。
-
TokenValidationParameters默认不校验jti,需手动在OnTokenValidated或自定义IJwtSecurityTokenHandler中查库 - 查库 SQL 必须带
WHERE jti = @jti AND UserId = @userId AND IsUsed = 0 AND ExpiresAt > GETUTCDATE(),三者缺一不可 - 若用 Redis,键值结构推荐
HSET refresh:{userId} {jti} "valid"+EXPIRE,避免单 key 过大;验证时用HEXISTS+EXPIRE原子检查
最易被忽略的一点:RefreshToken 的签发密钥最好和 AccessToken 分开,比如前者用 RSA 私钥签名、后者用 HS256 共享密钥——这样即使前端被 XSS 泄露了 AccessToken 解析逻辑,也无法伪造 RefreshToken。











