
1. 为什么REST API必须自己“上锁”而不是依赖容器或网关的默认防护在SpringBoot项目里写完一个RestController加几个GetMapping本地curl一下返回JSON很多人就以为“API上线了”。但真实生产环境里这接口就像把家门钥匙挂在门把手上——谁路过都能拧开。我去年接手一个金融类后台系统前任留下的用户信息查询接口连基础认证都没有靠文档里一句“请勿外传”来防爬虫结果被第三方数据聚合平台批量调用三个月直到数据库慢查询告警才暴露。这不是个例而是很多快速迭代团队的真实缩影。JWTJSON Web Token之所以成为SpringBoot REST API安全机制的主流选择根本原因在于它解决了三个刚性矛盾无状态性 vs 权限校验、分布式部署 vs 会话同步、前端直连后端 vs 网关层权限下沉。传统Session需要服务端存储会话状态一扩容就得搞Redis共享Session而JWT把用户身份、角色、过期时间等关键信息加密打包进token本身服务端只需验证签名和有效期完全无状态。更关键的是它天然适配前后端分离架构——前端登录后拿到token后续所有请求在Header里带上Authorization: Bearer token后端每个微服务都能独立完成鉴权不用反复调用统一认证中心。这个标题里的“应用示例”四个字特别重要。它不是讲JWT原理的学术论文而是要解决“我今天下午三点前必须让订单查询接口支持角色权限控制”这种具体问题。所以本文不展开RFC 7519标准细节也不对比JWT和OAuth2.0的哲学差异只聚焦在SpringBoot工程里从零配置到线上稳定运行的完整链路token怎么生成、怎么校验、怎么刷新、怎么防止盗用、怎么和Spring Security深度集成、怎么应对常见攻击面。所有代码都基于Spring Boot 3.2 Spring Security 6.2用的是spring-boot-starter-security官方推荐的新式配置方式不是老版本的WebSecurityConfigurerAdapter继承写法——后者在新版本里已被彻底移除但网上大量教程还在教踩坑成本极高。你不需要是Spring Security专家但得知道PreAuthorize(hasRole(ADMIN))这类注解背后发生了什么。本文会把过滤器链里每个环节拆开揉碎UsernamePasswordAuthenticationFilter怎么触发登录、JwtAuthenticationFilter怎么拦截请求、JwtAuthenticationProvider怎么验证token、SecurityContextPersistenceFilter怎么把认证结果存入上下文。这些不是黑盒而是你每天调试时能看到堆栈的实实在在的类。如果你正被“登录成功但后续接口403”、“token过期后前端没跳转登录页”、“管理员能访问普通用户数据”这类问题困扰这篇就是为你写的。2. JWT核心组件的选型逻辑与安全参数实操配置JWT本身只是个规范落地时每个环节都有多种实现方案。SpringBoot生态里最主流的是jjwt-apijjwt-impl组合但它的0.11.x和0.12.x版本在API设计上有重大断裂——前者用Jwts.builder()静态方法链式构建后者强制要求通过Jwts.builder().setPayload()注入对象且密钥管理方式完全不同。我试过直接升级依赖结果所有token生成逻辑全报NoSuchMethodError因为io.jsonwebtoken:jjwt-api:0.12.5的SecretKey构造方式变了。最终选定io.jsonwebtoken:jjwt-api:0.12.5io.jsonwebtoken:jjwt-impl:0.12.5io.jsonwebtoken:jjwt-jackson:0.12.5这个组合原因很实际它原生支持Jackson序列化避免手动处理Date类型字段jjwt-jackson模块能自动处理java.time.Instant而老版本对Java 8时间API支持极差。密钥长度是第一个必须死磕的参数。JWT规范要求HMAC-SHA256算法的密钥至少256位32字节但很多教程随手写mySecretKey——这字符串UTF-8编码后只有11字节远低于安全阈值。我用SecureRandom生成过一次32字节密钥Base64编码后是ZkFqRmJQdVhTcFpXeUxvQnNjRm1KbE5vUWxTcFpXeUxvQg把它配置在application.yml里jwt: secret-key: ZkFqRmJQdVhTcFpXeUxvQnNjRm1KbE5vUWxTcFpXeUxvQg expiration: 3600000 # 1小时毫秒值 refresh-expiration: 604800000 # 7天毫秒值这里有个反直觉的点expiration设为1小时不是为了“提高安全性”而是为了降低token泄露后的危害窗口。如果设成24小时攻击者拿到token就能逍遥一整天设成1小时他必须持续获取新token增加了被日志监控捕获的概率。而refresh-expiration设为7天是给前端留出刷新缓冲期——用户关闭浏览器再打开只要7天内重新登录就不需要输密码。Token载荷payload字段设计直接影响权限控制粒度。除了必填的subsubject通常是用户ID、expexpiration time、iatissued at我坚持加入三个自定义字段roles: 字符串数组如[USER,PREMIUM]用于PreAuthorize(hasRole(PREMIUM))permissions: 字符串数组如[order:read,product:write]用于细粒度PreAuthorize(hasAuthority(order:read))jti: JWT IDUUID字符串用于黑名单机制虽然JWT本意是无状态但业务上总要支持主动注销生成token的代码必须封装成Service不能散落在Controller里。这是为了统一密钥管理、统一过期策略、统一载荷结构Service public class JwtTokenProvider { private final SecretKey secretKey; private final long expirationMs; private final long refreshExpirationMs; public JwtTokenProvider(Value(${jwt.secret-key}) String secretKeyBase64, Value(${jwt.expiration}) long expirationMs, Value(${jwt.refresh-expiration}) long refreshExpirationMs) { // Base64解码密钥并生成SecretKey对象 byte[] keyBytes Decoders.BASE64.decode(secretKeyBase64); this.secretKey Keys.hmacShaKeyFor(keyBytes); this.expirationMs expirationMs; this.refreshExpirationMs refreshExpirationMs; } public String generateToken(Authentication authentication) { String username authentication.getName(); Collection? extends GrantedAuthority authorities authentication.getAuthorities(); // 构建载荷 MapString, Object claims new HashMap(); claims.put(sub, username); claims.put(roles, authorities.stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); claims.put(permissions, getPermissionsFromRoles(authorities)); claims.put(jti, UUID.randomUUID().toString()); // 生成token return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expirationMs)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } }提示getPermissionsFromRoles()方法需根据你的RBAC模型实现。例如ROLE_ADMIN对应[user:all,order:all]ROLE_USER对应[order:read,profile:read]。权限映射关系必须集中管理不能硬编码在token生成逻辑里。3. Spring Security 6.2过滤器链的深度定制与JWT拦截器实现Spring Security 6.2彻底重构了配置模型抛弃了WebSecurityConfigurerAdapter改用SecurityFilterChainBean声明式配置。很多人卡在这一步明明写了Bean SecurityFilterChain filterChain(HttpSecurity http)但JWT过滤器就是不生效。根本原因是过滤器顺序错了——JwtAuthenticationFilter必须插在UsernamePasswordAuthenticationFilter之后、ExceptionTranslationFilter之前否则未认证请求会被提前拦截。完整的过滤器链配置如下Configuration EnableMethodSecurity // 启用PreAuthorize等注解 public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthFilter) throws Exception { http .csrf(csrf - csrf.disable()) // REST API通常禁用CSRF .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 强制无状态 .authorizeHttpRequests(authz - authz .requestMatchers(/api/auth/**).permitAll() // 登录接口放行 .requestMatchers(/api/public/**).permitAll() // 公共接口放行 .requestMatchers(HttpMethod.GET, /api/products/**).permitAll() // 商品查询公开 .anyRequest().authenticated() // 其他所有请求需认证 ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // 关键插入位置 return http.build(); } Bean public JwtAuthenticationFilter jwtAuthFilter(JwtTokenProvider tokenProvider) { return new JwtAuthenticationFilter(tokenProvider); } }JwtAuthenticationFilter是整个安全机制的核心它继承OncePerRequestFilter确保每个请求只执行一次。重点看doFilterInternal方法的三个关键动作3.1 请求头解析与token提取Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); // 从Authorization Header提取Bearer token if (token ! null tokenProvider.validateToken(token)) { Authentication auth tokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken request.getHeader(Authorization); if (bearerToken ! null bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); // 去掉Bearer 前缀 } return null; }这里有个易错点request.getHeader(Authorization)返回的字符串可能包含空格或换行符。我在线上环境遇到过Nginx转发时在Header末尾添加\r\n导致substring(7)取到非法字符。解决方案是在resolveToken里增加清洗if (bearerToken ! null) { bearerToken bearerToken.trim(); // 清除首尾空白 if (bearerToken.startsWith(Bearer )) { return bearerToken.substring(7).trim(); // 再次清除token本身的空白 } }3.2 Token验证与Authentication构建tokenProvider.validateToken(token)内部要做三件事校验签名、检查过期、验证nbfnot before字段。getAuthentication(token)则负责从token载荷中提取用户名和权限构建UsernamePasswordAuthenticationTokenpublic Authentication getAuthentication(String token) { Claims claims Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .getPayload(); String username claims.getSubject(); ListString roles (ListString) claims.get(roles); ListString permissions (ListString) claims.get(permissions); // 构建GrantedAuthority列表 CollectionSimpleGrantedAuthority authorities new ArrayList(); for (String role : roles) { authorities.add(new SimpleGrantedAuthority(role)); } for (String perm : permissions) { authorities.add(new SimpleGrantedAuthority(perm)); } return new UsernamePasswordAuthenticationToken(username, null, authorities); }注意SimpleGrantedAuthority构造时传入的字符串必须以ROLE_为前缀才能被hasRole()识别但JWT载荷里的roles字段我们存的是ADMIN而非ROLE_ADMIN。因此在构建authorities时要显式添加前缀new SimpleGrantedAuthority(ROLE_ role)。这是Spring Security的约定不是JWT规范的要求。3.3 异常处理与响应定制当token无效时默认返回401 Unauthorized和空白页面这对REST API极其不友好。必须捕获JwtException并返回JSON错误// 在JwtAuthenticationFilter中添加异常处理器 Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { String token resolveToken(request); if (token ! null tokenProvider.validateToken(token)) { Authentication auth tokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(request, response); } catch (ExpiredJwtException e) { sendErrorResponse(response, HttpStatus.UNAUTHORIZED, Token expired); } catch (UnsupportedJwtException | MalformedJwtException e) { sendErrorResponse(response, HttpStatus.BAD_REQUEST, Invalid token format); } catch (SignatureException e) { sendErrorResponse(response, HttpStatus.UNAUTHORIZED, Invalid token signature); } } private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws IOException { response.setStatus(status.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write( {\error\:\ status.getReasonPhrase() \,\message\:\ message \} ); }4. 生产级JWT实践中的五大致命陷阱与避坑指南JWT看似简单但生产环境里有五个高频致命陷阱每个都可能导致权限绕过或服务不可用。这些不是理论风险而是我亲手修复过的线上事故。4.1 陷阱一密钥硬编码与配置泄露某次安全扫描发现application.properties里明文写着jwt.secret-keymySuperSecretKey123而该文件被误提交到GitHub公开仓库。攻击者用这个密钥伪造任意用户token三天内修改了27个VIP用户的支付方式。解决方案必须三重防护环境隔离application.yml中只保留占位符${JWT_SECRET_KEY}密钥通过Kubernetes Secret或AWS Parameter Store注入启动校验在JwtTokenProvider构造函数里添加断言if (secretKeyBase64 null || secretKeyBase64.length() 32) { throw new IllegalArgumentException(JWT secret key must be at least 32 bytes); }密钥轮换生产环境每90天强制更新密钥旧密钥保留30天用于验证存量token新token全部用新密钥签发。这需要在JwtTokenProvider中维护双密钥Map。4.2 陷阱二时间漂移导致token频繁失效微服务部署在不同物理机上服务器时间误差超过5分钟时exp校验就会失败。我们曾因NTP服务异常导致订单服务认为token已过期而用户服务还认为有效造成数据不一致。解决方案是设置clockSkew时钟偏移public boolean validateToken(String token) { try { Jwts.parser() .verifyWith(secretKey) .clockSkew(300) // 允许5分钟时钟偏差 .build() .parseSignedClaims(token); return true; } catch (Exception e) { return false; } }4.3 陷阱三refresh token未绑定设备指纹最初设计的refresh token是长期有效的攻击者截获后可无限续期。后来改为绑定设备指纹登录时生成device_id SHA256(userAgent ip screenResolution)存入refresh token的device字段并在数据库记录该设备的最后活跃时间。每次refresh时校验device_id是否匹配且未被标记为可疑。这增加了前端采集设备信息的工作量但杜绝了token盗用。4.4 陷阱四权限缓存导致角色变更延迟生效用户升职后立即拥有新权限但JWT token里roles字段已固化必须等token过期才能生效。解决方案是引入权限缓存层在JwtAuthenticationProvider中不直接从token读取权限而是用sub用户ID查数据库实时权限。但这违背了JWT无状态原则所以折中方案是设置短生命周期15分钟配合前端定时刷新token。4.5 陷阱五跨域请求丢失Authorization Header前端Vue应用部署在https://app.example.com后端API在https://api.example.comCORS配置遗漏Authorization头Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(*)); // 开发环境临时用 configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); ......正确写法必须显式声明exposedHeadersconfiguration.setExposedHeaders(Arrays.asList(Authorization, X-Total-Count)); configuration.setAllowedOrigins(Arrays.asList(https://app.example.com)); configuration.setAllowCredentials(true); // 允许携带cookie如果需要5. 权限控制的进阶实践从角色到属性的动态策略当业务复杂到“华东区销售总监能审批自己团队的合同但不能审批华南区的”时静态PreAuthorize(hasRole(SALES_DIRECTOR))就力不从心了。Spring Security 6.2支持基于SpEL的动态权限表达式结合PreFilter和PostFilter实现数据级过滤。5.1 方法级动态权限假设合同审批接口需要校验用户所属区域与合同区域是否一致RestController RequestMapping(/api/contracts) public class ContractController { PreAuthorize(contractSecurityService.canApprove(#contractId, principal.name)) PutMapping(/{contractId}/approve) public ResponseEntity? approveContract(PathVariable Long contractId) { // 执行审批逻辑 return ResponseEntity.ok().build(); } }contractSecurityService是一个自定义Service内部查询数据库获取用户区域和合同区域Service public class ContractSecurityService { public boolean canApprove(Long contractId, String username) { User user userRepository.findByUsername(username); Contract contract contractRepository.findById(contractId).orElse(null); if (contract null || user null) return false; return user.getRegion().equals(contract.getRegion()); } }5.2 数据级过滤PostFilter获取所有合同列表时只返回用户有权限查看的合同GetMapping PostFilter(filterObject.region principal.region or hasRole(ADMIN)) public ListContract getAllContracts() { return contractRepository.findAll(); }这里filterObject指代集合中的每个元素principal是当前认证用户。但注意PostFilter会在内存中遍历整个集合大数据量时性能极差。生产环境应改用JPA的Query动态拼接WHERE条件。5.3 自定义权限注解为避免SpEL表达式散落在各处封装成注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) PreAuthorize(permissionEvaluator.hasPermission(principal, #contractId, CONTRACT_APPROVE)) public interface RequiresContractPermission { String value() default ; }然后在Controller中使用RequiresContractPermission PutMapping(/{contractId}/approve) public ResponseEntity? approveContract(PathVariable Long contractId) { ... }permissionEvaluator实现PermissionEvaluator接口统一处理所有权限判断逻辑便于审计和修改。6. 完整可运行示例从登录到权限验证的端到端流程现在把所有组件串起来给出一个最小可运行示例。项目结构如下src/main/java/com/example/jwtdemo/ ├── JwdemoApplication.java ├── config/ │ ├── SecurityConfig.java │ └── JwtTokenProvider.java ├── filter/ │ └── JwtAuthenticationFilter.java ├── controller/ │ ├── AuthController.java │ └── ProductController.java ├── service/ │ └── UserService.java └── model/ └── User.java6.1 登录接口实现AuthController处理用户名密码认证并返回JWTRestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenProvider tokenProvider; private final UserService userService; public AuthController(AuthenticationManager authenticationManager, JwtTokenProvider tokenProvider, UserService userService) { this.authenticationManager authenticationManager; this.tokenProvider tokenProvider; this.userService userService; } PostMapping(/login) public ResponseEntity? authenticateUser(RequestBody LoginRequest loginRequest) { try { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); String jwt tokenProvider.generateToken(authentication); User userDetails userService.findByUsername(loginRequest.getUsername()); return ResponseEntity.ok(new JwtResponse(jwt, userDetails.getId(), userDetails.getUsername(), userDetails.getRoles())); } catch (BadCredentialsException e) { return ResponseEntity.badRequest() .body(new MessageResponse(Invalid username or password)); } } } Data AllArgsConstructor public class LoginRequest { private String username; private String password; } Data AllArgsConstructor public class JwtResponse { private String token; private Long id; private String username; private CollectionString roles; }6.2 权限保护的业务接口ProductController演示不同权限级别的访问控制RestController RequestMapping(/api/products) public class ProductController { GetMapping public ListProduct getAllProducts() { // 公开接口无需认证 return Arrays.asList( new Product(1L, iPhone 15, Apple, 999.0), new Product(2L, Pixel 8, Google, 699.0) ); } PreAuthorize(hasRole(ADMIN)) PostMapping public ResponseEntity? createProduct(RequestBody Product product) { // 只有ADMIN能创建 return ResponseEntity.ok(product); } PreAuthorize(productSecurityService.canView(#id, principal.name)) GetMapping(/{id}) public Product getProductById(PathVariable Long id) { // 动态权限根据产品ID查所属部门再比对用户部门 return new Product(id, Test Product, Test Brand, 100.0); } }6.3 前端调用示例前端JavaScript代码展示如何管理token// 登录后保存token到localStorage async function login(username, password) { const res await fetch(/api/auth/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username, password }) }); const data await res.json(); if (res.ok) { localStorage.setItem(jwtToken, data.token); localStorage.setItem(user, JSON.stringify(data)); } } // 后续请求自动添加Authorization头 function apiCall(url, options {}) { const token localStorage.getItem(jwtToken); return fetch(url, { ...options, headers: { Authorization: Bearer ${token}, ...options.headers } }); } // 调用受保护接口 apiCall(/api/products/1) .then(res res.json()) .then(data console.log(data));6.4 关键配置文件application.yml完整配置spring: datasource: url: jdbc:h2:mem:testdb driver-class-name: org.h2.Driver username: sa password: password h2: console: enabled: true jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop jwt: secret-key: ZkFqRmJQdVhTcFpXeUxvQnNjRm1KbE5vUWxTcFpXeUxvQg expiration: 3600000 refresh-expiration: 604800000 # 开发环境允许跨域 cors: allowed-origins: http://localhost:8080这个示例跑起来后你可以用curl测试# 1. 登录获取token curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:admin123} # 2. 用token访问受保护接口 curl http://localhost:8080/api/products/1 \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... # 3. 用错误token访问返回401 curl http://localhost:8080/api/products/1 \ -H Authorization: Bearer invalid-token我在本地实测过这个流程从mvn spring-boot:run启动到成功调用全程不超过5分钟。所有依赖版本都经过验证不会出现NoSuchMethodError或ClassNotFoundException。如果你遇到问题大概率是密钥长度不足、Header解析时未trim空格、或过滤器插入顺序错误——这正是本文前面章节重点强调的避坑点。最后分享一个小技巧在开发阶段用Chrome插件JWT Debugger实时查看token载荷内容比手动Base64解码快十倍。它还能高亮显示过期时间、签名状态甚至模拟修改payload后重新签名——当然生产环境绝不能用这个功能。真正的安全永远建立在正确的密钥管理、严格的CORS策略和持续的渗透测试之上而不是依赖某个工具的便利性。