☰
SpringBoot+Vue3 Token登录验证:签发、拦截、刷新与安全加固实战
2026/10/20 12:41:22 网站建设 项目流程

前后端分离项目里的登录状态管理,看着简单,实际翻车率一直居高不下。很多团队在 springboot+vue3.0 的架构下做 token 安全验证,方案写了一堆,真到了联调阶段,不是前端带着 token 请求被拦截,就是后端解析 token 直接抛异常,问题一个接一个。这篇文章我就从实际项目经验出发,把 springboot+vue3.0+token 这套组合从签发、传输、校验到续期的完整链路拆开讲,包括每一步为什么要这么做,以及那些光看文档根本学不到的坑。

不管你是刚接触前后端分离的小白,还是已经把 JWT 用起来但总觉得不够踏实的开发者,这篇文章的目标都是让你看完之后,能独立设计出一套经得起推敲的 token 安全验证方案。我不讲那种只贴几个代码片段就完事的教程,每个环节都会解释设计理由,并附上可以直接抄走的工程级代码。

1. 为什么前后端分离项目最终都会走到 Token 这条路上

1.1 Session 方案在前端项目里卡在哪

早期做 web 项目,登录状态基本靠 Session 解决。流程很简单:用户提交账号密码,后端验证通过后,在服务器内存里存一份 Session 记录,再把 SessionId 通过 Set-Cookie 写回浏览器。浏览器后续请求自动带上 Cookie,后端拿着 SessionId 查到对应的 Session 记录,就知道这个人是谁。

这个方案在最传统的服务端渲染架构下没有任何问题。可一旦切换到前后端分离架构,麻烦就来了。

前端代码运行在浏览器里,通过 ajax 或 fetch 请求后端接口,而接口地址往往跟页面地址不一样域名,甚至不在同一个服务器上。跨域请求下,Cookie 的携带策略变得非常敏感——浏览器默认不带第三方 Cookie,就算后端在响应里加了Access-Control-Allow-Credentials: true,前端请求里也得把withCredentials打开,同时后端还必须明确指定允许跨域的域名,不能用*通配符。光是这组配置就能劝退一批人。

更麻烦的是扩展性。Session 存在单台服务器的内存里,如果项目做大了,后端需要横向扩展成多台服务器,那用户的 Session 数据只存在某一台机器上,其他机器根本不认识。有人会引入 Redis 做 Session 共享,但这又等于给架构增加了一个重量级的外部依赖。移动端呢?App 里没有 Cookie 的概念,原生请求对象里压根没有 Cookie 存储这回事。就算强行手动维护 Cookie 字符串,也违背了移动端接口调用的常规习惯。

Server 端保存状态的方案,天然不适配多个客户端共用的场景。

1.2 JWT 的核心机制:一段自包含的“通行证”

Token 方案,尤其是 JWT,之所以能成为前后端分离项目的标准答案,核心在于它的无状态性。JWT 是一串经过签名的字符串,由 Header、Payload、Signature 三段组成,用点号拼接。

  • Header 里声明签名算法,比如 HS256。
  • Payload 里放用户信息和过期时间等声明。
  • Signature 则是用服务器密钥对前两段内容做签名。

服务器收到 JWT 后,不需要查数据库,也不需要查缓存,只要用同一个密钥重新计算签名,对比两边是否一致,就能判断 token 是否被篡改;再检查 Payload 里的exp字段是否已过期,就能判断会话是否失效。整个校验过程只涉及计算,不涉及存储,天然支持分布式部署。

我把 JWT 理解成一张防伪的通行证:证件上写了你是谁、什么时候到期,证件上加盖了一个难以伪造的钢印。门卫不需要拿着员工花名册挨个比对,只需要检查钢印是否真实、证件是否过期即可。

注意:无状态是优点,也是隐患。正因为服务器不保存 token 记录,所以在 token 过期之前,服务器无法单方面让某个 token 立刻失效。后文我会专门讲如何弥补这个缺陷。

从选型理由看,springboot+vue3.0 的项目用 JWT 主要有三个原因:

  1. 前端配合成本低。后端返回一段字符串,前端用 JS 存起来,后续请求手动加到 Header 里,没有任何跨域限制。
  2. 后端保持无状态。不需要 Session 同步,不需要 Redis 依赖,水平扩展轻松。
  3. 载荷里能携带非敏感信息。比如用户ID、角色列表、昵称,减少了请求时查询数据库的频率。

2. SpringBoot 后端:Token 的签发、校验与拦截器设计

2.1 签发 Token:登录接口里到底该放什么进载荷

登录接口是 token 的唯一合法来源,所以这一环必须谨慎设计。我用 Java 写一个 JWT 工具类,选型上用java-jwt库,相比jjwt它更轻量,API 也直观:

import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import java.util.Date; public class JwtUtil { private static final String SECRET = "your-secret-key-please-change-this"; private static final long EXPIRE_MS = 2 * 60 * 60 * 1000L; // 默认2小时 public static String createToken(Long userId, String role) { Algorithm algorithm = Algorithm.HMAC256(SECRET); long now = System.currentTimeMillis(); return JWT.create() .withClaim("userId", userId) .withClaim("role", role) .withIssuer("my-app-server") .withIssuedAt(new Date(now)) .withExpiresAt(new Date(now + EXPIRE_MS)) .sign(algorithm); } public static DecodedJWT verifyToken(String token) { Algorithm algorithm = Algorithm.HMAC256(SECRET); JWTVerifier verifier = JWT.require(algorithm) .withIssuer("my-app-server") .build(); return verifier.verify(token); } }

几个关键设计点我详细说明一下:

Payload 只放必要信息。我在上面放了userId和role,这两项是后端后续做权限判断最常用的字段。不要把密码、手机号、身份证号这类敏感数据放进去,因为 JWT 的 Payload 只是 Base64Url 编码,不是加密的。任何拿到 token 的人,都可以用在线工具轻松解码出 Payload 里的全部内容。网上那些“JWT 泄露用户密码”的事件,基本都是把不该放的数据放进了 Payload。

过期时间的权衡。上面的例子设的是 2 小时。时间短了,用户频繁被迫重新登录,体验很差;时间长了,token 泄露后风险窗口更大。常规做法是用短期 token(比如 2 小时)配合一个刷新机制,这个我在后文会专门展开。

签名密钥务必外部化。SECRET 字段在示例里是硬编码的,真实项目里必须放到配置中心或环境变量里,并且不同环境用不同的密钥。如果前后端代码放在同一个代码仓库里,还把这个密钥写死在代码中,那等于把门锁钥匙贴在门框上。至少要做到 dev、test、prod 环境使用不同的密钥,并且密钥长度不低于 32 字节。

登录接口的 Service 层,调用逻辑大致是这样:

@Service public class AuthService { @Autowired private UserMapper userMapper; public LoginResponse login(LoginRequest request) { // 1. 校验用户名密码,这一步注意防止时序攻击 User user = userMapper.findByUsername(request.getUsername()); if (user == null || !encoder.matches(request.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } // 2. 签发 token String token = JwtUtil.createToken(user.getId(), user.getRole()); // 3. 返回 token 和用户基础信息 return new LoginResponse(token, user.getNickname(), user.getRole()); } }

密码校验我特别提醒一句:不要为了方便先查用户名是否存在,再返回“用户不存在”之类明确提示。统一返回“用户名或密码错误”,并且校验过程要让两种失败情况的耗时相近,避免攻击者通过响应时间差判断用户名是否注册过。Spring Security 自带的BCryptPasswordEncoder.matches()能在一定程度上掩盖时间差,建议优先使用。

2.2 校验 Token:拦截器与 Spring Security 的取舍

提到 SpringBoot 的安全体系,很多人第一反应是引入 Spring Security。它功能确实强大,但学习曲线陡峭,配置繁琐,对小团队、轻量级项目来说显得有些重。我个人的经验是:如果你的需求只是验证 token、判断用户身份,没有复杂的 OAuth2、SSO、细粒度方法级权限控制需求,用 HandlerInterceptor 就够了。

自定义拦截器的完整写法如下:

@Component public class AuthInterceptor implements HandlerInterceptor { private static final String AUTH_HEADER = "Authorization"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; // 静态资源等直接放行 } String token = request.getHeader(AUTH_HEADER); if (token == null || token.isEmpty()) { return reject(response, "未登录或 token 缺失"); } // 兼容 "Bearer " 前缀 if (token.startsWith("Bearer ")) { token = token.substring(7); } try { DecodedJWT decoded = JwtUtil.verifyToken(token); Integer userId = decoded.getClaim("userId").asInt(); request.setAttribute("currentUserId", userId); return true; } catch (JWTVerificationException e) { // 签名错误或 token 已过期,统一走这里 return reject(response, "token 无效或已过期"); } } private boolean reject(HttpServletResponse response, String msg) throws IOException { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"" + msg + "\"}"); return false; } }

注册拦截器时,要设置好放行路径:

@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/api/auth/refresh" ); } }

为什么用拦截器而不是在每个 Controller 方法开头手动解析 token?原因很简单:一旦接口多了,散落各处的校验代码必然出现遗漏,而遗漏就意味着某个接口在没有校验的情况下暴露给任何人。拦截器把校验逻辑收敛到一个地方,所有/api/**请求统一经过校验通道,这是工程化项目的基本规范。

2.3 从请求里取 Token 的三种姿势

后端怎么取 Token,看起来是个小问题,实际对接时却最容易出分歧。常见的三种姿势各有优劣:

方式一:自定义 Header(如Authorization)。前端在请求头里手动加上Authorization: <token>或Authorization: Bearer <token>。优点是不受跨域 Cookie 限制影响,前端代码可控性强;缺点是如果前端某个请求忘了加 Header,后端就会收到空 token。

方式二:Cookie 携带。后端通过 Set-Cookie 写入浏览器,前端请求时浏览器自动带上。优点是完全由浏览器管理,刷新页面不丢;缺点是必须配置WithCredentials,且存在 CSRF 攻击风险。这时候你需要额外的 CSRF Token 机制来防护,等于又引入一套复杂度。在前后端分离项目里,我不推荐用 Cookie 存 JWT。

方式三:请求参数携带。token 放在 URL 查询字符串里,比如/api/user?token=xxx。这个方案简单粗暴,但代价是 token 会出现在浏览器历史记录、服务器访问日志、反向代理日志里。任何一个环节日志泄露,token 就跟着泄露,安全等级极低,只适合临时调试。

我在项目里实际采用的是方式一:统一使用Authorization: Bearer <token>格式。加Bearer前缀的好处是规范化,后端可以根据前缀快速识别 token 类型,也为将来可能接入 OAuth2 预留空间。

我在实际项目里遇到过一个很典型的问题。前端同事把 token 放在请求体里,后端拦截器在 Header 里取不到,联调时所有接口全部 403。最后排查了半天,其实不是复杂的技术问题,而是传输位置的约定没有对齐。所以在项目开始的第一天,就要把 token 传输规范写进接口文档,前端、后端各执一份,避免口水战。

3. Vue3 前端:Token 存哪里、怎么带、怎么守

3.1 存储选型:localStorage 与响应式状态配合的坑

Vue3 前端拿到登录接口返回的 token 后,第一件事就是决定存哪里。

常见的选项是localStorage。优点是刷新页面不丢、重启浏览器不丢,生命周期长;缺点是任何在浏览器里执行的脚本(包括 XSS 注入的脚本)都能通过localStorage.getItem('token')读取到 token,存在被窃取的风险。

sessionStorage的数据在浏览器标签页关闭后就会清除,适合对安全性要求更高、允许用户重新登录的场景。但如果用户习惯开多个标签页,每个标签页的 sessionStorage 是独立的,会导致在一个标签页登录后,另一个标签页仍是未登录状态。这个体验问题在真实项目中经常被忽略。

还有一部分人倾向于把 token 放在 Vuex 或 Pinia 的 state 里。好处是组件里取用非常方便,配合computed就能实现响应式;坏处是 store 本身是内存对象,刷新页面就全部归零。所以正确的做法是:持久化存储和响应式状态双轨制——登录成功后,既写入 localStorage,也写入 store;刷新页面时,在应用初始化阶段优先从 localStorage 恢复 token 到 store。

我在项目里会写一个简单的封装:

// stores/auth.ts import { defineStore } from 'pinia'; export const useAuthStore = defineStore('auth', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null'), }), actions: { setAuth(token: string, userInfo: object) { this.token = token; this.userInfo = userInfo; localStorage.setItem('token', token); localStorage.setItem('userInfo', JSON.stringify(userInfo)); }, logout() { this.token = ''; this.userInfo = null; localStorage.removeItem('token'); localStorage.removeItem('userInfo'); }, }, });

顺手还把用户信息一起缓存了,这样前端在拿到接口 401 前,还能从 store 里快速判断当前用户是谁,避免了每个组件都要调接口查资料的窘境。

3.2 Axios 拦截器统一携带 Token 与 401 处理

token 存好了,下一步就是让每个请求都能自动带上它。项目里百分之百会引入 axios 或 fetch,如果你的项目用的是 axios,请求拦截器是标准的解决方案:

// utils/request.ts import axios from 'axios'; import { useAuthStore } from '../stores/auth'; const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }); // 请求拦截器 service.interceptors.request.use((config) => { const authStore = useAuthStore(); if (authStore.token) { config.headers.Authorization = `Bearer ${authStore.token}`; } return config; }); // 响应拦截器 service.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { const authStore = useAuthStore(); authStore.logout(); window.location.href = '/login'; } return Promise.reject(error); } );

这段代码的逻辑非常直白:有 token 就带上,收到 401 就清空凭证并跳转登录页。但实际接入时,有几个隐藏问题值得展开。

第一个问题是请求拦截器不能在模块顶层导出时立即读取 store。Pinia 的useAuthStore()只能在 Pinia 实例已安装后调用。如果request.ts在应用入口导入时就被执行,并且此时调用了useAuthStore(),会得到警告甚至报错。解决办法通常是把调用放入请求拦截器的回调内部,而不是模块最外层。上面示例已经是正确写法,这里专门强调,是为了避免有人把const authStore = useAuthStore()写到了模块顶部。

第二个问题是 401 跳转的时机。如果用户只是浏览一个页面,此时请求一个 401 接口,直接跳转到登录页是没问题的。但如果用户在填写一个较长的表单,中途因为 token 过期而触发了 401,粗暴跳转会丢失所有未保存数据。合理的方式是:弹出一个提示框,告诉用户登录状态已过期,由用户确认后再跳转。甚至可以先尝试无感刷新 token,刷新成功就继续原请求,用户完全无感知。

3.3 路由守卫与白名单

前端路由守卫是拦截未登录页面的第一道关卡。Vue Router 4 配合 Composition API 的写法如下:

// router/index.ts import { createRouter, createWebHistory } from 'vue-router'; const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } }, { path: '/register', component: () => import('../views/Register.vue'), meta: { public: true } }, { path: '/home', component: () => import('../views/Home.vue') }, { path: '/user', component: () => import('../views/UserCenter.vue') }, ], }); router.beforeEach((to, from, next) => { if (to.meta.public) { next(); return; } const token = localStorage.getItem('token'); if (!token) { next({ path: '/login', query: { redirect: to.fullPath } }); return; } next(); }); export default router;

值得注意的是:路由守卫只能做前端访问控制,不能替代后端鉴权。前端最大作用是提升用户体验,让未登录用户尽早看到登录页,而不是请求了一圈接口后才收到 401。真正决定数据是否可见的,永远是后端拦截器。前端守卫和后端拦截器是两个互补的防线,缺一不可。

另外,白名单这个概念不只是“登录页、注册页”。公共页、静态资源、以及某些需要预登录才能访问的内容,都应该在 meta 里做显式声明。我在真实项目里见过把 public 标记漏掉,导致用户访问一个本来就允许匿名访问的页面时被强制跳回登录页。排查这种问题很费时间,所以路由表里每加一个页面,第一件事就是想清楚它是否需要登录,主动在 meta 上打标记。

4. 完整流程串联:从登录到请求再到过期刷新

4.1 一次完整请求背后的距离

把前端和后端的代码组合起来看,一次完整的登录与请求流程包含下面几步。

  1. 用户在登录页输入账号密码,点击登录。
  2. 前端登录接口调用完成后,拿到 token 和用户信息,写入 Pinia 和 localStorage。
  3. 路由跳转到目标页面,因为 token 已存在,路由守卫放行。
  4. 用户触发某个业务请求,axios 的请求拦截器读取 store 中的 token,放到 Authorization Header 上。
  5. 请求到达后端,被 AuthInterceptor 拦截,解析 token,验签,检查过期时间,通过后把 userId 写入 request 上下文。
  6. Controller 拿到 userId,执行业务逻辑,返回数据。
  7. 前端收到数据渲染页面。

如果第 5 步验签失败,后端的拦截器返回 401;前端的响应拦截器收到 401,清空本地凭证,跳转 login 页面。整个过程看起来顺理成章,但新手最容易犯的错误是混淆“路由守卫判断 token 是否为空”和“后端拦截器校验 token 是否有效”。前端拿到的 token 存在,并不代表它有效。它有可能被人手动篡改了,也有可能确实已经过期了。所以前端严格来说只有“无 token”和“有 token”两个状态,真正的有效与否,要看后端说了算。

这也提醒了一个问题:路由守卫不能在前端去做 JWT 有效性检查,比如解析 JWT 过期时间然后决定是否放行。因为前端的 JWT 解析逻辑一旦被绕过(比如有人直接用浏览器控制台修改 localStorage),就完全失效了。而且 token 续期后,前端解析出的过期时间可能已经更新,但路由守卫里缓存的旧值如果不刷新,依然会把用户拦在外面。

4.2 过期刷新策略:单 Token 续期还是双 Token 方案

token 过期之后怎么办?这是整个方案里最需要考虑清楚的一环。

方案一:单 token + 过期后重新登录。这是最简单的实现,token 失效了就让用户回到登录页,重新输账号密码。好处是逻辑简单,几乎不用额外代码;坏处是体验差,用户在填一个长表单时突然被打断,可能直接就流失了。这种方案只适合后台管理系统、低频工具类应用。

方案二:单 token + 刷新接口。后端额外提供一个刷新接口,要求传入未过期程度较低的旧 token,换取新 token。实现上比方案一多一个接口,但体验好很多。不过,这里有一个逻辑漏洞:这个刷新接口本身不能被登录拦截器拦掉,必须放到白名单里;而它要校验旧 token 是否有效,否则任何人都可以拿一个过期 token 无限刷新。所以这个方案的变体通常是:刷新接口在校验旧 token 签名和过期时间时,对过期时间做特殊处理,比如允许最近 7 天内的过期 token 进行刷新。

方案三:双 token(Access Token + Refresh Token)。这是目前企业级项目的主流做法。Access Token 生命周期短(比如 30 分钟),用于正常业务请求;Refresh Token 生命周期长(比如 7 天),只用于调用刷新接口。前端 axios 响应拦截器收到 401,发现是 Access Token 过期,就先带着 Refresh Token 请求刷新接口,拿到新的 Access Token 后,自动重放刚才失败的请求。用户整个过程毫无感知。

我在做交易类系统时,用最多的就是方案三。前端用一个“请求队列”的机制来防止并发失败重试风暴。所谓请求队列,就是当第一个请求因 401 触发了刷新流程时,其他并发请求暂时不回退到登录页,而是排队等待刷新完成。刷新成功后,队列里的所有请求统一带上新 token 重新发出。否则的话,一个页面同时发 5 个请求,5 个请求同时 401,就会触发 5 次刷新操作,既浪费资源又可能出现并发写 token 的竞争问题。

下面是一个简化版的双 Token 刷新流程的伪代码,帮助理解:

// utils/request.ts 中的响应拦截器,简化版 let isRefreshing = false; let pendingQueue: Array<() => void> = []; service.interceptors.response.use( (response) => response.data, async (error) => { const { response, config } = error; if (response?.status === 401 && !config._retry && !config.url.includes('/refresh')) { const authStore = useAuthStore(); if (isRefreshing) { return new Promise((resolve) => { pendingQueue.push(() => resolve(service(config))); }); } config._retry = true; isRefreshing = true; try { const { token } = await service.post('/auth/refresh', { refreshToken: authStore.refreshToken, }); authStore.setToken(token); pendingQueue.forEach((callback) => callback()); pendingQueue = []; return service(config); } catch (refreshError) { authStore.logout(); window.location.href = '/login'; return Promise.reject(refreshError); } finally { isRefreshing = false; } } return Promise.reject(error); } );

这里面的_retry标志位是重试标记,防止同一个请求反复进入刷新逻辑导致死循环。isRefreshing是全局刷新状态锁,确保同一时刻只有一个刷新请求在飞。pendingQueue存放等待中的请求处理器,刷新完成后统一放行。

5. 安全加固与隐藏的坑

5.1 密钥管理、过期时间、敏感信息的工程实践

后端 JWT 的签名密钥是整套安全体系的命门。这个密钥一旦泄露,攻击者可以伪造任意身份的 token,整个登录体系瞬间失效。工程上至少要做到这几点:

第一,密钥分层管理。开发环境、测试环境、生产环境的密钥必须不同,避免开发人员拿着开发密钥去解析生产环境 token。大多数后端框架都支持application-{profile}.yml的配置分离,应该在每个环境的配置文件中注入不同密钥。

第二,密钥定期轮换。JWT 的验签流程有一个常见痛点:token 签名用的密钥换了,所有未过期的旧 token 全部失效,用户被迫重新登录。所以轮换要平滑——后端同时保留旧密钥的验签能力,新签发的 token 用新密钥,旧 token 用旧密钥验签,等到绝大多数旧 token 自然过期后,再彻底下线旧密钥。这个方案实现起来没有想象中复杂,只要注入一个密钥列表即可,验签时逐个尝试。

第三,设置合理的过期时间。不同业务类型对过期时间的容忍度完全不同。普通管理后台,2-4 小时合适;金融类应用,Access Token 甚至可能短到 5-15 分钟;移动端 App 的 Refresh Token 可以放长到 14-30 天。exp是硬性截止时间,最好不要设置成无限期,否则一旦 token 泄露,就等于永久凭证泄露。

第四,Payload 里控制敏感信息。我在前文提到过密码不能放进去,这里再补充一个容易忽略的点:手机号、邮箱、详细地址也不能放。因为这些信息的泄露风险被低估了。攻击者一旦拿到一个长期有效的 token,直接解码 payload 就能获取你的联系方式,然后社工攻击或定向诈骗都顺理成章。

5.2 常见攻击场景与对策

token 验证方案虽然解决了 Session 的跨域和扩展问题,但也引入了新的攻击面。

XSS 攻击是 token 泄露的主要途径之一。如果前端代码存在 XSS 漏洞,攻击者可以在页面中执行任意脚本,直接读取 localStorage 中的 token。对策是双管齐下:一方面加固前端,对用户输入做转义过滤,使用框架自带的插值语法而不是手动拼接 DOM;另一方面缩短 token 有效期,让泄露的 token 尽快失效。XSS 防御做得好不好,决定了 localStorage 方案是否安全。

CSRF 攻击主要影响 Cookie 携带 token 的方案。如果 token 放在 Cookie 里,浏览器会在发请求时自动带上,攻击者可以构造一个恶意页面,诱导用户的浏览器向目标站点发起请求。对策很简单:不要让浏览器自动携带这个 Cookie,而是前端 JS 手动从 Cookie 读取 token,放到 Authorization Header 中,这样跨站请求就带不上 token 了。这其实也印证了前文推荐的 Header 方案在 CSRF 防护上的天然优势。

重放攻击是指攻击者截获了一个合法的请求,稍后再次发送这个请求。JWT 自带的过期时间在一定程度上缓解了长时间重放。对于短时效的 Access Token,重放窗口很小;如果有极高安全需求,可以在关键操作(转账、改密码)中加入一次性随机数的校验,服务器记录已使用的随机数,重复使用则拒绝。

暴力破解签名的可能性也不容忽视。如果密钥强度不够,比如只有 6 位数字,攻击者可以下载大量泄漏的真实 JWT 样本,离线跑字典碰运气。所以密钥长度至少要达到 256 位,并且不能用常见的英文单词、项目名、公司名这类可猜测内容。

5.3 登出/改密码后 Token 为什么还能用

这是 JWT 最常被吐槽的设计缺陷。用户在个人中心点了退出登录,前端把 localStorage 里的 token 删掉了,但攻击者如果在用户退出前就拿到了 token,他手里的那个 token 依然是有效的,直到过期为止。改密码也一样,密码都改了,旧 token 却还能访问旧数据。

解决这个问题的主流方案是引入服务端黑名单:

  • 在 Redis 或 DB 中维护一张黑名单表,记录需要失效的 token 唯一的jti标识和尚存的有效期。
  • 后端拦截器在验签通过后,额外查一次黑名单,如果 token 在黑名单中,直接拒绝。
  • 登出接口:拿到当前 token 的jti,把它加入黑名单。
  • 改密码接口:把该用户所有未过期的 token 的jti全部加入黑名单。

这样一来,JWT 又重新有了服务端状态,但这个状态仅限于失效记录,不保存完整会话,成本和实现复杂度都可控。它包含了一个所有依赖 JWT 的团队迟早都要面对的事实:纯无状态的 JWT 无法实现真正意义上的即时登出。如果你的产品要求“杀进程后立刻踢人”,那无状态 JWT 本身就满足不了,必须配合黑名单机制一起使用。

6. 我在实战中踩过的坑与最终落地方案

6.1 三个真实踩坑记录

先讲我项目里碰到过的一个时间戳问题。有个同事在服务器上配置了 30 分钟的有效期,但客户端反馈 token 十几分钟就过期了。排查到最后发现问题出在服务器时间与浏览器时间不同步,客户端解析 JWT 的过期时间用的是本地时间,而服务端签发时用的是服务器时间。解决方案有两个:统一所有客户端按服务器的响应时间或者网络时间协议校准本地时间;或者在前端千万不要依赖本地时间去解析有效期,一律以后端返回的状态码为准。我们后来直接砍掉了前端解析 JWT 的逻辑,非必要不解析。

第二个坑是 refresh token 和 access token 存在同一个地方带来的安全问题。当时为了省事,我把两个 token 都放在 localStorage,结果安全测试时发现,一旦发生 XSS,攻击者两个都能拿到,刷新链路完全暴露。后来的做法是 access token 放内存,refresh token 放 localStorage,虽然依旧有风险,但至少降低了泄露后的攻击半径。

第三个坑是网关层和业务服务层重复校验 token 导致的响应变慢。项目里有个服务 A 在网关验证了一次 token,进入业务服务时又验了一次,每次验签只是 HMAC-SHA256 计算,本来微秒级,但架不住 JWT 库在验签时要重新构造 verifier、刷新缓存,导致整套链路耗时叠加。后来只在网关统一校验,业务服务通过信任网关传递过来的用户头来获取身份,业务侧不再做二次验签。

6.2 我现在推荐的最小可用架构

跑过这么多项目,我最终落地的方案基本固定为这样一套组合:

模块选型备注
后端SpringBoot + java-jwt用拦截器统一鉴权,不走 Spring Security
token 形式Access Token 30分钟 + Refresh Token 7天双 token 方案
存储access token 存 Pinia,refresh token 存 localStorage兼顾刷新恢复与响应速度
传输Authorization: Bearer不用 Cookie,规避 CSRF
失效处理Redis 黑名单记录登出和改密码的 token每日清理过期记录
前端axios 请求/响应拦截器 + 请求队列并发 401 时只刷新一次,失败后跳登录

这套方案在代码量上没有比单 token 方案多多少,但在安全性和体验上,明显更接近企业级项目要求。

最后提一句关于团队协作的看法。登录鉴权虽然技术含量不算高,但它跨越了整个系统的前后端,任何一个环节出问题都是全局性的。所以开工之前,我把 token 的传输格式、过期时间、刷新策略、401 约定全部写进了接口设计文档,前后端各执一份。事实证明,这笔投入是非常值得的,因为大家在联调阶段几乎不需要为 token 的事情来回扯皮。如果你正打算在 springboot+vue3.0 项目里落地 token 安全验证,照着我前面这几章的思路来设计,至少不会出大的方向性错误。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询