Spring Boot 中基于 JWT 与 Spring Security 的前后端分离认证鉴权实战
前后端分离项目里,登录认证几乎是绕不开的一环。很多同学第一次做接口安全时,常见路径是:
- 一开始用 Session,前后端跨域后发现状态不好维护
- 然后改成 JWT,能跑起来,但很快遇到:
401 Unauthorized- token 过期后用户体验差
- 角色权限判断总是失效
- Spring Security 过滤器链顺序不对
这篇文章我会带你从一个可运行的 Spring Boot 示例出发,完整实现:
- 登录获取 JWT
- 请求携带 JWT 访问受保护接口
- 基于角色的鉴权
- 统一异常处理
- 常见问题排查
- 安全与性能优化建议
我会尽量用“做项目时真正会遇到的问题”来组织内容,而不是只贴一堆概念。
背景与问题
传统的 Session 认证,在服务端保存用户会话状态。它在单体项目里很顺手,但到了前后端分离、微服务或者多实例部署场景,问题会慢慢暴露:
- 跨域与 Cookie 管理复杂
- 服务端有状态,不利于水平扩展
- 移动端、小程序、多终端接入不够统一
- 网关转发、多服务认证传递麻烦
JWT(JSON Web Token)的思路是:把认证信息放进一个签名后的 token 中,由客户端持有,每次请求携带,服务端校验其合法性即可。
但是,JWT 不是“用了就安全”。如果只是“登录返回 token,接口随便 decode 一下”,那实际项目里很容易埋坑。真正的关键是:
- 如何与 Spring Security 正确整合
- 如何把 token 中的信息转成 Spring Security 的认证上下文
- 如何做好鉴权、异常、过期处理
- 如何避免常见的安全误用
前置知识与环境准备
你需要具备
- Java 基础
- Spring Boot 基本使用
- Maven 依赖管理
- RESTful API 概念
环境版本
本文示例使用:
- JDK 17
- Spring Boot 3.x
- Spring Security 6.x
- Maven 3.8+
如果你还在用 Spring Boot 2.x,核心思路一致,但
WebSecurityConfigurerAdapter等写法已经过时,本文采用的是新式配置。
核心原理
先别急着上代码,我们先把链路理顺。
1. 登录认证流程
用户提交用户名密码后:
- 后端调用
AuthenticationManager校验账号密码 - 校验通过后生成 JWT
- JWT 返回给前端
- 前端保存 token(常见做法是内存或安全存储)
- 后续请求通过
Authorization: Bearer <token>携带
2. 请求鉴权流程
访问受保护接口时:
- JWT 过滤器拦截请求
- 从请求头中提取 token
- 校验签名、过期时间、载荷
- 解析出用户名、角色
- 构造
Authentication放入SecurityContext - Spring Security 根据接口配置判断是否有权限访问
整体流程图
flowchart TD
A[用户登录] --> B[提交用户名密码到 /api/auth/login]
B --> C[AuthenticationManager 校验]
C -->|成功| D[生成 JWT]
C -->|失败| E[返回 401]
D --> F[前端保存 JWT]
F --> G[访问受保护接口]
G --> H[JWT 过滤器解析 Authorization 头]
H -->|合法| I[写入 SecurityContext]
H -->|非法/过期| J[返回 401]
I --> K[Spring Security 做权限判断]
K -->|有权限| L[返回业务数据]
K -->|无权限| M[返回 403]
Spring Security 与 JWT 的关系
很多人会误解:用了 JWT 就不需要 Spring Security 了。
其实不是。我的经验是:
- JWT:负责“你是谁”的凭证传递
- Spring Security:负责“你能做什么”的认证鉴权框架
更准确地说:
- JWT 解决的是无状态认证载体
- Spring Security 提供的是过滤器链、认证模型、权限模型、上下文管理
二者搭配,才是完整方案。
认证对象关系图
classDiagram
class JwtTokenProvider {
+generateToken(username, roles)
+validateToken(token)
+getUsername(token)
+getRoles(token)
}
class JwtAuthenticationFilter {
+doFilterInternal(req, resp, chain)
}
class CustomUserDetailsService {
+loadUserByUsername(username)
}
class SecurityConfig {
+securityFilterChain(http)
+authenticationManager(config)
+passwordEncoder()
}
class AuthenticationManager
class SecurityContextHolder
JwtAuthenticationFilter --> JwtTokenProvider
JwtAuthenticationFilter --> CustomUserDetailsService
SecurityConfig --> JwtAuthenticationFilter
SecurityConfig --> AuthenticationManager
JwtAuthenticationFilter --> SecurityContextHolder
实战代码(可运行)
下面我们直接搭一个最小可用项目。
1. Maven 依赖
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>jwt-security-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jwt -->
<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>
<!-- lombok,可选 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- test,可选 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
2. 配置文件
src/main/resources/application.yml
server:
port: 8080
jwt:
secret: 01234567890123456789012345678901
expiration: 3600000
说明:
secret至少要足够长,HS256 建议使用强密钥expiration这里配置为 1 小时,单位毫秒
3. 启动类
package com.example.jwtsecuritydemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtSecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JwtSecurityDemoApplication.class, args);
}
}
4. 定义请求响应对象
LoginRequest
package com.example.jwtsecuritydemo.dto;
public class LoginRequest {
private String username;
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;
}
}
LoginResponse
package com.example.jwtsecuritydemo.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;
}
}
5. JWT 工具类
package com.example.jwtsecuritydemo.security;
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.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.List;
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long expiration;
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes());
this.expiration = expiration;
}
public String generateToken(String username, List<String> roles) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(username)
.claim("roles", roles)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public String getUsername(String token) {
return getClaims(token).getSubject();
}
@SuppressWarnings("unchecked")
public List<String> getRoles(String token) {
return getClaims(token).get("roles", List.class);
}
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception ex) {
return false;
}
}
private Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
这里为了演示清晰,
validateToken统一返回布尔值。生产环境里建议区分过期、签名错误、格式非法等异常类型,便于定位。
6. 自定义用户服务
这里先用内存用户模拟数据库,方便快速跑通。
package com.example.jwtsecuritydemo.security;
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.stereotype.Service;
import java.util.List;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) {
if ("admin".equals(username)) {
return new User(
"admin",
"$2a$10$DowJonesIndexyU3w4YQx2z8E2KhS0bD0q1m4gY5J1z8w2f9V1x7Q7Yy",
List.of(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority("ROLE_USER")
)
);
}
if ("user".equals(username)) {
return new User(
"user",
"$2a$10$DowJonesIndexyU3w4YQx2z8E2KhS0bD0q1m4gY5J1z8w2f9V1x7Q7Yy",
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
}
throw new org.springframework.security.core.userdetails.UsernameNotFoundException("用户不存在");
}
}
上面这段里,密码是 BCrypt 加密后的字符串。为了让代码真正可运行,我们还需要明确明文密码。这里约定:
admin / 123456user / 123456
如果你担心密文不匹配,最稳妥的方式是启动时动态生成,下面我给一个更可靠的版本。
更稳妥的写法:配置内存用户
package com.example.jwtsecuritydemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class UserConfig {
@Bean
public InMemoryUserDetailsManager inMemoryUserDetailsManager(PasswordEncoder passwordEncoder) {
return new InMemoryUserDetailsManager(
User.withUsername("admin")
.password(passwordEncoder.encode("123456"))
.roles("ADMIN", "USER")
.build(),
User.withUsername("user")
.password(passwordEncoder.encode("123456"))
.roles("USER")
.build()
);
}
}
如果你采用这个方案,可以删除上面的 CustomUserDetailsService 类,后面注入 UserDetailsService 即可。为了保证示例可运行,本文后续按 InMemoryUserDetailsManager 方案继续。
7. JWT 认证过滤器
package com.example.jwtsecuritydemo.security;
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.core.userdetails.UserDetailsService;
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 JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
UserDetailsService userDetailsService) {
this.jwtTokenProvider = jwtTokenProvider;
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);
if (jwtTokenProvider.validateToken(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
String username = jwtTokenProvider.getUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
8. 自定义未认证与无权限处理器
AuthenticationEntryPoint
package com.example.jwtsecuritydemo.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
new ObjectMapper().writeValue(response.getWriter(), Map.of(
"code", 401,
"message", "未认证或 token 无效"
));
}
}
AccessDeniedHandler
package com.example.jwtsecuritydemo.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
new ObjectMapper().writeValue(response.getWriter(), Map.of(
"code", 403,
"message", "权限不足"
));
}
}
9. Security 配置
package com.example.jwtsecuritydemo.config;
import com.example.jwtsecuritydemo.security.JwtAccessDeniedHandler;
import com.example.jwtsecuritydemo.security.JwtAuthenticationEntryPoint;
import com.example.jwtsecuritydemo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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 JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtAccessDeniedHandler jwtAccessDeniedHandler) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里有两个关键点:
SessionCreationPolicy.STATELESS:明确告诉 Spring Security 不要用 Session 保存认证状态addFilterBefore(...):把 JWT 过滤器放在用户名密码认证过滤器之前
10. 登录接口
package com.example.jwtsecuritydemo.controller;
import com.example.jwtsecuritydemo.dto.LoginRequest;
import com.example.jwtsecuritydemo.dto.LoginResponse;
import com.example.jwtsecuritydemo.security.JwtTokenProvider;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
List<String> roles = authentication.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
String token = jwtTokenProvider.generateToken(authentication.getName(), roles);
return new LoginResponse(token);
}
}
11. 测试接口
package com.example.jwtsecuritydemo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class TestController {
@GetMapping("/api/user/profile")
public Map<String, Object> profile(Authentication authentication) {
return Map.of(
"message", "用户信息获取成功",
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/admin/dashboard")
public Map<String, Object> admin() {
return Map.of(
"message", "管理员面板访问成功"
);
}
@GetMapping("/api/hello")
public Map<String, Object> hello() {
return Map.of("message", "hello jwt security");
}
}
12. 可选:统一全局异常处理
package com.example.jwtsecuritydemo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BadCredentialsException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Map<String, Object> handleBadCredentials(BadCredentialsException ex) {
return Map.of(
"code", 401,
"message", "用户名或密码错误"
);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, Object> handleException(Exception ex) {
return Map.of(
"code", 500,
"message", ex.getMessage()
);
}
}
请求时序图
sequenceDiagram
participant Client as 前端
participant Auth as AuthController
participant AM as AuthenticationManager
participant JWT as JwtTokenProvider
participant Filter as JwtAuthenticationFilter
participant API as Protected API
Client->>Auth: POST /api/auth/login 用户名密码
Auth->>AM: authenticate()
AM-->>Auth: 认证成功
Auth->>JWT: generateToken()
JWT-->>Auth: JWT
Auth-->>Client: 返回 token
Client->>Filter: GET /api/user/profile + Bearer token
Filter->>JWT: validateToken()
JWT-->>Filter: 合法
Filter->>Filter: 写入 SecurityContext
Filter->>API: 放行请求
API-->>Client: 返回受保护数据
逐步验证清单
项目启动后,可以按下面顺序验证。
1. 登录获取 token
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"123456"}'
返回类似:
{
"token": "eyJhbGciOiJIUzI1NiJ9....",
"tokenType": "Bearer"
}
2. 访问普通用户接口
curl http://localhost:8080/api/user/profile \
-H "Authorization: Bearer 你的token"
3. 用 user 访问管理员接口
curl http://localhost:8080/api/admin/dashboard \
-H "Authorization: Bearer 你的token"
预期返回 403。
4. 用 admin 登录再访问管理员接口
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
再拿返回的 token 请求:
curl http://localhost:8080/api/admin/dashboard \
-H "Authorization: Bearer 管理员token"
预期返回成功。
常见坑与排查
这部分我建议你认真看。真正做项目,时间往往都花在这里。
1. 明明带了 token,还是 401
先检查这几项:
- 请求头是否叫
Authorization - 值是否以
Bearer开头,注意有空格 - token 是否真的没过期
- JWT 过滤器是否成功加入过滤器链
- 放行路径有没有写错,比如
/api/auth/login和/auth/login不一致
建议临时在过滤器里打日志:
System.out.println("Authorization: " + authHeader);
如果日志里拿不到请求头,优先怀疑前端没传上来,或者网关/代理层把头吞了。
2. 返回 403,不是 401
这个问题非常典型。
- 401:说明你还没通过认证,或者 token 无效
- 403:说明你已经认证了,但权限不够
如果是 403,重点检查:
- token 里的角色有没有写进去
GrantedAuthority是否带了ROLE_前缀- 代码里是
hasRole("ADMIN")还是hasAuthority("ROLE_ADMIN")
我当时刚接 Spring Security 时就被这个前缀坑过很多次。经验规则很简单:
- 用
hasRole("ADMIN"),底层会匹配ROLE_ADMIN - 用
hasAuthority("ROLE_ADMIN"),那你就自己写完整
3. 过滤器执行了,但 SecurityContext 没生效
一般排查方向:
- 是否在同一个请求线程内
- 是否已经有别的认证对象覆盖了上下文
UsernamePasswordAuthenticationToken的第三个参数(authorities)是否为空- 是否忘了
SecurityContextHolder.getContext().setAuthentication(authentication)
4. 登录总是用户名密码错误
排查顺序建议这样走:
- 用户是否存在
PasswordEncoder是否一致- 数据库存的密码是否已经加密
- 是否错误地拿明文去比对密文
比如下面这种做法就是错的:
if (rawPassword.equals(encodedPassword)) {
// 错误示例
}
正确方式应该交给 PasswordEncoder:
passwordEncoder.matches(rawPassword, encodedPassword);
不过如果你用的是 AuthenticationManager,它会帮你完成这一步。
5. 跨域导致前端请求失败
前后端分离项目中,浏览器会先发 OPTIONS 预检请求。若 CORS 没配好,经常会误以为是认证失败。
可以加一个 CORS 配置:
package com.example.jwtsecuritydemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.*;
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);
}
}
如果你要带 Cookie,就不能把
allowCredentials和*随便混用,需要精确配置允许域名。
安全最佳实践
到这里,基本功能已经跑通了。但“能跑”和“适合生产”是两回事。
1. 不要把敏感信息直接放进 JWT
JWT 的 payload 只是 Base64 编码,不是加密。不要放:
- 用户密码
- 手机号全量
- 身份证号
- 银行卡号
- 过多业务敏感字段
适合放的是:
- 用户 ID
- 用户名
- 角色
- token 签发时间
- 过期时间
2. 密钥要足够强,且不要硬编码
错误做法:
String secret = "123456";
更好的做法:
- 从环境变量读取
- 使用配置中心
- 使用 KMS / Vault 管理密钥
- 定期轮换密钥
如果密钥泄露,攻击者就可以伪造合法 token。
3. Access Token 要短效,必要时配 Refresh Token
JWT 最大的一个现实问题是:签发后难以主动失效。
所以实践上通常会这样设计:
Access Token:有效期 15~60 分钟Refresh Token:有效期更长,单独存储与校验- 登出或风控时,服务端拉黑 refresh token 或 token jti
如果你的业务对“强制下线”要求很高,仅靠纯 JWT 无状态方案并不够,需要配合:
- Redis 黑名单
- Token 版本号
- Refresh Token 轮换机制
4. 生产环境必须走 HTTPS
如果不用 HTTPS,token 在传输过程中被抓包,别人拿到后就能直接冒充用户访问接口。
这一点不是 Spring Security 能补救的,属于传输层基础要求。
5. 不要把 token 长期存在不安全位置
前端常见存储方式各有取舍:
localStorage:使用方便,但受 XSS 风险影响较大sessionStorage:生命周期更短- 内存存储:更安全一些,但刷新页面会丢
- HttpOnly Cookie:可防止 JS 直接读取,但需要处理 CSRF
没有绝对银弹,要结合你的前端架构和安全要求决定。
6. 做好接口级权限控制
不要只在前端按钮上做权限控制。前端隐藏按钮只能算“体验控制”,真正的安全边界必须在服务端。
建议同时使用:
- 路径级权限:
requestMatchers - 方法级权限:
@PreAuthorize
路径级适合做粗粒度控制,方法级适合做细粒度控制。
性能最佳实践
JWT 方案常被认为“天然高性能”,其实也要看你怎么用。
1. 不要每次请求都查全量用户信息
如果过滤器里每次都:
- 解析 token
- 再查数据库
- 再组装权限
那高并发下数据库压力会很明显。
常见优化方式:
- token 中携带基础角色信息
- 用户权限变更不频繁时,可做本地缓存/Redis 缓存
- 对高敏感接口,再做实时校验
也就是说,不同接口可以分层处理,不必“一刀切”。
2. 过滤器里逻辑要轻量
过滤器是每个请求都要走的地方,不要在里面写太重的逻辑,比如:
- 复杂数据库联表
- 大量远程 RPC
- 频繁对象创建
- 过多日志输出
JWT 过滤器的职责应该尽量单一:提取、校验、写上下文、放行。
3. 合理设置 token 过期时间
过期时间太短:
- 用户频繁重新登录
- 前端体验差
过期时间太长:
- 泄露后风险窗口增大
经验上可以这样取:
- 管理后台:30 分钟到 2 小时
- 普通业务系统:1 小时左右
- 高安全系统:更短,并配合 refresh token 与二次校验
生产落地时的边界条件
这里给几个很实用的判断建议。
适合 JWT + Spring Security 的场景
- 前后端分离项目
- 移动端 / 小程序 / Web 多端统一认证
- 网关转发、服务间传递身份信息
- 希望服务尽量无状态,便于扩展
不适合“纯无状态 JWT”的场景
- 需要强制实时踢人下线
- 权限变更必须立刻生效
- 安全合规要求极高
- 设备管理、会话管理非常严格
这种情况下,建议使用:
- JWT + Redis 黑名单
- Session + Redis
- OAuth2 / OIDC 统一认证平台
别为了“技术看起来先进”而硬上 JWT。选型一定要看业务边界。
一个更完整的改进方向
如果你准备把本文示例继续扩展到生产项目,我建议按下面路径迭代:
- 用户从内存改为数据库
- 增加注册、登出、修改密码功能
- 引入 Refresh Token
- 支持 token 黑名单
- 增加审计日志与登录风控
- 引入网关统一鉴权
- 最终演进到 OAuth2 授权体系
这样做比一开始就把系统设计得特别重更现实。
总结
这篇文章我们完整做了一套基于 Spring Boot + JWT + Spring Security 的前后端分离认证鉴权方案,重点包括:
- 为什么前后端分离更适合无状态认证
- JWT 与 Spring Security 各自负责什么
- 如何通过过滤器把 token 转成认证上下文
- 如何实现登录、鉴权、角色控制
- 如何区分和排查 401 / 403
- 如何在安全和性能之间做合理取舍
如果你现在就要落地,我给你的可执行建议是:
- 先跑通最小闭环:登录、带 token 访问、角色鉴权
- 再补生产能力:异常处理、CORS、日志、过期策略
- 最后考虑复杂诉求:登出、强制下线、refresh token、黑名单
一句话总结:
JWT 不是目的,安全、清晰、可维护的认证鉴权链路才是目的。
如果你的项目只是一个普通后台管理系统,本文这套方案已经足够作为稳妥起点;如果你的系统对会话控制要求很高,那就别停留在“纯 JWT”层面,尽早引入 refresh token 与服务端状态管理。