
前后端分离项目里接入 Spring Security最难的不是配置而是理解它的设计思路。我最早接手一个电商后台管理系统的登录认证模块时项目技术栈是 Spring Boot 3 Vue 3登录态选型定为 JWT。刚开始照着网上的教程写配置结果跑起来全是 403、跨域拦截、token 解析不到各种问题。后来把 Spring Security 的过滤器链、认证流程彻底理清楚之后才发现大部分坑都是在同一个点上还没有真正理解 Security 的“请求处理链条”就急着去写代码。这篇文章我会围绕“怎么把 Spring Security 正确应用到前后端分离项目”这个主题把我当时的选型思路、核心实现、权限设计、踩坑复盘完整讲一遍。适合正在做前后端分离项目、对 Spring Security 停留在“配过但没跑通”阶段的开发者参考也能帮准备从 Session 认证迁移到 JWT 的团队节省不少排查时间。1. 为什么前后端分离项目重写认证逻辑几乎是必然的Spring Security 默认的认证行为完全是围绕“服务端渲染页面”这个场景设计的。你要用前后端分离的思路去套它第一反应肯定是“各种别扭”。理解了这个底层差异后面所有配置改动才说得通。1.1 从 Session 认证说起为什么浏览器模型拯救不了移动端早期 Web 应用是典型的同源场景浏览器访问一个域名下的页面用户名密码提交给服务端服务端校验通过后把用户信息写到 Session再往浏览器种一个 JSESSIONID Cookie。后续请求浏览器自动携带 Cookie服务端根据 Session 里的登录态判断当前用户是谁。这套机制在单体服务端渲染时代没什么问题但前后端分离之后至少会遇到四个麻烦前端是 SPA 应用或原生 App它不关心“页面从哪渲染”只关心服务端 API 返回的 JSON。跨域场景下 Cookie 携带非常麻烦。前端服务、后端服务可能分属两个域名浏览器对跨域 Cookie 的限制越来越多。Session 存储在服务端多实例部署时需要引入 Redis 共享否则用户的请求落到另一台实例就判定未登录。移动端根本没有浏览器 Cookie 的概念你不可能要求 App 客户端维护一个 JSESSIONID。所以前后端分离项目里绝大多数团队都会选择“无状态 Token”方案把登录态从服务端 Session 挪到客户端自己保存的 token 里。1.2 Token 认证与 JWT 选型不是什么场景都该上 JWTToken 方案又分两种主流做法我当时做了一个对比维度JWT 无状态 Token随机 Token 存 Redis服务端存储不需要需要 Redis 或内存存储登录态校验验签 解析 Payload查 Redis强制下线/踢人难需黑名单直接删 Key 即可跨端适用性好适合 App、SPA好实现复杂度中低微服务共享天然支持网关验签需共享 Redis很多教程上来就推荐 JWT但实际项目中如果用户被踢下线的诉求很强JWT 的“无状态”反而成了劣势。我当时选 JWT 的原因很现实后台管理系统面向内部运营人员token 过期时间控制在 2 小时对“强制下线”的诉求不高而且后面可能要把登录态校验逻辑下沉到网关统一处理JWT 不需要每个下游服务都查一次 Redis。如果你做的是用户量大、需要精确控制登录态的系统我个人更建议随机 token 存 Redis别被 JWT 的“高大上”带偏。这个决定会影响后面一整条链路的设计。1.3 落到代码之前的三大改造范围Spring Security 默认的登录页、Session、CSRF 这些机制在前后端分离 JWT 场景下基本都要重写。你至少要改动四件事关闭默认的表单登录、HTTP Basic 登录方式。关闭 Session 管理设置SessionCreationPolicy.STATELESS。关闭 CSRF 防御。因为不使用 Cookie 保存登录态CSRF 攻击的前提就不成立了。自定义返回 401、403 的 JSON 结构而不是让 Security 返回默认的错误页。这四件事是“地基”地基打不好后面的 JWT 过滤器、权限注解都会挂。我见过不少项目在旧写法WebSecurityConfigurerAdapter的思路上打转实际上 Spring Security 5.7 之后官方已经废弃了这个基类新项目必须用组件化的SecurityFilterChainBean 写法。2. 读懂 Security 过滤器链95% 的配置问题都出在这里如果你只记住“怎么配”而不知道“为什么这样配”一旦遇到 Security 相关的问题就会非常被动。Spring Security 本质上是构建在 Servlet 过滤器之上的一套大型过滤器链框架理解了链就理解了整个认证授权流程。2.1 过滤器链是怎么串起来的可以把它想象成机场安检通道乘客HTTP 请求进入通道后要过一个又一个检查点过滤器。有的检查点负责确认你有没有登机牌未登录就让你去登录有的负责盖放行章授权检查有的专门处理免检 VIP 通道放行白名单。默认的过滤器链中和我们关系最大的几个过滤器大致是过滤器或组件职责和我们项目的关系SecurityContextPersistenceFilter请求开始时从上下文中加载认证信息结束时保存无状态场景下由它维持当前请求的安全上下文HeaderWriterFilter写安全响应头基本不用管CorsFilter处理跨域前后端分离项目必须配置CsrfFilterCSRF 防护无状态 JWT 下建议关闭UsernamePasswordAuthenticationFilter处理表单登录请求前后端分离下基本不用它FilterSecurityInterceptor访问控制决策核心授权逻辑靠它判断ExceptionTranslationFilter捕获认证/授权异常并转发给入口点负责把 401/403 转成 JSON配置http.formLogin()、http.authorizeHttpRequests()这些 API本质就是在往默认过滤器链里装配、挪动、删减过滤器。所以当你发现“登录页怎么自己跳出来了”“为什么 401 变成了 403”时不要先去怀疑逻辑代码先查链上发生了什么。2.2 加 JWT 过滤器位置错了全盘皆输我们做 JWT 认证时不会用默认的UsernamePasswordAuthenticationFilter而是在过滤器链的某个位置插入一个自定义JwtAuthenticationFilter。关键问题它到底该加在哪答案是在UsernamePasswordAuthenticationFilter之前。因为过滤器链是从前往后执行的。授权过滤器FilterSecurityInterceptor在链的后面它检查SecurityContext里有没有 Authentication 对象再决定能不能放行。如果我们的过滤器加在授权检查之后那授权过滤器已经判定“匿名用户”了后面你再去解析 token、塞 Authentication 都没意义。当时我做第一版的时候因为没搞清楚顺序直接把 JWT 过滤器加在了链的末尾结果每个接口都返回 403。排查了很久才发现是顺序问题——所以请你一定要记住自定义 JWT 过滤器在UsernamePasswordAuthenticationFilter前面。2.3 一个能直接跑的 Security 配置骨架以 Spring Boot 3 Spring Security 6 为例基础配置长这样Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .cors(cors - cors.configurationSource(corsConfigurationSource())) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .requestMatchers(/swagger-ui/**, /v3/api-docs/**).permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex - ex .authenticationEntryPoint(restAuthEntryPoint) .accessDeniedHandler(restAccessDeniedHandler) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }注意几个细节csrf关闭的原因前面说过前提是你的 token 不走 Cookie。如果前端非要把 token 存进 CookieCSRF 防护就不能关。.sessionManagement()设成STATELESS同时也就意味着服务端不会再创建 HttpSession。.cors()需要提供一个CorsConfigurationSourceBean后面讲跨域时会细说。.addFilterBefore(...)这个 API 就是“把自定义过滤器加到某个过滤器前面”这一行决定整个认证链的成败。3. 核心实现JWT 登录、Token 解析与无状态会话配置配置骨架搭好之后接下来就是把认证逻辑真正写出来。这个过程涉及四个关键类UserDetailsService、JWT 工具类、登录控制器、自定义认证过滤器。3.1 定制 UserDetailsService用户从哪来Spring Security 定义了一个统一接口UserDetailsService它只有一个方法根据用户名加载用户信息。Security 在做认证时会拿它查出来的用户信息去比对密码。Service public class AppUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public AppUserDetailsService(UserMapper userMapper) { this.userMapper userMapper; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserDO user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } ListString permissions userMapper.selectPermissionsByUserId(user.getId()); ListSimpleGrantedAuthority authorities permissions.stream() .map(SimpleGrantedAuthority::new) .toList(); return new User(user.getUsername(), user.getPassword(), authorities); } }这里有几个容易被忽略的点返回的UserDetails里的 password 是数据库中存储的加密后的密码不能是明文。权限集合不要只放角色名最好把“权限标识”也放进去。比如后台系统的菜单权限、按钮权限都能映射成一个个权限字符串。不要把业务字段塞进UserDetails里保持它的职责单一。业务信息可以通过后面的 JWT payload 或单独查询获取。3.2 JWT 工具类与认证过滤器解析 Token 的正确姿势JWT 工具类负责生成 token 和解析 token。我当时用的是 JJWT 库HS256 签名单服务内部够用。如果后面做微服务建议换成 RSARS256各服务用公钥验签。Component public class JwtUtil { private final SecretKey key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); public String generateToken(String username, Long userId, Collection? extends GrantedAuthority authorities) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(authorities, authorities.stream() .map(GrantedAuthority::getAuthority) .toList()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expireTime)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody(); } }接下来是认证过滤器。它做的事情很简单从请求头里拿出 token验签解析出用户身份然后构建一个 Authentication 放进SecurityContextHolder。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final AppUserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtUtil.parseToken(token); String username claims.getSubject(); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception ex) { // token 无效或过期这里不要抛异常让请求继续走 // 后面授权过滤器会判定未认证再由 AuthenticationEntryPoint 返回统一 JSON SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } }这里我踩过一个坑一开始在catch块里直接 throw RuntimeException结果所有带无效 token 的请求都返回 500。合理的处理方式是解析失败不要中断请求链把它当匿名请求处理最后统一走 401 逻辑。这样客户端看到的错误信息也是可控的统一 JSON。可能你会问为什么继承OncePerRequestFilter而不是普通Filter因为 Servlet 容器在 forward 和 include 场景下过滤器可能被调用多次。OncePerRequestFilter能保证同一个请求内过滤器只执行一次避免重复解析 token、重复设置上下文。3.3 登录接口用 AuthenticationManager 而不是自己写密码校验登录接口我们不直接在控制器里查数据库比对密码而是交给AuthenticationManager.authenticate()完成校验。Spring Security 会自己调用UserDetailsService拿到用户信息再用PasswordEncoder比对密码。RestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authenticationManager; private final JwtUtil jwtUtil; PostMapping(/login) public ResultLoginResponse login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword())); UserDetails userDetails (UserDetails) authentication.getPrincipal(); String accessToken jwtUtil.generateToken( userDetails.getUsername(), currentUserId, // 根据实际情况从 UserDetails 或缓存中取得 userDetails.getAuthorities()); return Result.ok(new LoginResponse(accessToken)); } }注意AuthenticationManager并不是自动暴露为 Bean 的。Spring Security 6 里需要自己声明Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }如果你不加这个 Bean直接Autowired进来会启动失败。登录成功后一般还需要把 Refresh Token 一并返回让前端在 access token 过期时静默刷新。这里篇幅有限不展开但提醒一句refresh token 不要放在 JWT payload 里存永久有效期建议单独生成、服务端保存或至少带一个很短的过期时间。3.4 无状态会话配置与 CORS 联动无状态配置我们已经在 SecurityConfig 里写了SessionCreationPolicy.STATELESS这一步会让 Spring Security 不再创建 HttpSession也会让SecurityContextHolder的策略变为每个请求独立。跨域配置要写一个CorsConfigurationSourceBeanBean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }如果你用allowCredentials(true)那么addAllowedOrigin(*)会出问题必须用addAllowedOriginPattern(*)。这是早期我踩过的一个相当隐蔽的错误浏览器直接报 CORS 错误。有一点再强调CORS 过滤器必须在 JWT 过滤器前面跑否则预检的 OPTIONS 请求带着 token 请求头也会被安全链路处理。Security 内部已经帮我们处理了 CorsFilter 的位置前提是你确实调用了.cors(...)并注入了配置源。4. 权限控制实战方法注解、动态权限与角色模型细节认证解决的是“你是谁”授权解决的是“你能干什么”。前后端分离项目里接口权限不放在页面控制必须放在服务端做硬校验。4.1 基于注解的接口级权限控制在配置类上加EnableMethodSecurity之后就可以在方法上使用PreAuthorizeRestController RequestMapping(/api/order) public class OrderController { GetMapping(/list) PreAuthorize(hasAnyAuthority(order:list, order:export)) public ResultPageResultOrderVO list(PageQuery pageQuery) { return Result.ok(orderService.page(pageQuery)); } DeleteMapping(/{id}) PreAuthorize(hasAuthority(order:delete)) public ResultVoid delete(PathVariable Long id) { orderService.delete(id); return Result.ok(); } }权限字符串我建议统一用“资源:操作”的格式比如order:list、order:delete。相比角色ROLE_ADMIN权限标识更加精细也方便后续做菜单/按钮的动态控制。为什么要用PreAuthorize而不是直接在SecurityFilterChain里用 URL 配置在前后端分离项目中接口路径和业务模块之间很可能不是一一对应关系方法注解粒度更细、更贴近业务也更容易在测试时看到具体权限需求。URL 级配置更适合做“精确到路径”的防火墙规则。4.2 登录用户身份怎么拿权限注解只能控制“能不能访问”业务代码里还需要拿到“当前是谁”。最直接的取法是Authentication authentication SecurityContextHolder.getContext().getAuthentication();更优雅一点的做法是用AuthenticationPrincipal注入UserDetailsGetMapping(/me) public ResultUserInfoVO me(AuthenticationPrincipal UserDetails userDetails) { String username userDetails.getUsername(); // 你还可以通过 username 或自定义参数再去查用户详情 return Result.ok(userService.getProfile(username)); }如果你需要拿到 userId 这类业务字段一个思路是写一个HandlerMethodArgumentResolver从 SecurityContext 里取出当前登录信息解析成自己的LoginUser对象注入控制器方法。这样做的好处是控制器不再依赖 Spring Security 的 API业务代码更干净。4.3 动态权限与数据权限的简单方案方法注解适合固定权限但在实际系统中经常有动态权限。比如运营后台里“某个用户拥有某些商品的管理权”这可能是动态配置的。我的做法是在登录时把用户权限完整加载进UserDetails.getAuthorities()。如果你的权限数据不多变更、用户量不大登录时加载完全够用也最简单。如果权限变更频繁建议每次请求只从 JWT 里解析 userId再通过 Redis 缓存查询该用户的最新权限用最新权限构建 Authentication。这个方案既保留了 JWT 的跨服务优势又解决了 JWT “权限变更不生效”的核心痛点。至于数据权限比如当前用户只能看自己部门的订单简单场景可以在 Service 层通过切面传入当前用户数据范围把部门和用户 ID 拼进查询条件。复杂场景建议引入专门的权限框架这里不展开。5. 常见问题复盘403、跨域、Filter 顺序等最后把我和身边同事遇到的高频问题完整复盘一遍。这些坑在网上零零散散都有但真正串起来讲清楚的不多。5.1 匿名请求返回 403 而不是 401Spring Security 默认对“匿名用户访问受保护资源”的处理是触发AccessDeniedException最终返回 403。但对前后端分离项目来说前端要区分“未登录”和“无权限”必须在 403 和 401 之间划清楚。解决办法是自定义AuthenticationEntryPoint和AccessDeniedHandlerComponent public class RestAuthEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); } } Component public class RestAccessDeniedHandler implements AccessDeniedHandler { Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\message\:\没有访问权限\}); } }然后在SecurityFilterChain里挂上这两个 Bean。常见问题排查表现象可能原因解决方案带错误 token 返回 500过滤器里抛异常了捕获异常后清空上下文不中断请求链未带 token 返回 403没有配 AuthenticationEntryPoint自定义返回 401 JSON带 token 返回 403JWT 过滤器位置不对确认addFilterBefore(...)在UsernamePasswordAuthenticationFilter前面前端登录正常刷新后又 401token 过期或未正确携带检查前端请求拦截器是否统一加 Authorization 头5.2 跨域预检请求被安全链路拦截前后端分离环境里非简单请求会先发一个 OPTIONS 预检。如果安全配置没有处理 OPTIONS 请求会被拦截并返回 403浏览器就把真实的 GET/POST 请求给拦截了。当时我的现象是登录接口在 Postman 里一切正常浏览器里请求直接 CORS 报错。排查下来发现是CorsFilter没有生效因为我只写了 SecurityConfig 的 CORS 配置却忘了提供CorsConfigurationSourceBean。后来补齐 Bean 后Security 会自动在链首插入 CorsFilterOPTIONS 请求在进入认证逻辑之前就被处理掉了。还有一种比较粗暴的做法是在authorizeHttpRequests里显式放行 OPTIONS 方法.requestMatchers(HttpMethod.OPTIONS, /**).permitAll()但我不建议这么做治标不治本正确的 CORS 配置才是正道。5.3 过滤器被调两次、Token 解析不到的问题有一次我排查一个问题日志里打印的过滤器执行日志出现了两次。原因是自定义过滤器直接实现了Filter接口而项目里某个转发逻辑触发了重复执行。改成继承OncePerRequestFilter之后问题消失。如果日志里 Token 解析不到优先看请求头到底带了什么。可以用浏览器开发者工具查看 Authorization 头的值常见问题是前端写了Bearer少了空格、大小写不对、或者中间层网关把 Header 名改了。5.4 修改用户角色后旧 token 依然有效这是用 JWT 之后最容易被吐槽的痛点。如果权限全部塞在 JWT 的 claim 里那用户的角色被改了已经发出去的旧 token 在过期之前依然“拥有”旧权限。我的折中方案是JWT 里只放 userId 和 username不放权限集合每次请求到过滤器时根据 userId 从 Redis 缓存加载最新权限列表再构建 Authentication 对象。这样角色变更后清一下 Redis 缓存用户下一次请求立刻拿到新权限。Redis 里没有缓存时再查数据库查完写回缓存。代价是每一次请求多一次 Redis 读但换来的是权限可控性大幅提升且不需要立刻把 token 吊销掉适合后台管理系统这类场景。如果你的系统要求更严格比如用户封号后必须立刻失效那应该在 Redis 维护一个“token 黑名单”或“用户可用状态”过滤器里每次校验时检查一下。这就是从“纯无状态 JWT”往“可控状态”上妥协实践中非常常见。结尾写到这里Spring Security 在前后端分离项目里的主线思路应该已经比较清晰了理解过滤器链的先后顺序、关掉不适用的默认机制、自定义 JWT 认证入口、把权限模型设计得可控可变更。这套组合我先后在订单管理、内容发布等几个后台系统里验证过稳定性和可维护性都还可以。最后分享一点我自己的体会学 Spring Security千万不要上来就抱着源码啃。先把SecurityFilterChain、AuthenticationManager、UserDetailsService、SecurityContextHolder这几个核心接口的协作关系吃透再去做项目会顺手得多。很多时候报错信息看不懂不是代码的问题是你还没有建立起“请求经过哪些过滤器”的心智模型。等你哪一天看到一个 401 能直接猜出是哪个环节出的问题那才算真正入门了。