Spring Boot 中基于 JWT 与 Spring Security 的前后端分离认证鉴权实战指南
前后端分离项目里,“登录能不能通、接口能不能控、用户身份稳不稳”基本决定了系统上线后会不会频繁报警。很多同学第一次接触 Spring Security 时,往往卡在两个地方:
- 默认表单登录那一套不适合前后端分离
- JWT、过滤器、权限控制几个点串不起来
这篇文章我会从一个能跑起来的最小实战出发,带你一步步搭建一个基于 Spring Boot + Spring Security + JWT 的认证鉴权方案。我们不追求“功能最全”,而是先把核心链路打通,再补上安全和性能上的细节。
背景与问题
在传统服务端渲染应用中,登录状态往往靠 Session 维护。浏览器拿到 JSESSIONID,后续请求自动带上 Cookie,服务端就能识别用户。
但到了前后端分离场景,问题就变了:
- 前端可能是 Vue/React,也可能是小程序、App
- 服务端更希望是无状态的
- 多服务部署时,Session 共享会增加复杂度
- 接口调用需要更清晰的身份凭证
这时,JWT 就很适合:
- 登录成功后,服务端签发 Token
- 前端存储 Token
- 后续请求通过
Authorization: Bearer xxx传递 - 服务端验签后恢复用户身份
但是只用 JWT 还不够,谁来接管认证流程、谁来做权限判断、谁来把用户信息放进上下文?这就是 Spring Security 的价值。
前置知识与环境准备
本文示例环境:
- JDK 17
- Spring Boot 3.x
- Spring Security 6.x
- Maven
- JWT 库:
jjwt
你最好已经知道这些概念:
- HTTP 请求头
- Spring Boot 基本开发方式
- RESTful 接口
- 用户、角色、权限的基本关系
整体方案设计
我们先明确一下登录链路:
- 用户提交用户名密码到
/auth/login - 服务端校验用户名密码
- 校验成功后生成 JWT
- 前端保存 JWT
- 后续访问业务接口时携带 JWT
- JWT 过滤器解析 Token,写入 Spring Security 上下文
- 控制器或权限注解读取当前登录用户信息并做鉴权
架构流程图
flowchart TD
A[前端登录请求 /auth/login] --> B[认证控制器]
B --> C[AuthenticationManager 校验用户名密码]
C --> D[UserDetailsService 查询用户]
D --> E[生成 JWT]
E --> F[返回 Token 给前端]
F --> G[前端携带 Authorization Bearer Token]
G --> H[JWT 过滤器解析 Token]
H --> I[写入 SecurityContext]
I --> J[访问受保护接口]
核心原理
要把这套方案真正用明白,建议先搞清楚 4 个核心角色。
1. JWT 是什么
JWT 本质上是一个经过签名的字符串,通常分为三段:
- Header
- Payload
- Signature
其中 Payload 可以放用户名、用户 ID、角色等信息,但要注意:
- JWT 不是加密,只是编码
- 不要把密码、身份证号这类敏感数据放进去
- JWT 一旦签发,在过期前通常默认有效
2. Spring Security 做了什么
Spring Security 的核心不是“登录页面”,而是这几件事:
- 拦截请求
- 认证当前请求是不是合法用户
- 判断当前用户是否有权限访问资源
- 把用户信息保存到
SecurityContext
前后端分离场景里,我们通常禁用默认 Session 和表单登录,改成自己处理登录接口 + 自定义 JWT 过滤器。
3. Authentication、UserDetails、SecurityContext 的关系
可以简单理解为:
UserDetails:用户对象,描述“你是谁”Authentication:认证结果,描述“你已经被认证了”SecurityContext:当前线程中的安全上下文,描述“这次请求对应的登录用户是谁”
4. 过滤器为什么重要
JWT 的使用不依赖 Session,所以每次请求都要重新校验 Token。这一步最适合放在过滤器中完成:
- 从请求头中取出 Token
- 解析并验证签名
- 校验是否过期
- 查询用户信息
- 构造
Authentication - 放入
SecurityContextHolder
认证时序图
sequenceDiagram
participant Client as 前端
participant Filter as JwtAuthFilter
participant Security as Spring Security
participant Service as UserDetailsService
participant API as Controller
Client->>Security: 请求 /api/user/profile + Bearer Token
Security->>Filter: 进入 JWT 过滤器
Filter->>Filter: 解析并校验 Token
Filter->>Service: 按用户名加载用户
Service-->>Filter: 返回 UserDetails
Filter->>Security: 设置 Authentication 到 SecurityContext
Security->>API: 放行请求
API-->>Client: 返回业务数据
项目依赖
先创建一个 Spring Boot 项目,加入以下依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
实战代码(可运行)
下面这套代码是一个最小可运行版本。为了突出认证流程,用户数据先写死在内存中。你后续完全可以替换成数据库查询。
第一步:配置 application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
这里的 secret 至少要足够长。我自己就踩过这个坑:太短会导致签名密钥强度不够,运行时报错。
第二步:启动类
package com.example.jwtsecurity;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtSecurityApplication {
public static void main(String[] args) {
SpringApplication.run(JwtSecurityApplication.class, args);
}
}
第三步:定义登录请求与响应对象
package com.example.jwtsecurity.dto;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
package com.example.jwtsecurity.dto;
public class LoginResponse {
private String token;
private String tokenType = "Bearer";
public LoginResponse(String token) {
this.token = token;
}
public String getToken() {
return token;
}
public String getTokenType() {
return tokenType;
}
}
第四步:JWT 工具类
package com.example.jwtsecurity.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
@Component
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
private SecretKey getSignKey() {
byte[] keyBytes = Decoders.BASE64.decode(java.util.Base64.getEncoder().encodeToString(secret.getBytes()));
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(UserDetails userDetails) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(getSignKey(), SignatureAlgorithm.HS256)
.compact();
}
public String extractUsername(String token) {
return getClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return getClaims(token).getExpiration().before(new Date());
}
private Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSignKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}
说明:为了简化示例,这里只放了用户名。生产环境中可以额外放用户 ID、角色版本号等必要信息,但不要贪多。
第五步:实现 UserDetailsService
package com.example.jwtsecurity.service;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if ("admin".equals(username)) {
return new User(
"admin",
"$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
);
}
if ("user".equals(username)) {
return new User(
"user",
"$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
}
throw new UsernameNotFoundException("用户不存在");
}
}
这里两个用户的密码都是:password
第六步:JWT 认证过滤器
package com.example.jwtsecurity.security;
import com.example.jwtsecurity.service.CustomUserDetailsService;
import com.example.jwtsecurity.util.JwtTokenUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenUtil jwtTokenUtil;
private final CustomUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil,
CustomUserDetailsService userDetailsService) {
this.jwtTokenUtil = jwtTokenUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username;
try {
username = jwtTokenUtil.extractUsername(token);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
第七步:Security 配置
这是最关键的一段。Spring Security 6 推荐直接配置 SecurityFilterChain。
package com.example.jwtsecurity.config;
import com.example.jwtsecurity.security.JwtAuthenticationFilter;
import com.example.jwtsecurity.service.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomUserDetailsService userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
CustomUserDetailsService userDetailsService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults())
.formLogin(form -> form.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里要特别注意:
SessionCreationPolicy.STATELESS:无状态/auth/**放行- JWT 过滤器要加到
UsernamePasswordAuthenticationFilter之前
第八步:登录接口
package com.example.jwtsecurity.controller;
import com.example.jwtsecurity.dto.LoginRequest;
import com.example.jwtsecurity.dto.LoginResponse;
import com.example.jwtsecurity.util.JwtTokenUtil;
import jakarta.validation.Valid;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenUtil jwtTokenUtil;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenUtil jwtTokenUtil) {
this.authenticationManager = authenticationManager;
this.jwtTokenUtil = jwtTokenUtil;
}
@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String token = jwtTokenUtil.generateToken(userDetails);
return new LoginResponse(token);
} catch (AuthenticationException e) {
throw new RuntimeException("用户名或密码错误");
}
}
}
第九步:受保护接口
package com.example.jwtsecurity.controller;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public String profile(Authentication authentication) {
return "当前登录用户:" + authentication.getName();
}
}
package com.example.jwtsecurity.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin")
public class AdminController {
@GetMapping("/dashboard")
public String dashboard() {
return "欢迎访问管理员面板";
}
}
第十步:统一异常返回(建议加上)
如果不处理,Spring Security 默认异常风格不一定适合前端。建议统一返回 JSON。
package com.example.jwtsecurity.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class SecurityExceptionConfig {
@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> result = new HashMap<>();
result.put("code", 401);
result.put("message", "未认证或Token无效");
response.getWriter().write(new ObjectMapper().writeValueAsString(result));
};
}
@Bean
public AccessDeniedHandler accessDeniedHandler() {
return (request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> result = new HashMap<>();
result.put("code", 403);
result.put("message", "无权限访问");
response.getWriter().write(new ObjectMapper().writeValueAsString(result));
};
}
}
然后在 SecurityConfig 中接入:
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint())
.accessDeniedHandler(accessDeniedHandler())
)
同时通过构造器注入这两个 Bean。
过滤器链与鉴权关系图
classDiagram
class JwtAuthenticationFilter {
+doFilterInternal()
}
class JwtTokenUtil {
+generateToken()
+extractUsername()
+isTokenValid()
}
class CustomUserDetailsService {
+loadUserByUsername()
}
class SecurityContextHolder
class AuthenticationManager
JwtAuthenticationFilter --> JwtTokenUtil
JwtAuthenticationFilter --> CustomUserDetailsService
JwtAuthenticationFilter --> SecurityContextHolder
AuthController --> AuthenticationManager
AuthController --> JwtTokenUtil
逐步验证清单
建议你不要一口气全写完再跑,而是按下面顺序验证。
1. 登录获取 Token
请求:
curl -X POST 'http://localhost:8080/auth/login' \
-H 'Content-Type: application/json' \
-d '{
"username": "user",
"password": "password"
}'
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9.xxx.yyy",
"tokenType": "Bearer"
}
2. 带 Token 访问普通用户接口
curl 'http://localhost:8080/api/user/profile' \
-H 'Authorization: Bearer 你的token'
返回:
当前登录用户:user
3. 用 user 访问管理员接口
curl 'http://localhost:8080/api/admin/dashboard' \
-H 'Authorization: Bearer 你的token'
预期:
- 返回 403
- 说明认证成功,但权限不足
4. 用 admin 登录再访问管理员接口
curl -X POST 'http://localhost:8080/auth/login' \
-H 'Content-Type: application/json' \
-d '{
"username": "admin",
"password": "password"
}'
然后访问 /api/admin/dashboard,应返回成功。
常见坑与排查
这一部分我建议你收藏。JWT + Spring Security 最大的问题往往不是“不会写”,而是“看起来写对了但就是不生效”。
坑 1:接口一直返回 403
现象:
- 登录接口能通
- 访问业务接口总是 403
排查点:
requestMatchers是否配置正确- 角色是否带
ROLE_前缀 hasRole("ADMIN")实际匹配的是ROLE_ADMIN- JWT 过滤器是否真正执行了
建议:
在过滤器里打印日志:
System.out.println("Authorization: " + authHeader);
System.out.println("username: " + username);
如果用户名都解析不到,优先检查请求头格式。
坑 2:Token 明明有值,但认证信息拿不到
现象:
控制器里 Authentication authentication 是空的,或者匿名用户。
排查点:
- JWT 过滤器是否放在正确位置
- 是否成功执行了:
SecurityContextHolder.getContext().setAuthentication(authenticationToken); - 是否在异常分支里直接 return 了
坑 3:密码一直校验失败
现象:
用户名正确,但总提示用户名或密码错误。
原因通常有两个:
- 数据库存的是明文,但配置的是
BCryptPasswordEncoder - 数据库存的是 BCrypt,但你登录时做了额外加密,导致不匹配
建议:
Spring Security 中常见做法是:
- 数据库存 BCrypt 哈希
- 登录时前端传明文密码
- 后端使用
PasswordEncoder.matches()校验
不要手动再做一次 MD5 后传入,不然很容易错位。
坑 4:Token 过期后前端表现混乱
现象:
前端页面看起来还在登录状态,但接口突然 401。
建议:
前端要明确区分:
401:未登录或登录过期,跳转登录页403:已登录但无权限,展示无权限提示
这两个状态码千万别混着用。
坑 5:跨域后 Authorization 请求头丢失
如果前端和后端不在同域,浏览器会有 CORS 限制。你需要明确允许 Authorization 请求头。
package com.example.jwtsecurity.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(false);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
安全最佳实践
JWT 方案看起来简单,但真正上线时,安全细节比“能不能跑”更重要。
1. Access Token 过期时间不要太长
建议:
- 管理后台:30 分钟 ~ 2 小时
- 普通用户系统:按业务需求控制
- 高敏感系统:更短,并配合刷新机制
JWT 最大的问题之一是:签发后难以主动失效。过期时间越长,风险窗口越大。
2. 敏感信息不要放进 JWT
JWT Payload 只是 Base64 编码,不是加密。任何拿到 Token 的人都能解码看到内容。
不要放:
- 密码
- 手机号
- 身份证号
- 银行卡号
- 过多业务状态
3. 推荐使用短 Token + Refresh Token 机制
如果你项目规模稍大,我更建议:
- Access Token:短有效期
- Refresh Token:更长有效期
- 刷新时重新签发新的 Access Token
这样能兼顾安全和用户体验。
4. 服务端要考虑注销和强制下线
纯 JWT 最大的痛点是“服务端无状态”,但这也意味着:
- 用户点了退出,旧 Token 在过期前理论上还可用
- 管理员强制下线某用户也不容易立刻生效
常见做法:
- 建立 Token 黑名单
- Redis 记录失效 Token
- Token 中带版本号,服务端校验用户当前版本
如果系统涉及金融、管理后台、数据敏感操作,我不建议只靠“自然过期”。
5. 全站必须 HTTPS
JWT 一旦泄露,本质上就像“临时通行证”丢了。HTTPS 不是可选项,而是基础项。
6. 登录接口要做限流与风控
JWT 只是认证方式,不会帮你防暴力破解。建议至少加上:
- 登录失败次数限制
- IP 限流
- 验证码
- 异常登录告警
性能最佳实践
很多人觉得 JWT 一定比 Session 快,其实不绝对。JWT 的优势主要在于无状态和跨服务传递方便,不是“天然高性能”。
1. Token 不要塞太多内容
Token 越大:
- 请求头越大
- 网络开销越高
- 每次解析成本越高
一般保留:
- 用户唯一标识
- 用户名
- 少量必要声明
就够了。
2. 不要每次都查数据库太重
当前示例里,过滤器会按用户名加载用户。如果生产环境里每次都查复杂表关联,性能会受影响。
优化思路:
- 用户基础信息放缓存
- 权限信息做本地/分布式缓存
- 只查必要字段
但也要注意缓存一致性,尤其是角色权限变更。
3. 统一异常输出,减少前端重试误判
如果 401、403、500 混乱,前端可能做错误重试,反而放大系统压力。接口错误语义明确,其实也是一种性能优化。
4. 网关层可以前置校验
微服务场景中,可以在 API Gateway 先做一层 JWT 校验:
- 无效 Token 直接拦截
- 下游服务减少重复工作
不过敏感权限判断最好仍保留在服务侧,避免单点过度耦合。
这个方案的边界条件
这套方案适合:
- 中小型前后端分离项目
- 管理后台
- REST API 服务
- 需要无状态认证的场景
这套方案不一定直接适合:
- 需要实时强制下线的高安全系统
- 权限变化极其频繁的系统
- 极端高并发且 Token 策略复杂的网关体系
如果你有这些需求,建议进一步引入:
- Refresh Token
- Redis 黑名单
- OAuth2 / OIDC
- API Gateway 统一认证
总结
我们这篇文章做了几件关键的事:
- 解释了前后端分离下为什么常用 JWT
- 用 Spring Security 接管认证与权限控制
- 通过自定义过滤器完成每次请求的 Token 校验
- 给出了一套可运行的最小示例
- 总结了常见坑、安全与性能优化方向
如果你现在就要落地,我给你一个最实用的建议顺序:
- 先把本文最小版本跑通
- 再把用户查询替换为数据库
- 接着补统一异常处理和 CORS
- 然后增加 Refresh Token
- 最后根据安全要求考虑黑名单、强制下线、限流
一句话概括:JWT 负责“带身份”,Spring Security 负责“认身份、控权限”。这两者结合起来,才是前后端分离项目里比较稳妥的认证鉴权方案。
如果你是第一次完整走这条链路,建议真的跟着代码打一遍。很多概念在文档里看懂了,只有自己把过滤器、上下文、权限规则串起来之后,才算真正掌握。