简介一份基于Spring Boot 3与Vue 3的JWT登录Demo定位为前后端分离场景下的认证入门示例适合想快速掌握用户登录、Token鉴权流程的开发者。压缩包共66个文件包含job-admin后端与job-web前端两个子工程后端由13个Java源文件及XML/yml配置构成负责注册登录逻辑与JWT签发校验前端含5个Vue组件、7个TypeScript文件及JSON配置负责页面展示、登录交互与Token存储携带。整个资源仅93KB结构紧凑便于直接阅读和改造。目前已有100人学习浏览可用于理解Spring Boot 3与Vue 3通过HTTP交互完成用户认证的完整链路也能作为前后端分离项目搭建JWT鉴权时的参考模板。1. springboot3vue3 JWT登录Demo两小时能跑通两天才敢说搞懂“搭一个 springboot3vue3 的 JWT 登录 Demo”听起来是半天工作量后端写个生成 Token 的接口前端存一下、请求时带上完事。真动手你才会发现一个登录闭环在两套技术栈之间转一圈要穿过跨域预检、过滤器链、路由守卫、Token 过期、401 重定向这些环节。新手容易卡在“后端明明返回了 Token前端请求却一直 401”熟手也会被 Spring Boot 3 的 Jakarta 命名空间和 Spring Security 6 的新写法绊住照着旧教程抄一个下午全是红叉。这篇笔记按一线团队里最常踩的坑和最常见的搭法顺序来写从选型到前后端实现再到联调排错一次讲完适合准备拿这个 Demo 当脚手架的人也适合已经跑通但想搞明白边界条件的读者。2. 先把技术选型定下来JDK17、jjwt 版本和 Token 存放位置决定后面少踩多少坑2.1 Spring Boot 3 和 Vue 3 的版本前提旧教程为什么不能直接抄springboot3 最直观的变化是强制 JDK 17 起跳内嵌容器和底层 Servlet 从 javax 迁到了 jakarta。你在网上搜到的大部分 Spring Boot 2.x 教程里javax.servlet.Filter、WebSecurityConfigurerAdapter这类代码在 Spring Boot 3 下直接编译不过javax 包一个都 import 不到。这不是代码写错了是整套生态把命名空间换了。所以搭建前先确认三件事本机 JDK 必须是 17 及以上Maven 或 Gradle 能正常拉取 Spring Boot 3.x 依赖前端 Node 版本能跑 Vite 5。这个前提不满足后面每跑一步都会怀疑人生。Vue 3 侧的变化没那么剧烈组件写法还是选项式、组合式任选但配套生态已经固定成 Vite Vue Router 4 Pinia。如果用 Vue CLI 起项目虽然也能跑但 Vite 的启动速度和对.vue单文件的默认处理明显更适合这种前后端分离 Demo。我一般直接npm create vitelatest模板选 Vue 3 JavaScript 就能开工Demo 阶段选 JavaScript 能少写一层类型定义把注意力放在 JWT 这条链路上。Spring Boot 3.x 里spring-boot-starter-security6.x 的配置方式也变了WebSecurityConfigurerAdapter这条路彻底报废必须用SecurityFilterChain的 Bean 声明方式。这套写法在 Boot 2 时代也能用但在 3 里是唯一选择。所以网上那些“继承 WebSecurityConfigurerAdapter 重写 configure”的代码一眼就别看省得带偏思路。2.2 JWT 库选型与 Token 存放位置先决定后写代码后端 JWT 库我推荐 jjwtJava 生态里用得多、资料也全但选版本要特别小心。0.11.5 和 0.12.x 的 API 差别很大0.11.5 用parserBuilder().setSigningKey()0.12.x 改成parser().verifyWith()设置主题时 0.11.5 用.setSubject()0.12.x 直接写.subject()。我习惯先锁 0.11.5网上资料和存量代码多遇到问题好查。如果你想用 0.12.x 也完全可以但拷贝代码时要连 import 包一起核对不要只改依赖版本。在动手写代码前还要知道 JWT 本身是个什么形态。它是一串用两个点分成三段的字符串header 里是签名算法payload 里是业务声明signature 是把前两段用密钥算出来的签名。服务端不存 session靠验签确认 token 是否被篡改。这意味着密钥一旦泄露任何人都能伪造合法 token所以不要把密钥硬编码推到仓库至少放到 application.yml 的环境变量引用里。Demo 可以先用常量但要清楚这是后续必须补的债。Token 存放位置是前端绕不开的选择。常见方案有 localStorage、sessionStorage、Pinia 内存存储和 HttpOnly Cookie。Pinia 配合 localStorage 是 Demo 里最常见的组合Pinia 管当前用户状态localStorage 负责持久化刷新后能恢复登录态。Cookie 方案更安全但要处理 CSRFDemo 阶段先不碰。我的建议很直接先存 localStorage把过期时 401 的处理链路做通再考虑升级成 Cookie 方案。存储位置刷新后状态主要风险Demo 阶段推荐度localStorage保留XSS 脚本可直接读取推荐配合路由守卫sessionStorage保留到标签页关闭XSS 可读可以体验稍差Pinia 内存丢失回到登录页无持久化泄漏适合做状态同步HttpOnly Cookie保留依赖后端下发需防 CSRF进阶再上实际在 Demo 里我会用 Pinia 管理当前登录用户localStorage 里只存 token 和用户名这样刷新后有依据恢复 store。一个边界要记住localStorage 里不要放用户昵称、手机号这些敏感字段用户资料应该用 token 去换。2.3 前后端工程骨架一个能跑通的最小目录结构我搭这个 Demo 时后端是标准 Maven 目录前端用 Vite 承载。后端包路径全部放在com.example.demo下面避免默认包带来的组件扫描问题前端把src/拆成api/、store/、router/、views/四块。拆目录不是摆架子是因为 JWT 登录涉及配置类、过滤器、工具类、控制器四类文件堆在同一个包下面联调时找问题会先花 20 分钟找文件。demo-backend/ src/main/java/com/example/demo/ config/SecurityConfig.java filter/JwtAuthenticationFilter.java util/JwtUtil.java controller/AuthController.java demo-frontend/ src/ api/request.js api/auth.js store/auth.js router/index.js views/LoginView.vue App.vue目录的划分逻辑是controller 只接参数、调 serviceservice 校验用户名密码filter 只做 token 解析和身份写入SecurityConfig 只定义放行规则。文件依赖方向是单向的不会出现 filter 里直接操作数据库这种绕路写法。后续无论你往这个基座里加注册、加刷新 Token 接口还是在拦截器里做权限判断结构都不用推翻。后端直接用 Spring Initializr 生成项目依赖选 Spring Web 和 Spring Security前端创建完跑一次依赖安装。命令记录一下后面前后端联调全靠这两个进程同时开着# 前端依赖安装在 demo-frontend 目录下 npm install axios pinia vue-router # 启动后端接口默认端口 8080 mvn spring-boot:run两条命令没有先后依赖关系但调试时建议先确认后端 8080 起来再启前端 Vite。因为前端页面加载时如果调用了业务接口后端没起来Axios 报的是网络错误而不是 401容易让新手误判成跨域问题。如果你用npm create vuelatest生成的工程记得把createWebHistory换成createWebHashHistory前后端分离联调时能少处理一层路由回退配置。3. 后端落地签发 JWT、写登录接口、配置 Spring Security 6 过滤链3.1 后端最小依赖starter-web、starter-security 与 jjwt 的取舍后端依赖就四组按需引入不多不少。spring-boot-starter-web 提供 MVC 和 Tomcatspring-boot-starter-security 提供认证授权骨架jjwt 三件套负责 JWT 的生成和解析。pom.xml 关键部分如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependencyjjwt-api 编译期引入jjwt-impl 和 jjwt-jackson 运行时才需要分别提供实现和 JSON 序列化。少了 jjwt-jackson解析时会因为找不到 JSON 转换器直接抛 JwtException。spring-boot-starter-security 加进来后默认会拦下所有请求所以下一步必须显式写放行规则否则登录接口都进不去。有同学问我“不引入 Spring Security用 HandlerInterceptor 行不行”。行但那样你就得自己处理未登录返回 401 的结构复杂点儿的权限控制也更麻烦。既然标题是 springboot3vue3 的登录 Demo把 Security 一起跑了反而能一次摸到真实工程的边界。想精简的话把 SecurityFilterChain 换成 WebMvcConfigurer 注册 HandlerInterceptor拦截器代码和后面的 JwtAuthenticationFilter 里相差不大。3.2 JwtUtil 工具类签发和解析 Token 的核心代码JwtUtil 是整个 Demo 的核心不依赖 Spring单独拎出来也能测。它负责两件事登录成功后生成 token后续请求里解析 token。代码不长但加密参数直接决定你能不能跑起来。package com.example.demo.util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; public class JwtUtil { // 密钥长度必须 32 字节否则 HS256 在启动时直接抛 WeakKeyException private static final String SECRET demo-secret-key-change-it-in-production-0123456789; private static final long EXPIRE_MILLIS 1000 * 60 * 60 * 2L; private static final SecretKey KEY Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); public static String createToken(String username, Long userId) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MILLIS)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } }createToken 生成的是签名型 token服务端拿到后先根据同一个 KEY 验签再读 payload 里的 username 和 userId。验签是单向的任何一次篡改都会导致验签失败所以 token 不能改、不能拆只能整体换新的。参数说明里最要紧的是 SECRET。HS256 要求密钥字节数不小于 256 位也就是 32 字节上面的字符串长度不够会在启动时直接抛异常。EXPIRE_MILLIS 设的是 2 小时Demo 阶段可以改短到 30 分钟来测过期逻辑但别写成 7 天否则前端永远等不到 401 场景。parseToken 只负责解析调用方要自己捕获 JwtException——token 过期、签名不对、格式错误都会在这个方法抛出异常我习惯把异常翻译成业务异常再往外抛由控制器统一转换成 401。提示如果你的 jjwt 版本换成 0.12.xsetSubject要改成subjectparserBuilder().setSigningKey().build().parseClaimsJws()要改成parser().verifyWith().build().parseSignedClaims()接口签名完全不同。3.3 登录接口和 Security 配置放行 /api/auth/loginAuthController 不直接操作数据库Demo 阶段用一个静态账号充当用户表。验证通过后签发 token 返回给前端验证失败直接返回 401 状态码前端响应拦截器统一接住。package com.example.demo.controller; import com.example.demo.util.JwtUtil; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; RestController RequestMapping(/api/auth) public class AuthController { private static final MapString, String USER_DB Map.of(admin, 123456); PostMapping(/login) public ResponseEntityMapString, Object login(RequestBody LoginRequest request) { String password USER_DB.get(request.getUsername()); if (password null || !password.equals(request.getPassword())) { return ResponseEntity.status(401).body(Map.of(message, 用户名或密码错误)); } String token JwtUtil.createToken(request.getUsername(), 1L); return ResponseEntity.ok(Map.of(token, token)); } public static class LoginRequest { private String username; private String password; public String getUsername() { return username; } public String getPassword() { return password; } public void setUsername(String username) { this.username username; } public void setPassword(String password) { this.password password; } } }接下来是 Spring Security 6 的配置类。这一个类放行登录接口、关掉 CSRF、设置无状态会话并把 JWT 过滤器注册到合适的位置。package com.example.demo.config; import com.example.demo.filter.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; Configuration EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter jwtAuthenticationFilter; } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这段配置是 Spring Security 6 的标准写法核心是一句话关会话、放登录、拦其余。csrf().disable()是因为不用 Cookie 会话CSRF 防护没有意义STATELESS告诉 Spring 不创建 HttpSession每次请求都靠 token 认身份/api/auth/login放行是因为登录接口本身不需要 token。OPTIONS预检放行是给浏览器跨域用的axios 发跨域请求前会先发一个不带业务头的 OPTIONS这个请求必须放行否则真正的 POST 永远发不出去。最后把 JWT 过滤器加到UsernamePasswordAuthenticationFilter之前这个位置决定了过滤器拿到的还是原始请求来得及把认证信息写进上下文。如果顺序反了Spring Security 已经完成匿名认证JWT 过滤器再塞身份就晚了。3.4 JWT 过滤器从 Header 到 SecurityContext 的完整链路JwtAuthenticationFilter 的职责很单一如果请求头里带 Bearer token就解析、验签、把用户信息塞给 Spring Security。任何解析失败都直接跳过让匿名身份继续往下走最终被前面的.authenticated()拦住。package com.example.demo.filter; import io.jsonwebtoken.Claims; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims JwtUtil.parseToken(token); String username claims.getSubject(); ListSimpleGrantedAuthority authorities List.of(new SimpleGrantedAuthority(ROLE_USER)); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(username, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // token 过期、签名错误、格式非法都走这里不设置认证对象 } } chain.doFilter(request, response); } }UsernamePasswordAuthenticationToken的第一个参数是 principal这里放用户名第二个是凭证因为 token 已经验证过所以置空第三个是权限集合。SecurityContextHolder是线程级容器后续PreAuthorize和anyRequest().authenticated()都从这里取数据。解析失败时不设置认证对象请求最终会被拒绝并返回 401。用OncePerRequestFilter而不是普通 Filter是为了防止一次请求被转发时执行两次实现对同一个请求只过滤一次。Demo 里可能感受不到但真实项目有内部转发时普通 Filter 容易制造重复认证的隐患。另外过期 token 的异常建议在 catch 里单独区分ExpiredJwtException后续做 token 续期时要用它判断是哪一种失效。4. 前端落地Vue3 登录页、Pinia 存储与 Axios 拦截器联动4.1 创建 Vue3 Vite 工程与前端依赖前端侧的命令很直接用 Vite 官方模板生成工程然后补三个依赖axios 负责请求pinia 管状态vue-router 管路由。npm create vitelatest demo-frontend -- --template vue cd demo-frontend npm install npm install axios pinia vue-routerVite 模板生成的是最基本的 App.vue没有路由也没有状态管理。axios 用默认版本即可但 pinia 和 vue-router 要注意 main.js 里的挂载方式Vue 3 里插件必须通过app.use()注册。如果你用npm create vuelatest生成的项目自带 Router 和 Pinia可以少搭一层目录但对我来说 Vite 模板更干净没有脚手架自带的一大堆校验配置。前后端分离联调时我建议直接把跨域问题掐死在 Vite 开发服务器里。在 vite.config.js 加一个代理把/api转发到后端 8080// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, })这样浏览器里所有请求都发往 5173Vite 再把带/api前缀的请求转发给 8080浏览器侧不存在跨域后端也能少写一套 CORS 配置。用了代理之后前端 axios 的 baseURL 写空字符串就好请求路径直接写/api/auth/login和后端接口一一对应。4.2 登录页与 Pinia 状态管理Token 刷新后怎么办Pinia store 要做两件事内存里存登录态同时同步到 localStorage。state 初始化时先从 localStorage 恢复刷新页面后登录状态还能找回。// store/auth.js import { defineStore } from pinia export const useAuthStore defineStore(auth, { state: () ({ token: localStorage.getItem(token) || , username: localStorage.getItem(username) || , }), actions: { setLogin({ token, username }) { this.token token this.username username localStorage.setItem(token, token) localStorage.setItem(username, username) }, logout() { this.token this.username localStorage.removeItem(token) localStorage.removeItem(username) }, }, })这个 store 解决了刷新丢登录态的问题登录成功后写两份刷新后从 localStorage 恢复。username也存一份是因为 JWT 的 payload 里虽然有名但前端不解析 base64 就不会读与其写一个解码函数不如登录时顺手存了。注意这只适合 Demo真实项目里用户资料应该通过/api/auth/info获取不落本地。当用户退出或者 token 过期被踢回登录页时要把 store 和 localStorage 同时清掉。常见错误是只清了 store 忘了 localStorage导致新登录的人还带着上一个会话的 token。用法是封装auth.logout()路由跳转只调这一个方法避免清不干净。登录页面组件写起来很短form 提交时调 login 接口成功之后写 store再跳转首页template form submit.preventhandleLogin input v-modelusername placeholder用户名 / input v-modelpassword typepassword placeholder密码 / button typesubmit登录/button /form /template script setup import { ref } from vue import { useRouter } from vue-router import { useAuthStore } from ../store/auth import { login } from ../api/auth const router useRouter() const auth useAuthStore() const username ref() const password ref() async function handleLogin() { const res await login({ username: username.value, password: password.value }) auth.setLogin({ token: res.token, username: username.value }) router.push(/) } /script后端登录接口返回的res是{ token }前端拿到后直接调setLogin。如果接口返回结构是嵌套的比如{ data: { token } }那要在 axios 响应拦截器里统一解包前端业务代码只用拿最终结果。4.3 Axios 实例与请求拦截器每个请求自动带 Authorizationrequest.js 是所有接口请求的统一出口。请求拦截器负责加 token响应拦截器负责拆包和处理 401。// api/request.js import axios from axios const request axios.create({ baseURL: , timeout: 10000, }) // 请求拦截自动带上 Authorization request.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) ) // 响应拦截统一拆 data 层处理 401 request.interceptors.response.use( (response) response.data, (error) { if (error.response?.status 401) { localStorage.removeItem(token) window.location.replace(/login) } return Promise.reject(error) } ) export default request统一在请求拦截器里读取 localStorage拿到 token 就拼成Bearer xxx塞进 Authorization 头。业务代码不需要在每个接口里手动带 token新增接口时少忘一个头。拦截器执行时机是每次请求发起前注意 axios 版本里 config.headers 是 AxiosHeaders 实例直接对属性赋值没问题别去操作它的原型方法。baseURL 留空是为了配合 Vite 代理请求路径完整写成/api/auth/login。如果没有代理、前端直连后端域名那 baseURL 要写成http://localhost:8080同时后端要配置 CORS这是另一套方案。响应拦截器把response.data直接返回业务代码里拿到的就是后端返回对象少一层.data的重复劳动。每个接口文件只需要几行// api/auth.js import request from ./request export const login (data) request.post(/api/auth/login, data)登录接口就是request.post(/api/auth/login, data)token 自动被打包进 Header。这里有个细节登录本身不需要带 Authorization但如果 localStorage 里残留了过期 token请求拦截器一样会加上此时后端如果先校验了旧 token 再走登录逻辑就会出现“登录接口也 401”。常见做法是在请求拦截器里对/login做一个判断有 token 时不拼 Authorization。4.4 路由守卫没有 Token 直接踢回登录页路由守卫是前端登录态的最后一扇门。用户没登录就访问受保护页面时在路由跳转之前拦下来。// router/index.js import { createRouter, createWebHashHistory } from vue-router import LoginView from ../views/LoginView.vue const router createRouter({ history: createWebHashHistory(), routes: [ { path: /login, component: LoginView }, { path: /, component: () import(../views/HomeView.vue), meta: { requiresAuth: true } }, ], }) router.beforeEach((to) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { return { path: /login } } return true }) export default router用meta.requiresAuth标记需要登录的页面不需要在每个路由里手写判断逻辑。守卫只判断 token 存不存在不判断 token 过不过期过期由 axios 的 401 拦截统一处理两件事分工明确。用户停留页面上不操作token 过期后不会被立刻踢出直到下一次请求收到 401 才跳登录页。路由模式用createWebHashHistory路径里有#刷新页面不会找后端要资源对纯前后端分离的 Demo 最省事。等部署到 Nginx 时再切回createWebHistory配合try_files回退到 index.html。本地联调阶段用 hash 模式就够。每次改完路由可以在浏览器地址栏直接按回车试刷新如果跳回登录页说明守卫逻辑生效了。5. 联调避坑springboot3vue3 JWT 登录最常见的 5 个翻车现场联调阶段的问题基本都出在两件事上请求头里的 token 没正确传递路径和安全配置两边基准不一致。下面 5 个场景是我在搭这类登录 Demo 时反复见到的每条按现象、原因、解决三步写方便照着排查。如果你已经跑通前后端建议也扫一遍很多“偶尔 401”的随机问题就藏在这些细节里。5.1 现象跨域 OPTIONS 预检被拦截登录接口直接报 CORS现象前端 axios 发登录请求Network 里能看到一条 OPTIONS 请求返回 403浏览器报 CORS errorPOST 请求根本没发出去。原因浏览器跨域保护要求非简单请求先发 OPTIONS 预检。后端如果没有放行 OPTIONS请求就被 Spring Security 拦了。很多人只往接口上加了CrossOrigin没在 Security 层处理 OPTIONS预检请求还是进不到控制器。解决在 SecurityConfig 里加.requestMatchers(HttpMethod.OPTIONS, /**).permitAll()。更标准的方式是用CorsConfigurationSource定义一个全局 CORS Bean并在http.cors()里开启CrossOrigin在 Spring Security 过滤链生效时经常不起作用。如果你用了第 4 章的 Vite 代理方案这一步完全可以不配。5.2 现象前端一直 401但同一个请求在 Postman 里是好的现象用 Postman 调受保护接口带 token 能正常返回前端一调就是 401换浏览器也无解。原因Authorization 头没被带上常见两处axios 拦截器没执行到比如某处直接用了axios.get而不是封装后的 request 实例或者 token 变量名不一致拦截器读的是userToken登录时存的是token自然读不到。更隐蔽的原因是 token 字符串里混了空格或换行拼出来变成Bearer eyJ...后端header.substring(7)截出来的内容多了前导空格验签失败。解决先看 Network 里请求头的 Authorization 实际长什么样再确认该请求有没有走同一个 request 实例。登录接口也要放行如果它也被请求拦截器拼了旧 token旧 token 过期后会反过来拦住登录接口本身。我一般会在拦截器里做一个/login白名单不带旧 token其余接口统一按Authorization: Bearer token拼。5.3 现象刷新页面登录态丢失Pinia 里存的 Token 不见了现象登录成功后一切正常一按 F5 就跳回登录页。后端日志里没有任何错误前端也没请求后端。原因Pinia store 是内存状态刷新后所有 state 重置为空。如果登录时只写了store.token token没同步 localStorage刷新自然就丢了。路由守卫又只认 localStorage所以直接被踢回登录页。这个问题我见过有人配了一整天才发现跟后端一点关系没有纯前端玄学。解决按 4.2 的 store 写法初始化时从 localStorage 恢复登录成功时把 token 同时写进 store 和 localStorage登出时两处一起清。让你维护的持久化源只有一个不要同时开三条存储链路。另一个小坑是setLogin里如果少传了 username刷新后用户名会变成空字符串页面显示会闪烁一下。5.4 现象Security 放行路径不生效requestMatchers 没匹配上现象配置里明明写了/api/auth/loginpermitAll登录接口仍然返回 401其他接口也一起受影响。原因Spring Security 6 的requestMatchers默认匹配路径模式不是正则。如果你写的模式带多余空格或漏了/匹配不上。更典型的坑是项目配置了server.servlet.context-path/api此时控制器实际映射是/auth/loginSecurity 配置里写的/api/auth/login是浏览器视角两边基准不一致放行规则根本没对上。解决统一以控制器里的真实路径作为基准写放行规则。如果用了 context-path可以直接去掉它让路径直白或者所有接口统一从/api开头Security 也按/api/**匹配。配置 Nginx 时也要检查proxy_pass是否改了 URI 前缀后端看到的路径跟前端不一致放行规则就失效。这个坑在本地直连时看不出来一上代理就现原形。5.5 现象同时用 Security 过滤器和 HandlerInterceptor请求被拦两次现象控制器里拿不到用户信息或者日志里过滤器逻辑执行了两次再或者配了拦截器但不生效接口能通但代码里到处从 Header 里手动取 token。原因Spring Security 是一整条过滤器链HandlerInterceptor 是 Spring MVC 的拦截器两者在不同层次工作。很多人一边在 Security 里注册了 JwtAuthenticationFilter一边又在 WebMvcConfigurer 里 addInterceptors 加了一遍同样逻辑代码重复执行SecurityContext 和 自定义上下文互相覆盖。解决Demo 阶段二选一。用了 Spring Security就只保留 JwtAuthenticationFilter不用 Security就只保留 HandlerInterceptor。两套同时上排查问题时根本分不清是哪一层在拦。我的经验是项目后面要做权限控制早期就用 Security Filter只做登录放行简单拦截器也足够。真需要两套并存时让 Security 只负责认证HandlerInterceptor 只做业务参数校验两边不要同时写 token 解析。6. 把 Demo 升级成能用的登录基座Token 续期与 401 统一处理Demo 跑通之后第一件要补的事是 Token 续期。JWT 签发时就定死了有效期2 小时过期后用户被强制踢下线体验很差。常见做法是 access token 短一点半小时或两小时另签一个 refresh token 给七天用/api/auth/refresh接口换新 token。前端在 axios 响应拦截器收到 401 时先判断是 access token 过期再拿 refresh_token 请求刷新成功后重放刚才失败的请求request.interceptors.response.use( (response) response.data, async (error) { const originalRequest error.config if (error.response?.status 401 !originalRequest._retry) { originalRequest._retry true try { const { token } await request.post(/api/auth/refresh, { refreshToken: localStorage.getItem(refreshToken), }) localStorage.setItem(token, token) originalRequest.headers.Authorization Bearer ${token} return request(originalRequest) } catch (e) { localStorage.clear() window.location.replace(/login) } } return Promise.reject(error) } )_retry标记是为了避免刷新后请求再次失败形成死循环。刷新接口本身也走同一个 request 实例如果它在 401 时同样进入这个拦截器就会无限递归所以要么加标记跳过要么单独建一个不带响应拦截器的 axios 实例。这是续期方案最容易踩的坑。再补充一个认知边界JWT 无状态意味着服务端不保存会话登出时只清前端 token旧 token 在有效期里仍然能访问接口。高安全场景要引入黑名单或 Redis 存储被注销的 token这不属于 Demo 范围但需要心里有数。续期、登出、踢人这些功能都要自己设计这是 JWT 方案和 Session 方案最本质的区别。我自己搭这种登录基座的习惯是先让“登录、放行、过期踢回”这条主线跑通再加刷新逻辑。刷新的复杂点不在后端而在前端如何防止并发 401 重复刷新多个请求同时过期时会产生多次 refresh 请求。真实项目里可以用 axios 拦截器做一个 refresh 队列把所有并发失败的请求串起来只刷一次。Demo 阶段用_retry就够了。这个流程完整过一遍才算把 springboot3vue3 JWT 登录这块硬骨头啃下来。希望这篇能帮你少走两小时弯路。本文还有配套的精品资源点击获取