写在前面做过前后端分离项目的同学应该都遇到过这个问题——Token过期了怎么办是让用户重新登录还是静默刷新重新登录用户体验差静默刷新又担心安全性。今天聊聊双令牌模式这是Sa-Token、Spring Security OAuth2等框架都在用的经典方案。文章目录一、为什么需要双令牌1.1 单Token方案的痛点1.2 双Token如何解决二、双令牌的核心实现2.1 登录时返回双Token2.2 Token刷新接口2.3 Token失效踢人/改密三、前端如何配合3.1 Token存储策略3.2 Token过期自动刷新拦截器四、预判问题与解答Q1Refresh Token泄露了怎么办Q2双Token比单Token多了几次请求性能会不会有问题Q3JWT还是Session双Token适合哪种Q4Access Token要不要存RedisQ5刷新时要不要返回新的Refresh Token五、与Sa-Token的对比六、面试高频考点面试官问请介绍一下双令牌模式的设计思路面试官问Refresh Token泄露怎么办面试官问双Token和单Token各有什么优缺点面试官问Access Token和Refresh Token存储位置有什么讲究七、总结参考资料一、为什么需要双令牌1.1 单Token方案的痛点踩坑提醒很多新手项目只用一个Token有效期设7天。看起来用户体验好但一旦Token泄露攻击者有7天时间冒用用户身份单Token方案的两个极端有效期问题短30分钟用户频繁登录体验差长7天Token泄露风险大无法主动失效实际场景你做的App用户登录后7天内免登录。某天用户手机丢了捡到手机的人打开App直接就是登录状态。用户改密码没用Token还在有效期内。1.2 双Token如何解决核心思路用两个Token分工Access Token有效期短30分钟用于接口访问Refresh Token有效期长7天用于刷新Access Token用户登录 ↓ 返回 Access Token30分钟 Refresh Token7天 ↓ 前端用 Access Token 调接口 ↓ Access Token 过期30分钟后 ↓ 前端用 Refresh Token 换新的 Access Token ↓ 用户无感知继续使用经验之谈Access Token泄露只影响30分钟Refresh Token泄露可以主动失效存数据库删了就失效。这就是风险隔离的设计思想。二、双令牌的核心实现2.1 登录时返回双TokenpublicclassAuthController{PostMapping(/login)publicResultlogin(RequestBodyLoginDTOdto){// 1. 校验用户名密码UseruseruserService.checkLogin(dto.getUsername(),dto.getPassword());// 2. 生成 Access Token短有效期StringaccessTokenJwtUtil.createToken(user.getId(),user.getUsername(),30*60*1000L// 30分钟);// 3. 生成 Refresh Token长有效期StringrefreshTokenJwtUtil.createToken(user.getId(),refresh,7*24*60*60*1000L// 7天);// 4. Refresh Token 存数据库支持主动失效tokenService.saveRefreshToken(user.getId(),refreshToken,7*24*60*60);// 5. 返回双TokenreturnResult.success(Map.of(accessToken,accessToken,refreshToken,refreshToken,accessExpire,30*60,refreshExpire,7*24*60*60));}}关键点说明Access Token不需要存数据库纯内存验证JWT自包含用户信息Refresh Token必须存数据库否则无法主动失效两个Token用不同的标识区分payload里加个type字段2.2 Token刷新接口PostMapping(/refresh)publicResultrefresh(RequestBodyRefreshDTOdto){StringrefreshTokendto.getRefreshToken();// 1. 校验 Refresh Token 格式和签名if(!JwtUtil.verify(refreshToken)){thrownewBizException(Refresh Token无效);}// 2. 校验 Refresh Token 是否在数据库中可能已被删除LonguserIdJwtUtil.getUserId(refreshToken);if(!tokenService.existsRefreshToken(userId,refreshToken)){thrownewBizException(Refresh Token已失效请重新登录);}// 3. 校验 Token 类型防止用 Access Token 来刷新StringtypeJwtUtil.getType(refreshToken);if(!refresh.equals(type)){thrownewBizException(Token类型错误);}// 4. 生成新的 Access TokenUseruseruserService.getById(userId);StringnewAccessTokenJwtUtil.createToken(user.getId(),user.getUsername(),30*60*1000L);// 5. Refresh Token 轮转可选每次刷新生成新的Refresh TokenStringnewRefreshTokenJwtUtil.createToken(userId,refresh,7*24*60*60*1000L);tokenService.updateRefreshToken(userId,refreshToken,newRefreshToken);returnResult.success(Map.of(accessToken,newAccessToken,refreshToken,newRefreshToken,accessExpire,30*60));}踩坑提醒一定要校验Token类型否则用户拿Access Token也能刷新那双Token的意义就没了。2.3 Token失效踢人/改密PostMapping(/logout)publicResultlogout(){LonguserIdSecurityUtil.getUserId();// 删除数据库中的 Refresh TokentokenService.deleteRefreshToken(userId);// Access Token 不需要删等它自然过期就行returnResult.success(退出成功);}PostMapping(/changePassword)publicResultchangePassword(RequestBodyChangePwdDTOdto){LonguserIdSecurityUtil.getUserId();userService.changePassword(userId,dto.getNewPassword());// 改密后踢掉所有设备tokenService.deleteAllRefreshToken(userId);returnResult.success(密码修改成功请重新登录);}关键点改密码后必须删除所有Refresh Token否则用户改了密码旧Token还能继续刷新。三、前端如何配合3.1 Token存储策略存储位置Access TokenRefresh TokenlocalStorage❌ 不安全❌ 不安全sessionStorage✅ 相对安全❌ 关闭浏览器就没了内存变量✅ 最安全✅ 最安全CookieHttpOnly✅ 防XSS✅ 防XSS经验之谈我推荐的做法是Access Token放内存Vue的data/React的stateRefresh Token放CookieHttpOnly Secure。这样XSS拿不到TokenCSRF也难以利用因为CSRF拿不到Cookie内容。3.2 Token过期自动刷新拦截器// axios 拦截器示例axios.interceptors.response.use(responseresponse,asyncerror{const{response,config}error;// Access Token 过期401if(response?.status401!config._retry){config._retrytrue;// 防止无限重试try{// 用 Refresh Token 换新的 Access Tokenconst{data}awaitaxios.post(/auth/refresh,{refreshToken:getRefreshToken()});// 更新本地 TokensetAccessToken(data.accessToken);if(data.refreshToken){setRefreshToken(data.refreshToken);}// 重试原请求config.headers.AuthorizationBearer${data.accessToken};returnaxios.request(config);}catch(e){// Refresh Token 也失效了跳转登录页router.push(/login);returnPromise.reject(e);}}returnPromise.reject(error);});四、预判问题与解答Q1Refresh Token泄露了怎么办问题场景黑客拿到了用户的Refresh Token能不能一直刷新Access Token解答Refresh Token轮转每次刷新生成新的Refresh Token旧的立即失效。这样黑客拿到的Token可能已经被轮转掉了。设备绑定Refresh Token绑定设备指纹IPUADeviceId刷新时校验设备信息。单设备登录一个用户只允许一个Refresh Token新登录踢掉旧Token。异常检测刷新频率异常如1分钟刷新10次、异地刷新触发告警或自动失效。// 设备绑定示例PostMapping(/refresh)publicResultrefresh(RequestBodyRefreshDTOdto,HttpServletRequestrequest){StringdeviceFingerprintgenerateFingerprint(request);if(!tokenService.checkDevice(userId,refreshToken,deviceFingerprint)){tokenService.deleteRefreshToken(userId);// 异常踢掉thrownewBizException(设备异常请重新登录);}// ...}Q2双Token比单Token多了几次请求性能会不会有问题解答刷新请求只在Access Token过期时发生30分钟一次刷新请求很轻量JWT验证 数据库查询相比用户重新登录输入密码刷新请求几乎无感知性能对比方案7天内的请求次数用户感知单Token7天有效0次额外请求无感知但不安全单Token30分钟有效用户手动登录约16次每次都要输入密码双Token自动刷新约16次无感知Q3JWT还是Session双Token适合哪种解答双Token模式更适合JWT方案。方案Access TokenRefresh TokenJWT存JWT自包含信息存数据库支持失效Session存SessionId存数据库Session方案本身就可以在服务端失效不需要双Token。双Token的价值主要体现在JWT的无状态特性上。Q4Access Token要不要存Redis解答不建议存Redis原因JWT自包含用户信息存Redis就失去了无状态的优势每次请求都要查Redis性能下降Access Token有效期短30分钟泄露风险可控例外情况需要实时踢人删Redis立即生效需要控制并发登录数Q5刷新时要不要返回新的Refresh Token解答建议返回这叫Token轮转。策略优点缺点不轮转实现简单Refresh Token泄露后7天有效轮转泄露风险低旧Token立即失效实现稍复杂经验之谈我建议轮转。实现复杂度增加不多安全性提升明显。五、与Sa-Token的对比Sa-Token框架内置了双Token模式看看它是怎么做的// Sa-Token 配置ConfigurationpublicclassSaTokenConfig{BeanpublicStpInterfacestpInterface(){returnnewStpInterfaceImpl();}}// 登录PostMapping(/login)publicResultlogin(RequestBodyLoginDTOdto){// Sa-Token 一行代码搞定登录StpUtil.login(userId);// 获取 TokenStringtokenValueStpUtil.getTokenValue();returnResult.success(Map.of(token,tokenValue));}// Sa-Token 自动支持 Token 续期// 配置文件设置// token-styleuuid// token-active-timeout1800 30分钟活跃超时// token-timeout604800 7天绝对超时Sa-Token的活跃超时和绝对超时本质上就是双Token的思想活跃超时 Access Token有效期绝对超时 Refresh Token有效期六、面试高频考点面试官问请介绍一下双令牌模式的设计思路参考答案双令牌模式是解决安全性和用户体验矛盾的经典方案。核心思路是用两个Token分工Access Token负责接口访问有效期短30分钟泄露风险可控Refresh Token负责刷新Access Token有效期长7天存数据库支持主动失效工作流程登录时返回双Token前端用Access Token调接口Access Token过期后前端用Refresh Token静默刷新用户无感知除非Refresh Token也失效才需要重新登录关键设计点Refresh Token存数据库支持踢人、改密失效Token类型校验防止用Access Token刷新Token轮转每次刷新生成新Refresh Token面试官问Refresh Token泄露怎么办参考答案多层防护Token轮转每次刷新生成新Token旧Token立即失效设备绑定绑定IPUA设备指纹异常设备拒绝刷新单设备登录新登录踢掉旧Token异常检测刷新频率异常、异地刷新触发告警有效期控制Refresh Token有效期不要太长建议7天以内面试官问双Token和单Token各有什么优缺点参考答案方案优点缺点单Token短安全性好用户频繁登录体验差单Token长用户体验好泄露风险大无法主动失效双Token兼顾安全和体验实现稍复杂多几次刷新请求实际项目中前后端分离、移动端App推荐双Token传统Web应用用Session就够了。面试官问Access Token和Refresh Token存储位置有什么讲究参考答案Access Token放内存Vue/React状态XSS拿不到不放localStorageXSS可读取Refresh Token放HttpOnly CookieXSS拿不到CSRF难以利用或放数据库前端只存TokenId为什么不放localStorageXSS攻击可以读取localStorage一旦被XSSToken全泄露七、总结双令牌模式的核心价值风险隔离Access Token泄露只影响30分钟用户体验Token过期无感知刷新可控失效Refresh Token存数据库支持踢人/改密Token轮转每次刷新生成新Token进一步降低泄露风险一句话总结双Token把长期有效的风险转移到了可主动失效的Refresh Token上实现了安全性和用户体验的平衡。参考资料Sa-Token官方文档 - Token有效期详解RFC 6749 - OAuth 2.0 Authorization FrameworkJWT最佳实践 - RFC 8725互动话题你在项目中用的是单Token还是双Token有没有遇到过Token泄露的问题Refresh Token你是存数据库还是Redis欢迎在评论区分享你的实践经验如果这篇文章对你有帮助欢迎点赞、收藏关注我后续会继续分享更多Java后端技术亮点 本文为【Java后端技术亮点】系列第1篇持续更新中…
【Java后端技术亮点】双令牌模式(Access Token + Refresh Token),彻底搞懂认证机制
写在前面做过前后端分离项目的同学应该都遇到过这个问题——Token过期了怎么办是让用户重新登录还是静默刷新重新登录用户体验差静默刷新又担心安全性。今天聊聊双令牌模式这是Sa-Token、Spring Security OAuth2等框架都在用的经典方案。文章目录一、为什么需要双令牌1.1 单Token方案的痛点1.2 双Token如何解决二、双令牌的核心实现2.1 登录时返回双Token2.2 Token刷新接口2.3 Token失效踢人/改密三、前端如何配合3.1 Token存储策略3.2 Token过期自动刷新拦截器四、预判问题与解答Q1Refresh Token泄露了怎么办Q2双Token比单Token多了几次请求性能会不会有问题Q3JWT还是Session双Token适合哪种Q4Access Token要不要存RedisQ5刷新时要不要返回新的Refresh Token五、与Sa-Token的对比六、面试高频考点面试官问请介绍一下双令牌模式的设计思路面试官问Refresh Token泄露怎么办面试官问双Token和单Token各有什么优缺点面试官问Access Token和Refresh Token存储位置有什么讲究七、总结参考资料一、为什么需要双令牌1.1 单Token方案的痛点踩坑提醒很多新手项目只用一个Token有效期设7天。看起来用户体验好但一旦Token泄露攻击者有7天时间冒用用户身份单Token方案的两个极端有效期问题短30分钟用户频繁登录体验差长7天Token泄露风险大无法主动失效实际场景你做的App用户登录后7天内免登录。某天用户手机丢了捡到手机的人打开App直接就是登录状态。用户改密码没用Token还在有效期内。1.2 双Token如何解决核心思路用两个Token分工Access Token有效期短30分钟用于接口访问Refresh Token有效期长7天用于刷新Access Token用户登录 ↓ 返回 Access Token30分钟 Refresh Token7天 ↓ 前端用 Access Token 调接口 ↓ Access Token 过期30分钟后 ↓ 前端用 Refresh Token 换新的 Access Token ↓ 用户无感知继续使用经验之谈Access Token泄露只影响30分钟Refresh Token泄露可以主动失效存数据库删了就失效。这就是风险隔离的设计思想。二、双令牌的核心实现2.1 登录时返回双TokenpublicclassAuthController{PostMapping(/login)publicResultlogin(RequestBodyLoginDTOdto){// 1. 校验用户名密码UseruseruserService.checkLogin(dto.getUsername(),dto.getPassword());// 2. 生成 Access Token短有效期StringaccessTokenJwtUtil.createToken(user.getId(),user.getUsername(),30*60*1000L// 30分钟);// 3. 生成 Refresh Token长有效期StringrefreshTokenJwtUtil.createToken(user.getId(),refresh,7*24*60*60*1000L// 7天);// 4. Refresh Token 存数据库支持主动失效tokenService.saveRefreshToken(user.getId(),refreshToken,7*24*60*60);// 5. 返回双TokenreturnResult.success(Map.of(accessToken,accessToken,refreshToken,refreshToken,accessExpire,30*60,refreshExpire,7*24*60*60));}}关键点说明Access Token不需要存数据库纯内存验证JWT自包含用户信息Refresh Token必须存数据库否则无法主动失效两个Token用不同的标识区分payload里加个type字段2.2 Token刷新接口PostMapping(/refresh)publicResultrefresh(RequestBodyRefreshDTOdto){StringrefreshTokendto.getRefreshToken();// 1. 校验 Refresh Token 格式和签名if(!JwtUtil.verify(refreshToken)){thrownewBizException(Refresh Token无效);}// 2. 校验 Refresh Token 是否在数据库中可能已被删除LonguserIdJwtUtil.getUserId(refreshToken);if(!tokenService.existsRefreshToken(userId,refreshToken)){thrownewBizException(Refresh Token已失效请重新登录);}// 3. 校验 Token 类型防止用 Access Token 来刷新StringtypeJwtUtil.getType(refreshToken);if(!refresh.equals(type)){thrownewBizException(Token类型错误);}// 4. 生成新的 Access TokenUseruseruserService.getById(userId);StringnewAccessTokenJwtUtil.createToken(user.getId(),user.getUsername(),30*60*1000L);// 5. Refresh Token 轮转可选每次刷新生成新的Refresh TokenStringnewRefreshTokenJwtUtil.createToken(userId,refresh,7*24*60*60*1000L);tokenService.updateRefreshToken(userId,refreshToken,newRefreshToken);returnResult.success(Map.of(accessToken,newAccessToken,refreshToken,newRefreshToken,accessExpire,30*60));}踩坑提醒一定要校验Token类型否则用户拿Access Token也能刷新那双Token的意义就没了。2.3 Token失效踢人/改密PostMapping(/logout)publicResultlogout(){LonguserIdSecurityUtil.getUserId();// 删除数据库中的 Refresh TokentokenService.deleteRefreshToken(userId);// Access Token 不需要删等它自然过期就行returnResult.success(退出成功);}PostMapping(/changePassword)publicResultchangePassword(RequestBodyChangePwdDTOdto){LonguserIdSecurityUtil.getUserId();userService.changePassword(userId,dto.getNewPassword());// 改密后踢掉所有设备tokenService.deleteAllRefreshToken(userId);returnResult.success(密码修改成功请重新登录);}关键点改密码后必须删除所有Refresh Token否则用户改了密码旧Token还能继续刷新。三、前端如何配合3.1 Token存储策略存储位置Access TokenRefresh TokenlocalStorage❌ 不安全❌ 不安全sessionStorage✅ 相对安全❌ 关闭浏览器就没了内存变量✅ 最安全✅ 最安全CookieHttpOnly✅ 防XSS✅ 防XSS经验之谈我推荐的做法是Access Token放内存Vue的data/React的stateRefresh Token放CookieHttpOnly Secure。这样XSS拿不到TokenCSRF也难以利用因为CSRF拿不到Cookie内容。3.2 Token过期自动刷新拦截器// axios 拦截器示例axios.interceptors.response.use(responseresponse,asyncerror{const{response,config}error;// Access Token 过期401if(response?.status401!config._retry){config._retrytrue;// 防止无限重试try{// 用 Refresh Token 换新的 Access Tokenconst{data}awaitaxios.post(/auth/refresh,{refreshToken:getRefreshToken()});// 更新本地 TokensetAccessToken(data.accessToken);if(data.refreshToken){setRefreshToken(data.refreshToken);}// 重试原请求config.headers.AuthorizationBearer${data.accessToken};returnaxios.request(config);}catch(e){// Refresh Token 也失效了跳转登录页router.push(/login);returnPromise.reject(e);}}returnPromise.reject(error);});四、预判问题与解答Q1Refresh Token泄露了怎么办问题场景黑客拿到了用户的Refresh Token能不能一直刷新Access Token解答Refresh Token轮转每次刷新生成新的Refresh Token旧的立即失效。这样黑客拿到的Token可能已经被轮转掉了。设备绑定Refresh Token绑定设备指纹IPUADeviceId刷新时校验设备信息。单设备登录一个用户只允许一个Refresh Token新登录踢掉旧Token。异常检测刷新频率异常如1分钟刷新10次、异地刷新触发告警或自动失效。// 设备绑定示例PostMapping(/refresh)publicResultrefresh(RequestBodyRefreshDTOdto,HttpServletRequestrequest){StringdeviceFingerprintgenerateFingerprint(request);if(!tokenService.checkDevice(userId,refreshToken,deviceFingerprint)){tokenService.deleteRefreshToken(userId);// 异常踢掉thrownewBizException(设备异常请重新登录);}// ...}Q2双Token比单Token多了几次请求性能会不会有问题解答刷新请求只在Access Token过期时发生30分钟一次刷新请求很轻量JWT验证 数据库查询相比用户重新登录输入密码刷新请求几乎无感知性能对比方案7天内的请求次数用户感知单Token7天有效0次额外请求无感知但不安全单Token30分钟有效用户手动登录约16次每次都要输入密码双Token自动刷新约16次无感知Q3JWT还是Session双Token适合哪种解答双Token模式更适合JWT方案。方案Access TokenRefresh TokenJWT存JWT自包含信息存数据库支持失效Session存SessionId存数据库Session方案本身就可以在服务端失效不需要双Token。双Token的价值主要体现在JWT的无状态特性上。Q4Access Token要不要存Redis解答不建议存Redis原因JWT自包含用户信息存Redis就失去了无状态的优势每次请求都要查Redis性能下降Access Token有效期短30分钟泄露风险可控例外情况需要实时踢人删Redis立即生效需要控制并发登录数Q5刷新时要不要返回新的Refresh Token解答建议返回这叫Token轮转。策略优点缺点不轮转实现简单Refresh Token泄露后7天有效轮转泄露风险低旧Token立即失效实现稍复杂经验之谈我建议轮转。实现复杂度增加不多安全性提升明显。五、与Sa-Token的对比Sa-Token框架内置了双Token模式看看它是怎么做的// Sa-Token 配置ConfigurationpublicclassSaTokenConfig{BeanpublicStpInterfacestpInterface(){returnnewStpInterfaceImpl();}}// 登录PostMapping(/login)publicResultlogin(RequestBodyLoginDTOdto){// Sa-Token 一行代码搞定登录StpUtil.login(userId);// 获取 TokenStringtokenValueStpUtil.getTokenValue();returnResult.success(Map.of(token,tokenValue));}// Sa-Token 自动支持 Token 续期// 配置文件设置// token-styleuuid// token-active-timeout1800 30分钟活跃超时// token-timeout604800 7天绝对超时Sa-Token的活跃超时和绝对超时本质上就是双Token的思想活跃超时 Access Token有效期绝对超时 Refresh Token有效期六、面试高频考点面试官问请介绍一下双令牌模式的设计思路参考答案双令牌模式是解决安全性和用户体验矛盾的经典方案。核心思路是用两个Token分工Access Token负责接口访问有效期短30分钟泄露风险可控Refresh Token负责刷新Access Token有效期长7天存数据库支持主动失效工作流程登录时返回双Token前端用Access Token调接口Access Token过期后前端用Refresh Token静默刷新用户无感知除非Refresh Token也失效才需要重新登录关键设计点Refresh Token存数据库支持踢人、改密失效Token类型校验防止用Access Token刷新Token轮转每次刷新生成新Refresh Token面试官问Refresh Token泄露怎么办参考答案多层防护Token轮转每次刷新生成新Token旧Token立即失效设备绑定绑定IPUA设备指纹异常设备拒绝刷新单设备登录新登录踢掉旧Token异常检测刷新频率异常、异地刷新触发告警有效期控制Refresh Token有效期不要太长建议7天以内面试官问双Token和单Token各有什么优缺点参考答案方案优点缺点单Token短安全性好用户频繁登录体验差单Token长用户体验好泄露风险大无法主动失效双Token兼顾安全和体验实现稍复杂多几次刷新请求实际项目中前后端分离、移动端App推荐双Token传统Web应用用Session就够了。面试官问Access Token和Refresh Token存储位置有什么讲究参考答案Access Token放内存Vue/React状态XSS拿不到不放localStorageXSS可读取Refresh Token放HttpOnly CookieXSS拿不到CSRF难以利用或放数据库前端只存TokenId为什么不放localStorageXSS攻击可以读取localStorage一旦被XSSToken全泄露七、总结双令牌模式的核心价值风险隔离Access Token泄露只影响30分钟用户体验Token过期无感知刷新可控失效Refresh Token存数据库支持踢人/改密Token轮转每次刷新生成新Token进一步降低泄露风险一句话总结双Token把长期有效的风险转移到了可主动失效的Refresh Token上实现了安全性和用户体验的平衡。参考资料Sa-Token官方文档 - Token有效期详解RFC 6749 - OAuth 2.0 Authorization FrameworkJWT最佳实践 - RFC 8725互动话题你在项目中用的是单Token还是双Token有没有遇到过Token泄露的问题Refresh Token你是存数据库还是Redis欢迎在评论区分享你的实践经验如果这篇文章对你有帮助欢迎点赞、收藏关注我后续会继续分享更多Java后端技术亮点 本文为【Java后端技术亮点】系列第1篇持续更新中…