前后端分离的项目做多了,你会发现鉴权永远是绕不开的一道坎。SpringBoot和Vue这套组合,配合JWT,基本是目前中小团队做前后端分离接口鉴权的标配方案。这篇文章我结合自己实际项目的接入经验,把JWT从原理到落地的完整过程拆开讲清楚,包括后端如何签发和校验、前端如何携带和刷新、以及那些官方文档里不会告诉你的坑。
1. 为什么前后端分离项目需要JWT
1.1 前后端分离后,Session方案尴尬在哪
传统单体应用里,登录状态靠Session实现。用户登录后,服务端把SessionId写进Cookie,浏览器下次请求自动带上,服务端根据SessionId到内存或Redis里查一下有没有对应会话,有就认为是已登录。
这套流程在前后端不分离的时候没有任何问题,因为页面和接口在同一个域名下,Cookie的传递是浏览器自动完成的。但一旦前后端分离,前端跑在8080端口,后端跑在9090端口,甚至前端是一个独立部署的Nginx服务,情况就变了。
跨域请求下,Cookie的携带变得很麻烦。后端需要配置Access-Control-Allow-Credentials为true,前端axios需要设置withCredentials为true,两者必须同时满足,浏览器才允许跨域携带Cookie。这还只是第一个坑,第二个坑是Session存储在服务端内存里,如果项目做了负载均衡部署了多个实例,用户的请求被分发到不同实例上,Session就丢了,除非引入Redis做Session共享。第三个坑是移动端App或者小程序接这套接口时,根本没有Cookie这个概念,你得手动把SessionId存起来,每次请求塞到Header里。
这里就引出了JWT的价值:它把用户身份信息加密后直接放在Token里发给客户端,服务端不再保存任何会话状态。客户端每次请求把Token放到Header里带回来,服务端验签通过就认这个身份。这就是无状态鉴权,和Session方案有本质区别。
1.2 JWT鉴权的核心原理与优缺点
JWT全称是JSON Web Token,本质上是一串经过签名处理的JSON数据。一个JWT由三部分组成,用点号分隔,分别是Header、Payload、Signature。
Header里声明了签名算法和Token类型,比如HS256。Payload是业务数据区,你可以把用户ID、用户名、过期时间、角色等自定义字段放进去。Signature是签名部分,服务端用密钥把Header和Payload拼起来做哈希计算,防止内容被篡改。
整个流程是:用户登录成功后,服务端生成一个JWT返回给前端;前端存好;后续每次请求在Header里加Authorization: Bearer
JWT的好处很明显:服务端无状态,扩容不需要考虑会话同步;跨域友好,不存在Cookie跨域问题;移动端和浏览器通用,只要请求能带自定义Header就行。
但JWT也有几个天然的短板。最典型的是Token发出去之后,在过期之前你很难让它立即失效。用户点了退出登录,服务端删不掉已经签发的Token,只能等它自然过期。另一个Token体积比SessionId大,放在Header里会稍微增加请求开销。Payload是Base64编码而不是加密的,只要用Base64解码就能看到内容,所以敏感信息绝对不要放进去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与整体设计方案
2.1 依赖选择与版本坑点
Java生态里JWT的库有不少,实际用得最多的是jjwt。这个库由Java JWT的作者维护,API相对简洁,社区活跃度也不错。
但jjwt在版本上有一个大坑:0.9.x版本和0.11.x版本的API完全不一样。如果你在百度或者CSDN上找到一篇老教程,大概率用的是0.9.1版本,API是Jwts.builder()、setSubject()、setExpiration()这种写法。而你pom.xml里如果引入的是0.11.5版本,你会发现setSubject()方法被废弃了,改成.setSubject()依然能用但会提示deprecated,而signWith(SignatureAlgorithm.HS256, key)这种传String密钥的方式也不行了,必须传Key对象。
很多新手在这里卡住,报错信息也不够直观。我的建议是:新项目直接用0.11.5以上版本,因为官方后续版本继续维护,0.9.x版本号虽然也发布了但API较老。同时注意引入依赖时不要漏了javax.xml.bind.DatatypeConverter,JDK 8以上默认不包含这个模块,如果用到DatatypeConverter.printBase64Binary生成密钥,需要额外引入jaxb-api依赖,不然会报ClassNotFoundException。
另外一个常见问题是Spring Boot版本太高导致的兼容性问题。Spring Boot 2.7之后,WebMvcConfigurerAdapter类被移除了,如果你参照老教程继承这个类配置拦截器,项目直接启动失败。正确做法是实现WebMvcConfigurer接口。Spring Boot 3.x则要求JDK 17最低版本,jwt库、json库都要选对应适配版本,这点在搭建项目之前就要确认清楚。
2.2 JWT在项目里的落地结构设计
一个标准的SpringBoot + Vue前后端分离项目,加入JWT后,代码结构上我会建议按这样的方式划分。
后端部分,JWT相关代码主要拆成三个文件:JwtUtil(生成和解析Token的工具类)、JwtInterceptor(拦截器,负责验证请求头里的Token)、WebConfig(配置类,注册拦截器并放行白名单)。如果项目里还涉及权限控制,可以再加一个注解类似@RequireRole,配合拦截器做角色校验,但这个看实际需求。
前端部分,核心是三个文件:axios封装文件(统一在请求拦截器里加Token)、路由配置文件(做全局前置守卫,没登录跳登录页)、store或者localStorage工具(管理Token的存取)。
这个分层结构,核心思路是把JWT的“生成”、“校验”、“携带”三件事分离,互相不掺和。后端只负责生成和校验,前端只负责存储和携带,业务代码里不出现任何JWT相关的逻辑。如果你把Token校验逻辑散落在各个Controller里,后期改起来会非常痛苦。
2.3 token放哪?Store还是LocalStorage?
前端拿到Token后存在哪里,这个问题争论了很多年。实际项目里,主流方案是放localStorage或者sessionStorage。localStorage的特点是不设置过期时间,关闭浏览器再打开数据还在;sessionStorage的特点是关闭浏览器标签页就清空。
我个人更推荐localStorage,配合路由守卫做登录过期检查。原因很简单:sessionStorage在浏览器关闭后数据会丢,用户下次打开还要重新登录,体验不好。localStorage虽然存在XSS攻击下Token被偷走的风险,但只要你在前端注意不轻易使用v-html渲染用户输入、避免把用户内容直接拼进DOM,这个风险是可控的。
Vuex或者Pinia当然也可以存,但注意Store是内存态,刷新页面就丢了。所以正确的做法是:Token持久化到localStorage,同时同步一份到Store,刷新时从localStorage重新取值。前端需要区分“用户身份信息”和“Token”两个概念:Token存localStorage,用户信息可以存Store,刷新后再根据Token从接口拉取。
3. 后端SpringBoot集成JWT完整实现
3.1 登录接口与token生成
先写JwtUtil工具类。这个类要提供两个核心方法:生成Token和解析Token。生成Token时需要传入用户ID、用户名,以及一个过期时间参数。下面是我项目里的实际代码,基于jjwt 0.11.5版本。
java复制import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(Long userId, String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration * 1000);
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
}
密钥长度这里有一个必须注意的点:HS256算法要求密钥长度至少256位,也就是32个字节。你配置里的jwt.secret如果太短,启动时不会报错,但生成Token时会抛WeakKeyException。所以密钥尽量写长一点,比如配置一个32位以上的随机字符串。
过期时间jwt.expiration的单位是秒。我习惯设置成7200,也就是2小时,这个值可以根据业务调整。设置太短用户频繁登录体验差,设置太长Token泄露后风险窗口太大。如果项目对安全性要求高,可以配合刷新Token机制,这个后面细说。
登录接口的Controller比较简单:接收用户名密码,调用UserService校验,成功后调用JwtUtil生成Token返回给前端。注意密码校验用BCrypt,不要明文存储,也不要用MD5做密码哈希。
java复制@PostMapping("/login")
public Result login(@RequestBody LoginRequest request) {
User user = userService.login(request.getUsername(), request.getPassword());
if (user == null) {
return Result.error("用户名或密码错误");
}
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("userInfo", user);
return Result.success(data);
}
3.2 拦截器校验与白名单设计
Token生成好了,接下来要做的是让后端接口都能自动校验Token,而不是在每个Controller里手动调用解析方法。这里就用到了SpringMVC的拦截器机制。
JwtInterceptor继承HandlerInterceptor,在preHandle方法里取出请求头Authorization,判断Token是否有效。
java复制public class JwtInterceptor implements HandlerInterceptor {
private JwtUtil jwtUtil;
public JwtInterceptor(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtUtil.validateToken(token)) {
Claims claims = jwtUtil.parseToken(token);
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("username", claims.get("username"));
return true;
}
}
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或Token已过期\"}");
return false;
}
}
注意几个细节。第一,OPTIONS请求必须放行,因为浏览器在实际请求之前会发一个预检请求,此时不会携带自定义Header,如果拦截器直接拦截,会导致所有跨域请求全部失败。第二,Token被解析后把userId和username放进request的attribute里,这样Controller里用@RequestAttribute Long userId就能拿到当前登录用户,避免在每个接口里重复解析Token。第三,校验失败时直接返回401状态码,配合前端统一处理跳转登录页。
有了拦截器,还需要一个配置类把它注册到SpringMVC,同时配置白名单。所谓白名单,就是不需要登录就能访问的接口,比如登录接口、注册接口、验证码接口。如果项目里还有Swagger文档,也要把Swagger相关的路径放行。
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private JwtUtil jwtUtil;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JwtInterceptor(jwtUtil))
.addPathPatterns("/**")
.excludePathPatterns(
"/api/auth/login",
"/api/auth/register",
"/doc.html",
"/webjars/**",
"/v3/api-docs/**"
);
}
}
网上很多教程把拦截器注册和JWT逻辑放在一起,但实际项目建议分文件,职责清晰,方便后期维护。
3.3 token续签与刷新机制
JWT无状态特性的另一面,是Token一旦签发,在过期前服务端无法主动让它失效。如果用户一直在操作,2小时过期后突然被踢下线,体验很差。所以很多项目会引入“Token续签”机制。
续签方案常见的有三种。
第一种是前端定时刷新。前端设置一个定时器,在Token过期前比如还剩10分钟时,主动调用后端刷新接口,拿旧的Token换取新的Token。这种方式实现简单,但需要后端额外提供一个刷新接口,并且旧Token还有效期内可以换新。
第二种是后端拦截器自动续签。每个请求进来验证Token时,判断如果剩余有效期小于某个阈值,就签发一个新Token放进响应头,前端收到新Token后更新本地存储。这个方案对前端侵入小,但在多实例部署时存在一个换新Token并发的问题,需要额外处理。
第三种是我实际项目中用得最多的方式:短期Token + 长期RefreshToken。登录时后端返回两个Token,accessToken有效期短,比如2小时;refreshToken有效期长,比如7天。前端请求携带accessToken,当接口返回401时,前端用refreshToken调用刷新接口拿到新的accessToken,再重放原来的请求。如果refreshToken也过期了,就跳登录页。
这个方案的好处是安全和体验兼顾。accessToken即使泄露,有效期短,风险可控;refreshToken虽然有效期长,但它只在专门刷新接口里传输,不随业务请求到处带,暴露面小。
后端刷新接口的实现思路是:接收refreshToken,验证它的签名和有效期,看它是否在Redis黑名单里(如果实现了下线踢出功能),然后签发一个新的accessToken返回。注意refreshToken不应该续签本身,而是固定周期更替,避免refreshToken无限期有效下去。
3.4 用户信息怎么从token里拿出来
有个很常见的问题是:前端登录成功后,怎么获取当前用户信息?很多新手的做法是,登录接口返回Token,前端再调一次/api/user/info带Token去拿。这个思路没错,但要注意Token本身已经包含了用户基本信息的签名,后端接口里不需要每次从数据库查用户。
在拦截器放行之后,Controller方法里可以直接通过@RequestAttribute("userId")拿当前用户ID,然后按需查库。这种是性能较好的方式,无论接口被调用多少次,都不需要重新解析Token,因为解析动作在拦截器里已经做过一次了。
如果你用的是Spring Security + JWT的组合,那流程略有不同。Spring Security的过滤器链会在OncePerRequestFilter里解析Token,把Authentication对象放到SecurityContext中,然后Controller里通过@AuthenticationPrincipal或者SecurityContextHolder.getContext().getAuthentication()取当前用户。这个方案比纯拦截器更严格,适合复杂权限模型的项目,但学习曲线也更高,配置节也多,架构比较重。对于中小项目,我的建议是拦截器方案就够了,没必要引入Spring Security全家桶。
4. 前端Vue处理JWT完整实战
4.1 axios拦截器统一携带token
后端校验Token靠的是请求头里的Authorization字段,前端要做的就是每次请求自动带上。手动在每个请求里加Header显然不现实,正确的做法是在axios实例的请求拦截器里统一处理。
javascript复制import axios from 'axios'
import { getToken, removeToken } from '@/utils/auth'
import router from '@/router'
const service = axios.create({
baseURL: '/api',
timeout: 15000
})
service.interceptors.request.use(
config => {
const token = getToken()
if (token) {
config.headers['Authorization'] = 'Bearer ' + token
}
return config
},
error => {
return Promise.reject(error)
}
)
这里getToken方法从localStorage里取值。注意一个细节:header里的样式是Bearer <token>,Bearer和Token之间有一个空格,中间不要加其他参数。后端解析时用的substring(7)就是从位置7开始截取,正好把Bearer 去掉。如果你schema写错成token或者JWT,后端拦截器会解析失败。
4.2 路由守卫控制页面访问
Token带上之后,还需要在路由层面做控制:哪些页面必须登录才能访问,未登录的跳转到登录页并带上回跳地址。
Vue Router的全局前置守卫beforeEach就是干这个的。每次路由跳转前,先判断目标路由是否需要登录,如果需要就检查本地有没有Token。有Token放行,没有Token跳登录页。
javascript复制router.beforeEach((to, from, next) => {
const token = getToken()
if (to.meta.requireAuth) {
if (token) {
next()
} else {
next({
path: '/login',
query: { redirect: to.fullPath }
})
}
} else {
next()
}
})
路由配置里可以给需要登录的页面加一个meta: { requireAuth: true }标记。登录成功后跳转时,判断如果url里有redirect参数,就跳回原目标页,否则跳首页。这样用户访问一个需要登录的页面时被踢去登录,登录完成后能自动回到之前想访问的页面,体验会好很多。
4.3 401统一处理与退出登录
后端接口返回401状态码意味着Token失效或者未登录。常见情况有三种:第一次进入系统还没登录、Token过期了、非法Token被服务端拒绝。前端需要统一处理这些情况,而不是在每个页面弹一个错误提示。
比较规范的做法是在axios响应拦截器里捕获401,然后做两件事:清除本地Token,跳转登录页。
javascript复制service.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 200) {
Message.error(res.msg || '请求失败')
return Promise.reject(new Error(res.msg || '请求失败'))
}
return res
},
error => {
if (error.response && error.response.status === 401) {
removeToken()
router.push({
path: '/login',
query: { redirect: router.currentRoute.fullPath }
})
}
Message.error(error.response?.data?.msg || '网络异常')
return Promise.reject(error)
}
)
如果你做了Token续签机制,这里401的逻辑会复杂一些:先尝试用refreshToken调用刷新接口,刷新成功就重新发起原请求,刷新失败才跳登录页。为了避免多个请求同时401导致重复刷新,需要加一个锁或者标记位,防止并发刷新。
退出登录的逻辑就更简单了:清除本地Token,清除用户信息,跳转登录页。因为JWT是无状态的,后端不需要显式注销接口,只要前端把Token扔掉,下一次请求自然就变成未登录状态。
5. 常见问题与排查技巧实录
5.1 跨域导致token没带上
前后端分离项目跨域问题是重灾区,而JWT模式下跨域的表现又比Session模式更隐蔽。我遇到过不少次,前端明明在请求头里加了Authorization,但后端就是收不到。扒开浏览器Network面板一看,请求直接标红,或者接口返回401。
这种情况大多数是跨域预检请求没处理好。浏览器在发送POST、PUT、DELETE这类会触发预检的请求时,会先发一个OPTIONS请求探测服务端允不允许跨域。如果后端跨域配置没有放行OPTIONS,或者拦截器把OPTIONS请求拦截了,就会出问题。
排查步骤:第一步看Network里有没有OPTIONS请求,状态码是多少;第二步看后端跨域配置是否正确处理了OPTIONS;第三步确认是否配置了CORS允许自定义Header,也就是Access-Control-Allow-Headers里有没有包含Authorization。SpringBoot的跨域配置可以这样写:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
前端的axios也要配合设置withCredentials: true,表示允许跨域携带凭证。如果同一项目同时配置了Nginx反向代理,更推荐用Nginx把/api前缀转发给后端Java服务,做到前后端同域,这样跨域问题直接被规避掉。
5.2 token过期后用户无感知刷新
Token过期是使用中频率最高的异常。基础的处理方式是401后直接跳登录页,但用户正在填表单或者正在看数据的时候突然被踢出去,真的很恼火。
无感知刷新的实现逻辑,我前面提到的RefreshToken方案就是一种。axios拦截器里捕获401后,不立即跳登录页,而是先尝试刷新Token。但版本升级后一些方法被废弃,或者是网上找的代码和本地依赖版本不一致导致编译报错。排查这类问题,先看pom.xml里jjwt的版本,然后根据版本去查对应的API文档。0.9.x用setSubject和signWith(SignatureAlgorithm, String),0.11.x用setSubject还在但推荐signWith(Key)且密钥长度至少256位。如果你硬用0.9.x的API配合0.11.x的版本,编译都过不了。
另一个高频报错是io.jsonwebtoken.security.WeakKeyException。这个报错的字面意思就是密钥太弱。HS256算法要求密钥至少256位,你的jwt.secret配置如果只有"123456"这种长度,一定报错。解决方案是配置一个32位以上的随机字符串。你可以用UUID工具生成,也可以手打一段足够长的无规律字符串。
5.3 JWT安全加固的几个细节
JWT虽然号称安全,但实际项目中如果细节处理不好,等于裸奔。我总结几个必须注意的点。
第一,Payload里不要放密码、手机号、身份证号等敏感信息。JWT的Payload只是Base64URL编码,不是加密,任何人都可以解码看到内容。你可以在jwt.io网站上把一个Token粘贴进去,Payload部分直接明文的。所以Payload只放用户ID、用户名这种非敏感标识信息。
第二,签名密钥一定要放在配置文件里,用环境变量注入,不要硬编码在代码里,更不要提交到Git仓库。密钥泄露意味着任何人都能伪造Token。
第三,日志里不要打印Token。很多排查问题的时候习惯把请求头打出来,Token一旦进日志,可能会随着日志系统被多方查看。我见过有同事排查Bug时把完整Authorization头打印到控制台,最后线上日志泄露了全部用户Token,这个后果很严重。
第四,区分accessToken和refreshToken的用途。accessToken有效期短,可以跟着业务请求走;refreshToken有效期长,但只能用于刷新接口,不要让它出现在普通请求里。另外,refreshToken可以做版本号或者下发时间记录,服务端在刷新时判断这个refreshToken是否已经被使用过,防止重放。
第五,如果项目涉及用户被封禁、修改密码后强制下线等场景,光靠JWT做不到实时失效,必须在Redis里维护一个Token黑名单,或者在Redis里只存“当前有效Token版本号”,拦截器校验时比对版本号,不一致就拒绝。
5.4 实战排查速查表
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 跨域请求全部失败 | OPTIONS请求被拦截 | 拦截器放行OPTIONS,后端配置CORS允许Authorization头 |
| 登录成功但所有业务接口401 | 前端请求没带Token或Header格式不对 | 检查axios拦截器,确认是Bearer + 空格 + Token格式 |
| 接口返回401但前端没跳转 | 响应拦截器没捕获401 | 在axios响应错误回调里判断error.response.status |
| 启动报WeakKeyException | jwt.secret密钥太短 | 配置32位以上字符串作为密钥 |
| 使用0.9.x教程代码编译报错 | jjwt版本API不一致 | 确认依赖版本,按对应版本API修改代码 |
| Token在jwt.io能解码出用户信息 | 这是正常的,Base64不加密 | 不要把敏感信息放Payload,签名密钥保管好 |
| 改密码后旧Token还能用 | JWT无状态导致无法主动失效 | 引入Redis维护Token版本号或黑名单 |
| 多台服务器部署后Token偶发失效 | 密钥配置不一致 | 所有实例的jwt.secret必须完全一致,放到同一个环境变量里 |
最后再说一个我实际踩过的坑。有一次前端反馈说用户登录后偶尔会跳到登录页,频率不高但很烦人。查了很久,最后发现是后端多实例部署时,两台服务器各自从自己的application.yml里读配置,其中一台的jwt.secret被同事改成了默认值,导致它签发的Token另一台验签不通过。从那以后,我把所有涉及密钥、签名的配置全部挪到环境变量和配置中心统一管理,代码里只保留默认占位符。这件事给我最大的启发就是:JWT本身并不复杂,难点往往在集成细节和团队协作上,把方案设计得简单清晰,比什么都重要。
