JWT配置与外部化实践:Spring Boot令牌鉴权指南

📅 发布时间:2026/10/21 4:20:09
JWT配置与外部化实践:Spring Boot令牌鉴权指南
项目标题: Jwt配置及使用 外部配置文件做过几个带登录鉴权的后台管理系统之后我对JWT的态度经历过一次明显的转变早先觉得它不过是个“把用户信息塞进一串字符串”的简单工具谁不会用后来在某个项目中把Token密钥直接写死在代码里上线后被人从反编译的JAR包里翻出了密钥才被迫花了一整晚去重构配置、轮换密钥、梳理签发逻辑。那次教训之后我养成了一个习惯——所有跟JWT相关的参数一律外部化配置绝不让密钥、过期时间、签发者这些信息躺在代码里。这篇内容就围绕两件事展开一是JWT从配置到签发、校验的完整工程落地过程我把每一步的代码、参数选择和设计理由都写清楚二是外部配置文件怎么做包括多环境profile、外置配置文件、环境变量几种方案以及最容易踩的坑。适合正在做登录鉴权、打算从Session切换到Token方案、或者已经用上JWT但配置管理比较随意的开发者参考。1. 先从需求说起接口鉴权为什么要选JWT1.1 一次典型的登录会话问题假设你在做一个前后端分离的管理系统客户端是PC端页面服务端是Spring Boot接口。用户登录之后后续每个请求都得让服务端知道“这人是谁”否则接口没法做权限控制。传统做法是Session用户登录成功服务端生成一个Session ID存在内存或者Redis里同时通过Cookie下发给浏览器之后每次请求带上这个ID服务端查到对应的Session就知道用户身份了。问题在于如果服务端是集群部署Session默认存在单机内存里用户在A机器登录下一次请求被负载均衡转发到B机器B机器没有这个Session用户就被判定为未登录。解决办法是引入Session共享把Session集中放到Redis但这又需要额外维护一套会话存储还要处理Session过期、序列化、脏数据清理等一系列问题。1.2 JWT和Session方案的差异JWT解决的是同一个问题但思路完全不同登录成功之后服务端把用户ID、用户名、过期时间等信息打包进一个经过签名的字符串直接返回给客户端。客户端后续请求在Header里带上这个字符串服务端验签通过、确认没过期就直接信任里面的用户信息不需要在服务端存任何会话数据。对比一下两者对比项Session方案JWT方案服务端存储需要存储会话数据无需存储服务器无状态集群扩展需要Session共享或粘滞会话天然支持任意节点可校验客户端载体通常依赖CookieHeader、Cookie、URL参数均可跨域支持Cookie有跨域限制需额外配置Header方式对跨域友好主动失效删除Session即可无状态失效依赖过期时间或黑名单传输体积Session ID很小Token较长含用户信息与签名安全关注点防Session固定、防窃取防密钥泄露、防Token窃取没有哪种方案绝对好但如果你的服务本身是无状态设计、接口要同时被Web端、移动端、第三方系统调用JWT往往比Session更顺手。开发者也少维护一套会话存储临时扩容时不用考虑Session共享的问题。2. JWT核心原理Token里到底装了什么2.1 三段式结构拆解JWT全称是JSON Web Token本质上是一个经过签名的JSON数据载体。一个标准的JWT长这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVpZCI6MTAwMSwiZXhwIjoxNzIwMDAwMDAwfQ.KkU4Vz1fQ5Y4v3XjLm0gZ9yP1fT6dG8wSzQxWnBmV2M用英文句点拆成三段每一段都是独立的Base64URL编码数据第一段是Header声明令牌类型和签名算法默认可以是{alg:HS256,typ:JWT}。第二段是Payload存放实际传递的数据比如用户ID、用户名、角色、过期时间等。第三段是Signature用指定算法对前两段内容加上密钥计算出的签名用来防止内容被篡改。要知道一个最基本的事实JWT的Payload虽然是Base64URL编码的但它只是编码不是加密。任何人拿到Token都可以解码看到里面的内容。我见过有人把数据库连接串、手机号甚至密码明文塞进JWT里的这是非常危险的做法。JWT适合存放非敏感的、用于身份标识和权限判断的数据比如用户ID、角色Code敏感信息一律不放。2.2 签名算法与防篡改原理签名的作用是保证数据完整性Token在签发之后如果有人修改了Payload里的用户ID服务端验签时会发现签名对不上直接拒绝。原理不复杂——签名是把Header和Payload拼接后用密钥做HMAC计算密钥只有服务端知道客户端无法伪造。常用的签名算法分两类HS256HMAC-SHA256对称算法签发和校验用同一个密钥实现简单单服务或可控服务环境下够用也是使用最广泛的方案。RS256RSA-SHA256非对称算法私钥签发、公钥校验适合第三方开放平台或微服务间互相校验Token的场景需要额外管理密钥对。绝大多数内部系统的管理后台用HS256就够了。只有当你需要把Token签发权和外系统校验权分离或者多个服务各自持有校验公钥、不需要共享同一个HMAC密钥时才考虑RS256。2.3 密钥选型与有效期设置的背后逻辑HS256下的密钥长度不是随便定的。如果你用过JJWT库密钥长度低于32字节256位时很多版本会直接抛异常比如WeakKeyException。原因是HMAC-SHA256要求密钥至少256位否则安全性不达标。实际项目里不要卡着32字节用建议生成一个随机的、长度在64字节以上的字符串用Base64编码后放到配置文件里。生成方式很简单openssl rand -base64 48这行命令会输出一个64字符左右的随机字符串可以直接作为JWT密钥。过期时间的选择是个典型的权衡。设置太短用户每隔一两个小时就要重新登录体验差设置太长Token一旦泄露攻击者能长时间冒充用户。折中方案是短Token配合刷新Token访问Token有效期设15分钟到2小时刷新Token有效期设7天或30天前端检测到401后自动用刷新Token换取新访问Token。大多数后台管理系统直接设单Token有效期几小时也能接受但如果你做的是面向大量用户的开放类系统建议上刷新Token机制。3. 工程落地依赖引入与基础配置3.1 依赖选型Java生态里JWT库最常见的是JJWTJava JWTAPI设计清晰、文档齐全、社区活跃。以Spring Boot项目为例Maven依赖如下dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency之所以拆成三个依赖是JJWT官方推荐的模块化做法jjwt-api提供编译期接口jjwt-impl是运行时实现jjwt-jackson负责JSON序列化和反序列化。默认使用Jackson因为Spring Boot本身就内置了Jackson不需要额外引入别的JSON库。需要注意版本差异。JJWT的0.9.x和0.11.x、0.12.x之间API差异很大。0.11.x用Keys.hmacShaKeyFor()构建密钥用Jwts.parserBuilder().setSigningKey()创建解析器而0.12.x把parserBuilder改成了parsersetSigningKey改成了verifyWith。网上很多教程混用两代API直接复制到新项目会编译不过。如果你用的是0.11.5下面我给的代码可以直接跑。3.2 配置文件初版为什么不能让密钥裸奔在代码里很多新手写JWT工具类时习惯直接在代码里写死密钥private static final String SECRET my-secret-key;这种写法的问题在开头说过一遍但值得再强调代码里写死密钥意味着每次改密钥都要改代码、重新编译、重新打包部署。而且一旦代码仓库泄露或者JAR包被反编译密钥就完全暴露了。密钥泄露的影响不是“Token能被伪造”一句话概括的而是攻击者可以用你的密钥签发任意用户身份的Token整个鉴权体系形同虚设。正确的做法是把密钥、过期时间、签发者都放到配置文件里通过配置项注入。先看Spring Boot的application.yml初版jwt: secret: ${JWT_SECRET:dev-please-change-this-secret-key-64-bytes-long} expire-seconds: 7200 issuer: my-system这里用到了${JWT_SECRET:默认值}语法优先读环境变量JWT_SECRET如果没设置就用冒号后面的默认值。开发环境本地跑的时候可以不设置环境变量直接用默认值生产环境必须设置JWT_SECRET环境变量覆盖默认值。后面讲外部配置时这个思路会进一步扩展。3.3 配置类设计读取配置与初始化密钥写一个配置类来承载JWT相关配置避免在工具类里散落魔法值Component ConfigurationProperties(prefix jwt) public class JwtProperties { private String secret; private long expireSeconds; private String issuer; // getter 和 setter 省略 }ConfigurationProperties会把application.yml里jwt前缀下的配置项自动映射到字段上。相比逐个用Value注入这种方式集中、清晰、后续加配置项不用改多个地方。不过要注意开启了ConfigurationProperties后必须确保类上有Component或者在配置类上用EnableConfigurationProperties(JwtProperties.class)否则Spring不会扫描到这个配置类。我踩过一次这个坑加了注解但是忘记让Spring管理这个Bean结果所有JWT配置都是null排查了半天才发现是Bean没注册。密钥的初始化有一点需要提醒建议在配置类中把字符串密钥提前转为SecretKey对象避免每次签发和校验都做一次转换。可以这样处理private SecretKey secretKey; public SecretKey getSecretKey() { if (secretKey null) { byte[] keyBytes Decoders.BASE64.decode(secret); secretKey Keys.hmacShaKeyFor(keyBytes); } return secretKey; }前提是配置里的密钥是用Base64编码的字符串。如果直接放明文密钥记得用secret.getBytes(StandardCharsets.UTF_8)并确保字节长度够32位。4. 签发与校验两个核心方法的完整实现4.1 生成Tokenclaims、过期时间、签名JWT工具类里最核心的是两个方法一个是根据用户信息生成Token另一个是从Token解析出用户信息。先看签发逻辑。Component public class JwtTokenProvider { private final JwtProperties jwtProperties; private final SecretKey secretKey; public JwtTokenProvider(JwtProperties jwtProperties) { this.jwtProperties jwtProperties; this.secretKey jwtProperties.getSecretKey(); } public String createToken(Long userId, String username, ListString roles) { Date now new Date(); Date expiryDate new Date(now.getTime() jwtProperties.getExpireSeconds() * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(roles, roles) .setIssuer(jwtProperties.getIssuer()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } }几个设计点逐个说setSubject存用户ID这是主流做法因为用户ID通常不会变而用户名可能因为修改资料而变化。claim(roles, roles)存放角色列表服务端后续做权限判断时直接从Token里取省得每次查数据库。setIssuedAt记录签发时间setExpiration记录过期时间。signWith(secretKey, SignatureAlgorithm.HS256)指定签名算法和密钥。也是值得注意的地方用户名、角色这类信息改动频率低、不敏感放进Token合理。但像用户头像URL、手机号这类信息没必要往Token里塞Token太大会导致每次请求Header体积膨胀影响网络传输效率而且信息更新后Token里的旧值无法同步刷新。4.2 解析Token捕获异常、判断有效性解析是反向过程public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); }这个方法只做一件事验签并取回Claims。但实际使用中必须考虑异常场景。Token可能过期、可能被篡改、可能是乱传的字符串这些情况都会抛出不同异常必须在调用层统一处理。我的做法是包装一个更友好的解析方法public Claims parseTokenSafely(String token) throws JwtValidationException { try { return Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { throw new JwtValidationException(Token已过期, e); } catch (JwtException e) { throw new JwtValidationException(Token无效, e); } }这里把ExpiredJwtException单独拎出来是有讲究的过期Token的Claims其实已经解析出来了在某些场景下比如做自动续期时你可能想在Token过期前几分钟就重新签发新Token那就要能拿到过期Token里的用户信息。但这个方式不适合直接做刷新Token因为真正的刷新Token机制需要独立的有效期和黑名单控制后面常见问题部分我再展开。4.3 单元测试跑通全流程配置、签发、解析都写完之后最好先写个测试类验证全流程避免把问题带到联调阶段。测试用例覆盖三件事正常签发能解析出正确的用户ID篡改Token会被拒绝过期Token会被拒绝。SpringBootTest class JwtTokenProviderTest { Autowired private JwtTokenProvider jwtTokenProvider; Test void createAndParseToken_shouldWork() { String token jwtTokenProvider.createToken(1001L, admin, List.of(ADMIN)); Claims claims jwtTokenProvider.parseTokenSafely(token); Assertions.assertEquals(1001, claims.getSubject()); Assertions.assertEquals(admin, claims.get(username, String.class)); } Test void tamperedToken_shouldThrow() { String token jwtTokenProvider.createToken(1001L, admin, List.of(ADMIN)); String tampered token.substring(0, token.length() - 2) xx; Assertions.assertThrows(JwtValidationException.class, () - jwtTokenProvider.parseTokenSafely(tampered)); } }第二个用例里篡改Token末尾的签名部分后解析器会抛出SignatureException因为签名验证不通过。这个测试能确保你的异常包装逻辑真的生效而不是把原始异常抛到接口层导致客户端收到一堆看不懂的错误堆栈。5. 集成Spring Security过滤器把Token变成认证信息5.1 认证过滤器的工作链路光有签发和解析还不够得让JWT和Spring Security接起来做到“请求进来时自动从Header里取Token、解析出用户身份、填充SecurityContext”。核心是一个OncePerRequestFilterComponent public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null) { try { Claims claims jwtTokenProvider.parseTokenSafely(token); String username claims.get(username, String.class); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (JwtValidationException e) { SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken request.getHeader(Authorization); if (StringUtils.hasText(bearerToken) bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); } return null; } }工作链路是请求进入过滤器从Authorization头提取Bearer开头的Token解析出用户名通过UserDetailsService加载用户完整信息含角色权限封装成UsernamePasswordAuthenticationToken放进SecurityContextHolder。后续Controller里用AuthenticationPrincipal就能拿到当前登录用户用PreAuthorize就能做角色控制。有个细节容易被忽略解析Token里已经带了角色信息为什么还要调用UserDetailsService去查库因为权限判断需要GrantedAuthority对象而且查库能顺带确认用户账号还是不是正常的比如被禁用、被删除。如果完全信任Token里的角色就可能出现“用户已经被管理员禁用但Token还没过期依然能访问接口”的情况。当然你也可以把角色直接转换成SimpleGrantedAuthority塞进认证对象省掉一次查询代价是用户状态变更不是实时生效看业务场景取舍。5.2 放行白名单与401处理所有请求都走过滤器没问题但登录接口本身、刷新Token接口、静态资源、Swagger文档这些是不需要认证的必须在SecurityConfig里显式放行Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/refresh, /doc.html, /webjars/**).permitAll() .anyRequest().authenticated()) .exceptionHandling(ex - ex.authenticationEntryPoint((request, response, authException) - { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或Token无效\}); })) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }重点看最后一行addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)。加不加这一行差别很大如果只是把JwtAuthenticationFilter注册成ComponentSpring Boot的自动配置可能会把它加到过滤器链的默认位置顺序不受控制。明确指定放在UsernamePasswordAuthenticationFilter之前能保证JWT过滤器先执行把认证信息放好后面的授权过滤器才能看到。SessionCreationPolicy.STATELESS也很关键表明接口不创建也不使用Session让Spring Security彻底走无状态模式。很多集成JWT后依然出现“明明设置了Token却还是返回401”的问题排查后往往是这里用了默认的IF_REQUIRED策略过滤器链尝试创建Session行为变得诡异。5.3 常见坑过滤器顺序、多次读取Body等集成过程中有几个坑值得单独提一下。第一个坑是过滤器执行顺序问题。如果你的项目里还有别的过滤器比如全局请求日志过滤器、CORS过滤器要注意它们和JWT过滤器的相对位置。CORS过滤器必须在JWT过滤器之前执行否则跨域请求的预检OPTIONS请求会先被JWT过滤器拦截导致前端拿到401而不是正常的跨域响应。第二个坑是OncePerRequestFilter的名字已经说明了它保证每个请求只执行一次但如果你不小心把它同时注册成Component又在SecurityConfig里addFilterBefore在某些版本的Spring Boot里可能会出现过滤器被注册两次的问题。解决办法是二选一要么只在SecurityConfig里注册要么只声明Component且不重复添加。我习惯只在SecurityConfig里用new创建过滤器对象避免Spring容器和Security配置重复管理同一个Bean。第三个坑是Controller里拿用户信息的方式。新手容易直接注入HttpServletRequest再手动解析Header里的Token其实完全没必要AuthenticationPrincipal UserDetails直接就能拿GetMapping(/me) public UserInfo getMe(AuthenticationPrincipal UserDetails userDetails) { return userService.findByUsername(userDetails.getUsername()); }6. 外部配置文件让JWT配置不再写死6.1 为什么要把配置外置聊完JWT本身的实现回到标题里的重点“外部配置文件”。为什么这个词值得专门拿出来讲因为JWT的密钥、过期时间、签发者这些配置天然就应该是和环境强相关的开发环境可以用短过期时间方便调试测试环境可以用独立的密钥生产环境必须用高强度密钥和合理的过期时间。如果这些信息只写在application.yml里跟着代码包走每次环境切换都要改配置、重新打包这既慢又不安全。外部配置的核心目标是把“配置”从“代码”中剥离代码包不变启动时通过外部指定配置文件或者环境变量来覆盖内部配置。同一个JAR包开发环境用开发配置启动生产环境用生产配置启动互不干扰。这也符合十二要素应用的理念配置要环境化、外置化而不是和代码耦合在一起。6.2 方案Aapplication.yml多环境profile最轻量的外部化方案是Spring Boot自带的profile机制。你在application.yml里不写死任何环境相关的值而是定义一个公共配置项再为不同环境创建profile文件# application.yml spring: profiles: active: dev# application-dev.yml jwt: secret: ${JWT_SECRET:dev-only-secret-key-32-bytes-long} expire-seconds: 3600 issuer: my-system-dev# application-prod.yml jwt: secret: ${JWT_SECRET} expire-seconds: 7200 issuer: my-system-prod启动时指定profile即可切换java -jar my-app.jar --spring.profiles.activeprod这个方案的好处是零额外依赖、Spring原生支持很多人就是这么干的。但它有一个隐患profile文件仍然打包在JAR内部。如果拿到JAR包的人解压出来依然能看到每个环境下的配置包括密钥的默认值。所以profile方案更适合管理“非机密配置”的环境差异机密信息还要靠环境变量或者外置文件来注入。6.3 方案B独立配置文件外置真正意义上的“外部配置文件”是让配置文件完全脱离JAR包。Spring Boot提供了启动参数来指定外部配置目录java -jar my-app.jar --spring.config.additional-location/etc/my-app/config/启动后Spring Boot会把这个目录下的application.yml或application-{profile}.yml和JAR包内部的同名配置合并外部文件的优先级更高也就是说外部配置会覆盖内部配置。生产环境运维只需要维护/etc/my-app/config/下的配置文件代码包升级时配置文件不会被动过。实际操作时有个需要注意的点目录下放application.yml是覆盖的同名文件放application-prod.yml只对prod生效。更可控的做法是直接把配置文件名改成application-jwt.yml然后只用spring.config.additional-location指向这个文件逻辑上更清晰专门放JWT配置和主配置互不干扰。java -jar my-app.jar \ --spring.profiles.activeprod \ --spring.config.additional-location/etc/my-app/config/application-jwt.yml这个方案适合中小项目不引入额外组件配置分离得干净。缺点是不能动态更新改完外部配置文件必须重启应用才能生效。如果项目对配置热更新有要求就要考虑配置中心了。6.4 方案C环境变量与配置中心环境变量是另一个维度的外部化。比如用Docker部署时在docker-compose.yml里注入环境变量应用内部通过占位符引用services: app: image: my-app:latest environment: - JWT_SECRET${JWT_SECRET} - SPRING_PROFILES_ACTIVEprod这种方法最安全密钥不落盘、不进代码库、不打包进JAR只在部署平台宿主机、容器平台上维护。如果你已经在用Kubernetes可以把密钥放到Secret里挂载为环境变量实现配置和密钥的安全管理。再往上走是配置中心比如Nacos、Apollo这类组件可以做到配置集中管理、动态刷新、版本回溯。团队规模大了之后几十个服务各自维护配置文件会很难受配置中心几乎是必经之路。但要注意引入配置中心是有成本的选择生产环境要多部署一套配置服务也多了个强依赖。一个只有两三个服务的中小项目环境变量加外置配置文件已经足够不必一上来就上配置中心。6.5 读取外部配置的代码写法与优先级注意不管用哪种方案代码层面读取配置的方式是不变的还是ConfigurationProperties或者Value。以Value为例Component public class JwtConfigReader { Value(${jwt.secret}) private String secret; Value(${jwt.expire-seconds:7200}) private long expireSeconds; }Value的默认值语法是${jwt.expire-seconds:7200}冒号后面是默认值。如果外部配置里没有这个项Spring会用默认值不会因为配置缺失直接启动失败。对于密钥这种必填项建议不要给默认值让它在缺失时启动报错避免带着弱密钥跑起来。外部配置生效之后要验证一件事应用到底加载了哪个配置。最直接的方式是启动时打印关键配置项去掉敏感内容。我习惯在启动日志里输出PostConstruct public void logJwtConfig() { log.info(JWT配置加载完成issuer{}, expireSeconds{}, secret已配置:{}, jwtProperties.getIssuer(), jwtProperties.getExpireSeconds(), StringUtils.hasText(jwtProperties.getSecret())); }只打印“密钥是否已配置”的布尔值不要打密钥本身。另外Spring Boot的ConfigDataEnvironmentPostProcessor会在启动时打印所有加载到的配置源如果外部配置没生效看启动日志里有没有对应的配置文件路径是最快的排查手段。7. 常见问题与排查实录7.1 一个排查案例密钥写死引发的安全劫难前面提到的那个项目具体情况是开发同学图省事把密钥写死在工具类里发布时用ProGuard做了模糊处理以为反编译看不到。结果某天监控发现一个管理员的账号在凌晨连续调用了大量导出接口而这位管理员本人根本没有登录。排查后发现攻击者从Git历史里直接拿到了明文密钥因为写死密钥的代码提交到过私有仓库而仓库权限管理并不到位。那次事件之后我推动团队做了三件事密钥全部移出代码仓库改用环境变量注入所有端点的Token有效期统一收短从7天改成2小时增加登录异常和敏感接口调用告警。这个教训说明技术上做得多好是一回事密钥管理是另一回事两者都不能省。7.2 高频问题速查表错误现象可能原因解决办法启动报WeakKeyException密钥长度不足32字节或密钥不是Base64编码的随机串用openssl rand -base64 48生成密钥确保解码后字节数≥32所有请求都返回401过滤器未注册到Security链路或SessionCreationPolicy未设置成STATELESS确认addFilterBefore生效检查SecurityConfigToken解析报SignatureException密钥不一致或Token被篡改确认签发和校验用同一个SecretKey对象启动时打印密钥指纹做对比Token明明没过期却解析失败服务器时间偏差或不同节点系统时间不同步检查系统时间如有需要做时间同步Value(${jwt.secret})注入为null配置类没有注册为Spring Bean或配置项前缀写错确认配置类有Component检查application.yml里前缀拼写外部配置文件不生效spring.config.additional-location路径不对或外部文件优先级低于内部配置确认路径存在检查启动日志里是否加载了该配置源修改外部配置文件后重启不生效配置文件名不匹配profile或缓存原因确认文件名符合约定避免使用application.yml覆盖主配置用独立文件名7.3 关于刷新Token的进阶备注如果你想做刷新Token别在JWT本身的编码上折腾更别想着“让Token自动续期”。JWT是无状态的Token签发之后就很难在服务端直接控制它的生死。业界通用方案是访问TokenAccess Token短有效期比如15分钟到2小时不存服务端。刷新TokenRefresh Token长有效期比如7天服务端存一份Redis、数据库均可支持主动吊销。刷新Token过期或已被吊销时客户端必须走重新登录流程。这样既发挥了JWT无状态的优点又补上了“无法主动踢人”的短板。很多教程把刷新Token的实现写得神神秘秘但核心逻辑其实不外乎上面这三条。如果你的系统要求不高单Token方案用着也没问题先把基础的东西做扎实。8. 写在最后JWT本身不是复杂的加密技术它就是把一个JSON做签名后塞给客户端再在下一次请求中验签取回。真正的复杂度在工程化密钥怎么管、配置怎么分离、过期策略怎么定、和Spring Security怎么衔接。把这些落地细节处理好比单纯会调API重要得多。就我个人的项目经验来说外部配置这块值得尽早做。哪怕是一个人维护的小项目从第一行代码开始就把密钥放在环境变量里成本几乎为零后面省掉的是重构的麻烦。等系统跑起来再回头改配置方案要动的东西就多了配置类、部署脚本、启动参数、测试用例四处都要跟着改。还有一个建议给JWT工具类写覆盖基本场景的单元测试包括签发、验签、篡改、过期这四种情况。这个测试花不了多少时间但排查问题的时候能省下大把时间至少能立刻排除“是代码Bug还是配置问题”的干扰。这是我踩过几次坑之后总结出来的希望你能少走这些弯路。