前后端分离做了这么多年,权限认证这块几乎是每次都要面对的老问题。Session方案在前后端同域时代挺好用,可一旦前端拆出去单独部署,跨域、分布式、移动端适配这几个坎全都绕不过去。后来切到JWT(JSON Web Token)之后,这些问题算是从根上解决了一大半。这次把我从项目里沉淀下来的JWT权限认证实践整套整理出来,从原理、代码到续签、安全防护都过一遍,适合刚开始接触微服务或者前后端分离项目、打算把权限模块彻底搞清楚的开发者参考。
1. JWT认证的核心机制与设计思路
1.1 一段代码理清JWT三段式结构:Header.Payload.Signature
JWT本质上是一个经过签名的JSON字符串,由三个部分用点号拼接而成:头部(Header)、载荷(Payload)、签名(Signature)。我见过很多同事一开始被这东西吓住,觉得它高深,其实拆开看非常简单。
先看Header,它就是一个JSON对象,记录了令牌的类型和签名算法。最常见的是HS256,也就是HMAC-SHA256对称签名算法:
json复制{
"alg": "HS256",
"typ": "JWT"
}
再看Payload,这是整个令牌的核心业务区。里面放的是声明(Claims),比如用户ID、用户名、过期时间、签发时间等。下面这个是我在项目里常用的一组声明:
json复制{
"sub": "10086",
"username": "zhangsan",
"iat": 1710000000,
"exp": 1710003600
}
这里有个特别容易被误解的地方:Header和Payload只是被Base64URL编码,并没有加密。任何人拿到token,把它贴到jwt.io或者其他在线解析工具里,都能直接看到里面所有内容。所以千万别往Payload里塞密码、手机号、身份证这类敏感数据,不然等于把用户信息公开贴出去了。签名部分才是JWT安全性的真正来源,签名算法会结合Header里声明的算法、Base64URL编码后的Header与Payload,以及服务端持有的密钥,计算出一段签名摘要。
以HS256为例,签名的大致计算方式可以理解为:
text复制signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secretKey
)
Token的完整形态看起来就是下面这样,点号分隔三段:
text复制eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDA4NiIsInVzZXJuYW1lIjoiemhhbmdzYW4ifQ.6fBxVn7sQy2bH0YLI1m_N3YQhgVW5N7zIoG_7h_V6G8
1.2 从登录到校验:一条Token的完整生命周期
理解了结构之后,再看JWT在真实项目里怎么流转。整个过程可以分成签发和校验两段,我用一个最典型的用户登录场景来讲。
第一步,用户提交用户名和密码。服务端校验账号密码成功后,组装好用户相关的Claims,结合密钥签发JWT,返回给前端。前端拿到token后,通常保存在localStorage或者内存中,后续所有需要认证的请求,都会在HTTP请求头里带上这样一个字段:
http复制Authorization: Bearer <token>
第二步,服务端收到请求后,拦截器或者过滤器先取出请求头里的token,依次做三件事:解析Header和Payload、用服务端密钥重新计算签名并对比、检查exp过期时间。只要签名不一致或者token过期,直接拒绝请求返回401。校验通过,才把请求放行到真正的业务接口。
这个过程中JWT最大的特点是服务端无需保存任何会话状态。token里该有的信息全都有,服务端只管验签,验完就信。这也是"无状态认证"这个词的来源。
1.3 为什么我在新项目里坚持用JWT而不是Session
不是所有项目都适合JWT,但如果你踩中了下面这几个场景,换成JWT会舒服很多。
第一个场景是前后端完全分离。前端部署在一个域名,后端接口在另一个域名,Session方案需要处理跨域Cookie、CSRF等一系列问题。JWT配合Authorization请求头,天然没有跨域携带的负担,只要前端在请求拦截器里统一加header就行。
第二个场景是横向扩展。Session是存储在服务端内存里的,一旦部署多台服务器,用户在第一台机器登录,第二台机器不认,就得引入Redis做会话共享。JWT无状态,每台服务器只靠密钥验签,谁接到请求都能直接处理,省掉了Redis存储这一层间接依赖。
第三个场景是移动端或第三方客户端接入。App、小程序、H5多端同时接入时,Session的Cookie机制在App里并不是那么顺手,而JWT只需要保存一个token字符串,放到哪个端都很自然。
当然,JWT也不是银弹。主动踢人下线做不了、token泄露后不好撤销,这些缺点在后文安全部分会详细说,但整体上对于大多数业务系统,JWT这套方案的收益是远大于代价的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring Boot集成JWT:依赖、工具类、拦截器三步走
2.1 依赖引入与JWT工具类封装
技术选型上,Java生态里最常用的是jjwt这个库,github上star数非常高,API设计也比较顺手。这里以jjwt 0.11.5版本为例,首先在pom.xml里引入依赖:
xml复制<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
依赖配好之后,第一步是写JWT工具类。工具类至少需要封装三个方法:生成token、解析token、校验token是否有效。下面这份代码是我在项目里封装的简化版本,核心逻辑都在里面:
java复制@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire}")
private Long expire;
/**
* 生成token
*/
public String generateToken(Long userId, String username) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(String.valueOf(userId))
.claim("username", username)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
.compact();
}
/**
* 解析token,获取Claims
*/
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 校验token是否有效
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
}
需要特别提醒的是密钥长度。HS256算法要求密钥长度至少是256位(32字节),如果用太短的字符串做密钥,jjwt启动时会直接抛异常。我在第一次写的时候就踩过这个坑,随便配了个"abc123",结果解析token一直报错。建议密钥直接用Base64编码后的64位以上随机字符串,不要用明文短口令。
2.2 登录签发与拦截器校验的完整实现
工具类写完之后,登录接口就很简单了。用户提交用户名密码后,先走一遍自己的用户校验逻辑,校验通过就直接调用generateToken返回给前端:
java复制@PostMapping("/login")
public Result login(@RequestBody LoginDTO loginDTO) {
// 业务逻辑:校验用户名密码,验证码等
User user = userService.checkLogin(loginDTO);
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
return Result.ok(token);
}
然后是最核心的部分:拦截器校验。我会实现一个HandlerInterceptor,在请求到达Controller之前统一做鉴权:
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private 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 ")) {
writeUnauthorized(response);
return false;
}
String token = authHeader.substring(7);
try {
Claims claims = jwtUtil.parseToken(token);
request.setAttribute("userId", claims.getSubject());
request.setAttribute("username", claims.get("username"));
return true;
} catch (ExpiredJwtException e) {
writeUnauthorized(response);
return false;
} catch (JwtException e) {
writeUnauthorized(response);
return false;
}
}
private void writeUnauthorized(HttpServletResponse response) throws IOException {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或token已过期\"}");
}
}
注意一个细节:拦截器里我特意把从token解析出来的userId放到了request attribute里,Controller里直接用(String) request.getAttribute("userId")就能拿到当前登录用户。这样做的好处是整个链路只需要解析一次token,业务层不用再重复解析。
接下来是注册拦截器,同时配置放行规则:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/user/login",
"/user/logout",
"/captcha",
"/error"
);
}
}
2.3 验证码接口与Swagger文档:白名单配置的正确姿势
这节要单独拿出来说,因为"Spring Boot JWT放开Swagger"和"SPA项目里的验证码实现"这两个问题被问到的频率特别高。很多人第一次配拦截器时直接对所有路径生效,然后发现Swagger文档页面全挂了,或者登录页的验证码图片刷不出来,然后一头雾水。
先说Swagger。如果你用的是Spring Boot 2.x + Springfox 2.9.2,那么除了登录接口,Swagger的静态资源、UI页面、API文档分组等一堆路径也得放行。我实际项目中放行清单是这样的:
text复制/swagger-ui.html
/swagger-ui/**
/webjars/**
/v2/api-docs
/swagger-resources/**
这里有个容易漏掉的点:如果你的Controller里写了Swagger自定义注解,并且Swagger会去扫描这些Controller,那么就算静态资源放行了,swagger在加载时可能还是会请求到部分未放行的接口路径,进而导致文档里出现401。遇到这种情况,最简单的排查方式就是看Swagger页面的Network请求,哪个接口返回401就把哪个路径加到excludePathPatterns里。
再补充一个Swagger的校验放行细节。很多时候我们希望接口文档可以公开访问,但具体业务接口仍然受保护。那么除了在WebMvcConfig里放行Swagger资源路径外,还要保证拦截器的preHandle逻辑里,对Swagger相关路径不进行token校验。另一种做法是引入springfox-boot-starter后直接开启springfox.documentation.enabled=true,用文档分组路径做更细粒度的控制,但最省事的还是路径放行。
再说SPA项目里的验证码。验证码接口(一般是GET请求生成一张图片)显然不能要求带token,因为验证码就是为了防止机器人刷登录接口的,用户此刻还没登录呢。所以验证码接口必须放行。但放行之后要注意一个问题:验证码的正确性校验一般有两种方式——存在Session里,或者存在Redis里。如果项目仍然用到了Session存验证码,那么在SPA跨域场景下,前端请求验证码接口时默认不会携带Cookie,就会导致后端存了验证码但前端下次请求时取不到。解决办法要么是把验证码存到Redis,返回一个随机uuid作为验证码标识,前端提交登录时同时带上这个uuid和用户输入的验证码;要么在后端配置CORS时开启allowCredentials(true),同时前端请求设置withCredentials: true。我个人更推荐Redis方案,因为Session在无状态化改造中本来就是要被淘汰的。
3. JWT续签、刷新与退出登录的设计方案
3.1 如何合理设置过期时间,避免频繁登录
JWT一旦签发,在过期之前都是有效的,这是无状态认证带来的固有特性。所以过期时间设置成多少,直接决定用户体验和安全性的平衡。
我见过不少项目把过期时间设置成7天甚至30天,理由是"用户不希望天天登录"。这个做法风险很大:token在手机上被别人拿走,等于一个持续一个月的通行证,别人想干什么都行。反过来,过期时间太短,比如五分钟,用户体验又很差,操作到一半突然提示登录过期。
常规做法是区分场景。后台管理系统,内部员工使用,过期时间可以设短一些,比如2小时;面向C端用户的App,过期时间可以适当延长,比如24小时到7天。但注意,这只是trade-off,真正解决体验问题的方案是续签机制,也就是下面要讲的内容。
3.2 双Token续签方案:刷新令牌究竟怎么设计
单token模式下,过期时间到了用户就得重新登录。为了既保持安全性又保住体验,业内最成熟的方案是双token:Access Token(短时效)+ Refresh Token(长时效)。
Access Token负责真正的资源访问,过期时间短(比如30分钟),即便泄露了,攻击者能用的时间窗口也很有限。Refresh Token负责在Access Token过期后去换取新的Access Token,过期时间可以设置成7天甚至更长,但Refresh Token必须只允许走刷新接口,不能用来访问业务资源。
整个续签流程这样设计:
- 前端请求接口时发现返回401,说明Access Token过期。
- 前端不直接跳转登录页,而是调用
/auth/refresh接口,携带refreshToken。 - 服务端校验refreshToken的合法性,返回一个新的Access Token(必要时同时返回新的Refresh Token,实现滑动续期)。
- 前端拿到新token后,重放刚才失败的请求。
服务端实现比较简单,就是签发token时多签发一种类型:
java复制public String generateRefreshToken(Long userId) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("type", "refresh")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshExpire * 1000))
.signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256)
.compact();
}
刷新接口里需要校验claim里的type字段,确保拿的是refreshToken而不是accessToken来刷新:
java复制@PostMapping("/refresh")
public Result refresh(@RequestBody RefreshDTO dto) {
Claims claims = jwtUtil.parseToken(dto.getRefreshToken());
if (!"refresh".equals(claims.get("type"))) {
return Result.error("无效的刷新令牌");
}
String newAccessToken = jwtUtil.generateToken(
Long.valueOf(claims.getSubject()),
(String) claims.get("username")
);
return Result.ok(newAccessToken);
}
这里有个细节:Refresh Token不应该在本地或者数据库里保存状态,它的安全性完全靠签名和过期时间来保证。也就是纯无状态的刷新方案。这个方案还能支持"记住我"的功能:用户勾选了记住我,refreshToken有效期就给个14天;没勾选,有效期就给个1天甚至直接不给。
3.3 退出登录不等于一次性:黑名单机制实现思路
JWT一个最常被吐槽的点就是:服务端没法主动让一个token失效。用户点了退出登录,前端把本地token删了,但服务端收到的旧token在过期前依然是有效的。这在权限敏感的后台系统里是个硬伤。
要解决这个问题,最常见的办法是引入Redis黑名单。退出登录时,把当前token放入黑名单,设置过期时间为token本身的剩余有效时间。拦截器校验时先查黑名单:
java复制// 退出登录时
long remainTime = expiration.getTime() - System.currentTimeMillis();
redisTemplate.opsForValue().set("blacklist:" + token, "1", remainTime, TimeUnit.MILLISECONDS);
// 拦截器校验时
Object blacklisted = redisTemplate.opsForValue().get("blacklist:" + token);
if (blacklisted != null) {
// 在黑名单里,拒绝访问
writeUnauthorized(response);
return false;
}
注意这里有个效率关键点:黑名单的过期时间必须和token的剩余有效期保持一致,这样黑名单数据到了token过期时间后自动删除,Redis里不会堆积垃圾key,也不需要额外的定时清理任务。
另外一个被动方案是设置全局的token版本号,比如每个用户一个token_version,用户退出时这个版本号加1,签发token时把这个版本号写进claim里,校验时对比版本号。这个方案适合"踢人下线"或者"修改密码后让所有旧token失效"的场景,但它有一个代价:服务端还是得存一个版本号,严格意义上已经破坏了JWT的无状态性。项目里如果Redis本来就有,我更推荐用Redis黑名单,毕竟代码侵入小。
4. JWT安全与常见问题排查
4.1 签名算法与密钥管理:别让Token从"防伪"变"摆设"
安全这块必须单独开一节。JWT的安全性建立在签名算法和密钥保密的基础上,一旦这两点出了问题,整个认证体系就形同虚设。
先选算法。HS256是HMAC+SHA256,用的是对称密钥,签发和校验都是同一个secret,实现简单,适合大多数单体应用或者内部微服务。RS256是RSA非对称签名,私钥签发、公钥验签,适合多服务端场景,比如认证中心负责签发,其他业务服务只拿公钥验签,这样即使某个业务服务被攻破,也拿不到私钥去伪造其他服务的token。如果你的服务拆分得比较细,强烈建议直接上RS256,密钥管理上用JKS或者云厂商的KMS都行。
然后是密钥管理。很多项目把secret直接写在application.yml里,这是非常危险的习惯。代码仓库一旦泄露,secret就是明文摆在那里,攻击者可以直接用这个secret伪造任意用户的token。至少要做到下面几条:
- 密钥使用环境变量或者配置中心管理,不要提交进Git仓库。
- 生产环境的密钥与开发环境分离,定期轮换。
- 密钥强度要够,HS256至少32字节以上随机值。
下面是在Spring Boot里通过环境变量读取密钥的示例:
yaml复制jwt:
secret: ${JWT_SECRET:dev-only-secret}
expire: 7200
4.2 token为什么能被在线解析?伪造与破解的真实原理
前面提到过jwt.io这个在线工具,把token贴进去就能看到里边的明文内容。很多人看到这个就觉得"JWT不安全,随便一个人就能看到我的信息",这就是对它最大的误解。
jwt.io只是一个Base64URL解码器,任何人都能看到Header和Payload,这是设计如此,不是漏洞。真正决定token是否安全的是签名部分。如果没有密钥,你改掉Payload里的sub字段之后,签名对不上了,服务端一验就拒绝。所以"能看到"不等于"能篡改"。
那"jwt伪造"和"jwt破解"又是怎么回事?这里说几个真实存在的攻击路径。
第一个是alg=none攻击。JWT规范里有一种alg: "none"的选项,表示不签名。攻击者把Header里的alg改成none,去掉签名部分,服务端如果没做算法白名单校验,就可能接受一个没有签名的token。防护方式很简单:解析的时候强制校验算法必须是你预定义的算法,jjwt在解析时会自动校验,但前提是你用parserBuilder设置签名密钥时指定了算法。
第二个是弱密钥爆破。HS256是对称算法,如果密钥是"123456"、"secret"这种弱口令,攻击者可以把JWT的Header+Payload组合,遍历常见弱密钥字典,算一遍HMAC对比签名,撞库撞出密钥。严格来说这不算破解JWT,而是破解密钥,但实际效果就是你的token能被伪造。我在测试环境就遇到过有人搞了个弱密钥字典来扫,幸好当时用的密钥足够长,不然整个权限体系就被打穿了。
第三个是token在传输过程中被截获。这个跟JWT本身无关,任何认证方式都有这个问题。对策只有一个:全站HTTPS,没有别的捷径。
总结一下防护要点:
| 风险点 | 防护手段 |
|---|---|
| Payload泄露信息 | 不放敏感数据,只用必要字段 |
| alg降级攻击 | 固定算法白名单,显式指定签名算法 |
| 弱密钥爆破 | 使用高强度随机密钥,定期轮换 |
| token被截获 | 全站HTTPS,短过期时间 |
| token被重放 | 短过期时间+必要时校验请求时间戳 |
4.3 常见问题速查表
把实际开发中经常遇到的几个问题和排查思路整理成表格,方便直接对照。
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 登录成功后请求接口仍然401 | token没有正确塞进请求头 | 检查前端请求拦截器是否在header里带了Authorization: Bearer xxx |
| 同一个token时好时坏 | 多实例部署且使用不同密钥 | 检查各实例的jwt.secret配置是否一致 |
| 粘贴到jwt.io能看到明文 | 这是正常现象 | 确认没有放敏感数据即可 |
| Swagger页面打不开 | 拦截器拦截了静态资源和文档路径 | 在excludePathPatterns放行Swagger相关路径 |
| 验证码图片刷不出来 | 验证码接口被拦截器拦截 | 放行/captcha路径 |
| 修改密码后旧token仍有效 | JWT无状态特性导致 | 引入版本号或Redis黑名单机制 |
| 提示签名不匹配 | Header或Payload被篡改,或密钥不一致 | 检查密钥配置,确认签名算法一致 |
| 提示token过期 | 过期时间设置不合理 | 调整过期时间,或实现续签机制 |
4.4 一个容易忽略的细节:Base64URL编码与中文乱码
最后说一个坑,很多人写工具类的时候容易忽略。JWT里的Base64编码不是普通的Base64,而是Base64URL变体:+和/分别被替换成-和_,且末尾的=填充符会被去掉。如果手写解析代码,用标准Base64去解码JWT,碰到含有+或/的token就会解析出错。jjwt库内部已经处理了这个问题,所以工具类直接用库提供的API一般没事。但如果你自己写decode模块,一定要记得用Base64.getUrlDecoder()而不是Base64.getDecoder()。
另一个跟中文相关的问题是,如果Payload里的username是中文,用某些库解析时可能会出现乱码,尤其是手写签名计算时编码方式不一致。务必确保整个流程都是UTF-8编码,从请求到响应再到数据库,统一编码,不然排查起来极其浪费时间。
我在实际项目中踩过几次坑之后,渐渐形成了一个习惯:不管项目大小,权限这块我会先把token的生命周期、密钥管理方式、过期策略在文档里画清楚,再开始写代码。因为权限体系出问题往往不是单个函数写错,而是整个链路里的某个环节设计得不够严谨。JWT本身不复杂,复杂的是怎么在业务场景里把它的边界想清楚。希望这篇文章能给你省下一点摸索的时间。
