1. JWT到底是干什么的:先搞懂它解决的痛点
说到JWT(JSON Web Token),很多人第一反应是“一个加密的字符串”,其实这个理解不够准确。JWT本质上是一段自带签名、可验证、可携带信息的凭证,它不是加密数据,而是经过签名的数据。这俩概念的区别后面会详细展开,但先记住一点:JWT的价值在于“我确认这份数据没被人改过”,而不是“这份数据没人看得见”。
在我这两年排查过的生产事故里,JWT相关的问题占比相当高。有的是token过期策略设计不合理导致用户频繁掉线,有的是密钥硬编码在代码里被上传到公开仓库,更有甚者直接在日志里打印完整token,被下游系统抓去重放攻击。这些问题本质上都不是JWT本身不安全,而是使用JWT的人没搞清楚它的安全边界。
JWT适合解决什么问题?最典型的是无状态认证。传统的Session方案需要服务端维护会话状态,后端一扩容就会话失效,或者得引入Redis做Session共享,架构上绕了一圈。而JWT把用户身份信息、权限、过期时间全部塞进token里,服务端只需要验签通过就信任这个请求,天然适应分布式、微服务、前后端分离这些场景。
这篇文章我会拆开JWT的Header、Payload、Signature三个部分,讲清楚每个部分的作用和攻击面,再结合我实际踩过的坑聊签名算法选型、密钥管理、token续签、Spring Security整合这几个核心话题。适合正在使用JWT但没系统捋过安全机制的后端开发,也适合那些准备自研认证体系、想避开常见设计缺陷的架构师。
2. JWT结构拆解与签名验签过程还原
2.1 Header、Payload、Signature三段式结构
一个标准的JWT长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c用.分隔成三段,分别对应Header、Payload、Signature。
Header是一个JSON对象,最核心的两个字段是alg和typ。alg声明了签名算法,比如HS256、RS256;typ一般固定为JWT。这里有个经典攻击点:很多老旧的库在解析token时会信任Header里的alg字段,攻击者如果把alg改成none,服务端就可能跳过验签,直接信任伪造的Payload。这个漏洞在2015年前后非常流行,虽然主流JWT库现在都默认禁用none,但如果你维护的是老项目,值得自查一遍。
Payload是业务数据的载体,官方预定义了7个注册声明,实际中常用的就几个:
sub(Subject):用户唯一标识,一般是用户IDexp(Expiration Time):过期时间,Unix时间戳iat(Issued At):签发时间nbf(Not Before):生效时间,早于这个时间的token无效iss(Issuer):签发方标识
Signature则是前两段内容的签名结果,计算逻辑很简单:
签名 = 算法( base64url(Header) + "." + base64url(Payload), 密钥 )2.2 验签流程到底做了什么
服务端收到token后,验签过程拆开来看其实是四步:
- 用
.拆分出三段,检查格式是否合法 - 解码Header,读取
alg字段,确认签名算法 - 用服务端持有的密钥,对
base64url(Header) + "." + base64url(Payload)重新计算签名 - 比对计算出的签名与token携带的Signature是否一致
这里有一个特别容易忽略的细节:验签成功不代表数据安全。因为Payload只是base64url编码,不是加密,任何人拿到token都能解码看到里面的内容。所以千万不要往Payload里塞密码、手机号、身份证号这类敏感数据。我在实际项目里就见过把用户手机号明文放进token的“骚操作”,结果前端抓包就能看到所有人手机号,这比token被盗还尴尬。
2.3 HS256与RS256:对称与非对称的抉择
签名算法的选择直接决定密钥管理的复杂度。目前主流就两种:HS256和RS256。
HS256(HMAC-SHA256)是对称签名,签发和验签用的是同一个密钥。优点是实现简单,但缺点是所有需要验签的服务都必须持有这把密钥,密钥一旦泄露,攻击者既能伪造token也能验证token。
RS256(RSA-SHA256)是非对称签名,私钥只放在认证中心用于签发,各个业务服务只持有公钥用于验签。公钥泄露不影响安全性,因为公钥无法伪造签名。这对于微服务架构特别友好,新接入的服务只需要配置一个公钥,完全接触不到签发私钥。
我在实际项目里的推荐是:单体应用用HS256问题不大,但只要是微服务架构或者有第三方系统接入,一律用RS256。把签发能力收口到统一的认证中心,下游服务只验签不签发,权限边界就清晰了。
注意:RS256的公钥如果泄露,攻击者可以把算法改成HS256,用公钥作为HMAC的对称密钥来伪造token。这种“算法混淆攻击”在旧版库中真实存在,所以除了选对算法,还要在代码里显式锁定允许的算法列表,而不是完全信任token里的
alg字段。
3. JWT常见漏洞与攻击面:都是我实际见过的问题
3.1 密钥泄露:最致命也最常见的洞
在所有JWT安全事件里,密钥泄露的占比最高。泄露途径五花八门,但本质上都是同一个问题:密钥没有按机密信息的标准去管理。我盘点一下常见泄露场景:
- 开发阶段为了方便,把密钥硬编码在代码里,随着仓库一起提交
.env文件没加.gitignore,密钥被推到公开仓库- 密钥写在前端JS代码里(这个属于灾难级,前端代码任何人都能看到)
- 日志系统把请求头完整打印,token和密钥一起进了日志中心
- 离职员工的笔记、分享文档、在线代码片段里残留旧密钥
密钥一旦泄露,攻击者就可以用同一把密钥签发任意身份的token,包括管理员账号。我见过一个真实案例:某系统密钥是123456,攻方直接在GitHub历史提交里找到了这个密钥,然后用它签了一个sub=admin的token,整个后台如入无人之境。
密钥管理的底线要求:
- 生产密钥必须存放在配置中心或密钥管理服务(如KMS、Vault)
- 密钥至少256位(32字节),不允许使用弱密钥
- 环境隔离:测试、预发、生产各用各的密钥
- 定期轮换:每3-6个月换一次,配合刷新机制平滑过渡
3.2 kid注入与算法混淆:Header字段的攻防
kid(Key ID)是Header里的一个可选字段,用于告诉服务端“用哪把密钥来验签”。这个字段的设计初衷是方便多密钥场景下的密钥选择,但如果代码实现不当,就会变成注入点。
经典攻击方式是这样的:服务端为了支持多密钥,把密钥存在一个Map里,代码写成了:
// 存在缺陷的写法,仅用于演示 String kid = header.get("kid"); String secret = keyMap.get(kid); // kid 直接作为查询条件攻击者把kid改为../../etc/passwd之类的内容,某些库会直接用这个值去文件系统读取文件内容作为密钥,这就是路径穿越型读取。更常见的变体是如果服务端在kid对应的密钥不存在时报错并暴露堆栈信息,攻击者可以通过枚举方式探测系统文件结构,给后续渗透提供情报。
防御方式并不复杂:不要在业务代码里直接用kid作为文件路径或查询键,而是维护一份白名单映射,并且对非法kid统一报“验签失败”而不是“kid不存在”。
算法混淆攻击也是同类问题。前面提到过,攻击者把Header里的alg从RS256改成HS256,同时拿到公钥后,用公钥作为HMAC对称密钥来伪造签名。修复方案就是在解析JWT时显式指定允许的算法,而不是从Header读取,比如Java的Jwts.parserBuilder().setSigningKey(...)配合库的默认限制,或者参考下方示例,在解析前校验alg值:
// 解析前校验算法白名单 String alg = jwt.getHeader("alg"); if (!"RS256".equals(alg)) { throw new SecurityException("不支持的签名算法"); }3.3 默认密钥与框架配置陷阱
热搜词里提到的Nacos默认密钥身份认证绕过漏洞(CNVD-2023-17316)就是典型的默认密钥问题。Nacos的某些版本内置了一个固定的JWT密钥,攻击者拿这个公开的默认密钥就能伪造服务端身份token,直接绕过认证调用管理接口。这类问题的本质是:框架为了开箱即用,在配置里写死了默认密钥,而部署方没有修改。
这种坑不止Nacos一家,很多自带JWT能力的组件都有类似问题。排查方法很直接:去检查你的框架配置文件里是否有默认的secret或token-secret字段,如果有且没改,马上换掉。我在项目里习惯加一条检查规则:启动时如果检测到当前密钥等于框架默认值,直接拒绝启动。与其靠人记得改配置,不如让系统强制检查。
3.4 token失窃与重放攻击:无状态认证的短板
JWT无状态是把双刃剑,服务端不保存token状态,意味着token一旦签发,在有效期内无法主动作废。攻击者如果从日志、浏览器存储、中间人流量中窃取到token,在token过期前都可以冒充受害者发起请求。
缓解措施有几种:
- 缩短token有效期,把暴露窗口压缩到最小。比如登录态token设15-30分钟,配合续签机制
- 使用HTTPS强制传输加密,避免token在网络层被截获
- 前端禁止把token放localStorage(XSS一打就全丢),优先放内存配合刷新机制,或者用HttpOnly的Cookie承载
- 关键操作(改密、转账、下单)增加二次校验,比如要求输入密码或短信验证码
实际上,没有绝对安全的token方案,只能把风险控制在可接受范围内。这也是为什么很多大厂最终还是会引入“会话状态存储+短token+刷新token”的混合方案,用一定的状态化换取更强的管控能力。
4. 密钥管理与算法选型实操
4.1 密钥的生成、轮换与存储
密钥生成这一步,很多人是用手敲一段字符串当密钥,这个做法隐患很大。手敲的字符串通常熵值不够,很容易被暴力枚举。正确做法是用密码学安全随机数生成器:
# 生成256位随机密钥,输出base64格式 openssl rand -base64 32 # 生成RSA密钥对 openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048 openssl rsa -pubout -in private_key.pem -out public_key.pem密钥轮换是经常被忽略的一环。轮换的难点不是“生成新密钥”,而是“如何做到无缝过渡”。如果直接换新密钥,所有已签发的token都会验签失败,用户全部掉线。实际方案是保留多个验签密钥,签发时使用最新密钥,验签时根据kid选择对应密钥。这样旧密钥签发的token在新密钥生效后依然可以验签,直到旧token自然过期。
轮换流程可以这样设计:
- 生成新密钥对,写入配置中心,标记为
active - 旧密钥标记为
verifying,保留一段时间(覆盖token最长有效期) - 所有新token都用active密钥签发
- 旧token到期后,移除verifying密钥
4.2 HS256与RS256的选型决策表
| 维度 | HS256 | RS256 |
|---|---|---|
| 签名/验签密钥 | 同一个对称密钥 | 私钥签名,公钥验签 |
| 密钥分发 | 所有服务共享一把密钥 | 只分发公钥,私钥单点保存 |
| 性能 | 快(HMAC计算量小) | 慢(RSA计算量大,约慢10-50倍) |
| 适合场景 | 单体应用、内部信任环境 | 微服务、跨系统、多服务验签 |
| 泄露影响 | 伪造+验证均可 | 公钥泄露不影响安全性 |
如果你在微服务架构里用了HS256,意味着每个服务都持有签发密钥,任何一个服务被攻破,整个系统的认证体系就崩了。RS256把签发能力收口在认证中心,下游服务只持公钥,即使某个业务服务被拿下,也造不出合法token。这就是我为什么在微服务场景下强推RS256的原因。
4.3 密钥硬编码的替代方案
密钥不入代码,这是底线。替代方案从轻到重有三级:
- 轻量级:环境变量。部署平台上配置,代码里只读环境变量
- 中量级:配置中心(Apollo、Nacos、Spring Cloud Config),支持动态刷新
- 重量级:密钥管理服务(KMS、Vault),由专门的密钥管理系统托管,应用通过API获取
实测下来,环境变量适合小项目,配置中心适合中等规模团队,KMS/Vault适合对合规要求高的企业。选择标准就是:密钥只存在于部署运行环境,任何进入代码仓库的密钥都是事故。
5. 会话管理与Token续签方案
5.1 为什么需要续签:过期策略的两难
token过期时间设置是典型的“两难选择”:设得太短,用户体验差,用户频繁重新登录;设得太长,token泄露后的风险窗口太大。
在实际项目中,我一般会把过期时间控制在15分钟到2小时之间,具体看业务敏感度。但单纯设置过期时间还不够,因为用户在持续操作时突然token过期,会被强制跳到登录页,体验非常割裂。所以需要续签机制,让活跃用户的token“自动续命”。
5.2 双Token模式:访问token与刷新token
目前实践最广的是双token模式:
- Access Token:有效期短(15分钟-30分钟),每次请求都携带,用于访问受保护资源
- Refresh Token:有效期长(7天-30天),只在换取新Access Token时使用,存放要求更严格
流程是这样的:用户登录后,服务端同时签发Access Token和Refresh Token。Access Token过期后,前端拿Refresh Token去调用刷新接口,服务端校验Refresh Token有效,签发新的Access Token。Refresh Token本身也可以做轮换(每次刷新时签发新的Refresh Token,旧的作废),防止Refresh Token泄露后长期有效。
双token模式的好处是访问token暴露窗口短,即使被窃取,15分钟后就失效了。而Refresh Token虽然有效期长,但它不参与每次请求,暴露机会少得多。如果Refresh Token也要失窃防范,可以额外做“设备指纹绑定”(记录设备ID,Refresh Token只能在本设备使用)和“检测到异常刷新立即吊销全部token”的策略。
5.3 服务端状态化:JWT也能实现主动失效
对于需要精确控制的场景(用户改密码、管理员封号、用户登出),靠JWT本身做不到主动失效,因为token是无状态的。但可以引入一层薄薄的黑名单机制:
- 服务端维护一份“已注销token ID(jti)”列表,过期时间与token一致
- 每次验签时先查黑名单,命中的拒绝访问
- 用户登出、修改密码时,把当前token的jti加入黑名单
这个方案牺牲了JWT的完全无状态,但换来了可管控能力。黑名单可以放Redis,key用jti,value随意,TTL设成token剩余有效期,这样内存不会无限增长。这是我在实际项目中比较推荐的折中方案:保留JWT的验签效率,同时解决“踢人下线”的需求。
5.4 SPA项目中的验证码与token配合
热搜词里提到“SPA项目开发之JWT验证码实现”,这里多说一句。验证码和JWT不是同一个层面的东西:验证码解决的是“你是不是真人”,JWT解决的是“你是谁、有没有权限”。但在登录流程里它们要配合起来:
- 用户输入账号密码+验证码
- 服务端验证验证码有效(先验“人”)
- 再验账号密码(再验“身份”)
- 签发JWT,返回给前端
有一个容易踩的坑:验证码的校验应该绑定会话而不是绑定用户。攻击者可以通过暴力枚举获得验证码后再去撞库,如果验证码不绑定会话ID,同一个验证码可以被任意会话复用,安全性大打折扣。正确做法是验证码生成时绑定会话ID(比如存Redis的captcha:{sessionId}),校验时也带上会话ID。
6. Spring Security整合JWT:从配置到实战
6.1 整合前的设计思路
Spring Security本身是一套完整的认证授权框架,默认基于Session。整合JWT的思路是:保留Spring Security的过滤链机制,用JWT过滤器替换掉Session认证逻辑。
核心组件有三个:
JwtAuthenticationFilter:负责从请求头提取token,验签,解析用户信息,写入SecurityContextRestAuthenticationEntryPoint:处理未认证请求,返回401 JSON而不是重定向JwtUtil:负责生成token、解析token、校验token
整体请求流程是:请求进来先过过滤器链,JwtAuthenticationFilter尝试解析token,解析成功就放行并设置认证信息,解析失败不直接拦截(让后面的授权逻辑决定是否返回401)。对于公开接口(登录、注册、验证码),在SecurityFilterChain里用permitAll()放行。
6.2 核心代码实现
下面是可用的核心代码,我尽量精简但保证能跑起来。
JwtUtil负责token的生成和解析(这里以RS256为例,因为前面讲了选型理由):
@Component public class JwtUtil { @Value("${jwt.private-key}") private String privateKeyStr; @Value("${jwt.public-key}") private String publicKeyStr; @Value("${jwt.expiration}") private Long expiration; // 单位:秒 private PrivateKey privateKey; private PublicKey publicKey; @PostConstruct public void init() throws Exception { // 从Base64字符串加载密钥 byte[] keyBytes = Base64.getDecoder().decode(privateKeyStr); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); privateKey = keyFactory.generatePrivate(spec); byte[] pubBytes = Base64.getDecoder().decode(publicKeyStr); X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(pubBytes); publicKey = keyFactory.generatePublic(pubSpec); } public String generateToken(Long userId, String username, List<String> roles) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("roles", roles) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000)) .signWith(privateKey, SignatureAlgorithm.RS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(publicKey) .build() .parseClaimsJws(token) .getBody(); } }JwtAuthenticationFilter是核心过滤器,注意算法白名单检查:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; public JwtAuthenticationFilter(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { try { // 解除Header里的alg,显式校验算法白名单 String alg = getAlgFromToken(token); if (!"RS256".equals(alg)) { throw new SecurityException("不支持的签名算法"); } Claims claims = jwtUtil.parseToken(token); Long userId = Long.valueOf(claims.getSubject()); List<String> roles = claims.get("roles", List.class); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())); authentication.setDetails(claims); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (JwtException | IllegalArgumentException e) { // 验签失败不抛异常,让后续的授权逻辑返回401 SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); if (bearer != null && bearer.startsWith("Bearer ")) { return bearer.substring(7); } return null; } private String getAlgFromToken(String token) { String[] parts = token.split("\\."); if (parts.length != 3) { throw new JwtException("token格式错误"); } byte[] headerBytes = Base64.getUrlDecoder().decode(parts[0]); return new String(headerBytes, StandardCharsets.UTF_8) .replaceAll(".*\"alg\":\"", "") .replaceAll("\".*", ""); } }SecurityFilterChain配置:
@Configuration @EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/captcha").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling().authenticationEntryPoint(new RestAuthenticationEntryPoint()) .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }注意几个关键点:
SessionCreationPolicy.STATELESS告诉Spring Security不要创建HttpSession,因为JWT模式不需要- 过滤器用
addFilterBefore放在UsernamePasswordAuthenticationFilter之前,在身份认证前先解析token RestAuthenticationEntryPoint返回JSON格式的401,而不是重定向到登录页
6.3 整合后的常见问题
整合过程中最常见的报错是401连环出现,典型原因:
- 密钥加载失败:检查公私钥的格式和Base64编码是否正确
- Bearer前缀不匹配:前端实际发的请求头里没有
Bearer前缀 - 过滤器顺序不对:过滤器没有生效,请求根本没经过JWT解析
- 白名单路径配置错误:明明配了
permitAll,但请求还是被拦截
排查这类问题,先从请求头入手,确认token格式;再看过滤器是否打印了解析日志;最后看Security配置哪里把路径拦截了。如果项目引入了spring-boot-starter-security,默认会开启基础认证,记得确认自定义配置生效了。
7. 实际问题排查实录与避坑经验
7.1 时钟偏移导致的token验证失败
这是一个很难查的“幽灵问题”。JWT里的exp、iat、nbf都是Unix时间戳,如果发起请求的客户端和服务端之间存在时间偏差,就会出现一种诡异现象:客户端本地时间没过期,服务端判定已过期,或者反过来。
我遇到过最夸张的一次:某内网服务端时钟慢了5分钟,所有token都比正常时间晚5分钟过期,安全审计时被重点点名。排查方法是用date命令对比服务端和权威时钟源的偏差。解决方法是服务器统一用NTP同步时间,同时JWT解析时可以设置一个小的clockSkew容忍值(比如30秒)。
7.2 token中的中文乱码与特殊字符
JWT的Payload是base64url编码,解码后就是UTF-8的JSON。如果往claim里塞中文用户名,有些库在解码时使用ISO-8859-1,就会出现乱码。遇到这个问题的第一反应不是改库配置,而是确认自己在解析时显式指定了UTF-8。另一个坑是header里如果带了非ASCII字符,某些老版本库会直接解析失败,所以我的习惯是header里不塞业务数据,只保留预定义字段。
7.3 日志打印token的安全隐患
开发为了方便,很多人会在拦截器里打印Authorization头或者个性化日志里带上token。这在开发环境没问题,但一旦日志接入了ELK之类的集中式系统,token就会长期滞留在日志存储里。一次日志平台泄露,所有在有效期内的token全部作废。
我的习惯是:生产环境日志里绝不打印完整token,最多打后四位。如果确实需要排查问题,用token的jti(JWT ID)来关联日志,这样既能追踪问题,又不会泄露可用凭证。
7.4 调试工具与JWT在线解析的取舍
接手一个老项目时,往往需要快速查看token内容。我推荐先用在线工具或命令行解码看结构,但要注意:不要拿生产环境的真实token去非可信站点解析。安全做法是本地写个小脚本,或者用Java里的jjwt库写一个几十行的解析工具类。
看一份token能不能用,先不管签名,直接看Payload里的exp和sub;再看Header里的alg和kid是否正确。如果这两层没问题,才是验签环节的问题。这个排查顺序能帮你省掉不少无头苍蝇式的时间。
7.5 黑名单与Redis的配合细节
用Redis存token黑名单时,有几个细节值得注意:
- key要加前缀区分业务,比如
jwt:blacklist:{jti} - TTL设置成token剩余有效期,避免黑名单无限膨胀
- 查询黑名单的时机要放在验签之前,而不是之后。先查黑名单再验签,能提前拦截已吊销的token,减少无效验签计算
还有一个后来我才意识到的问题:黑名单方案只适合低频吊销场景(用户主动登出、改密)。如果业务需要“封禁某个用户所有token”,应该记录用户级别的封禁状态,在过滤器里统一校验,而不是一个个token加黑名单。
8. 最后想说的话
做了这么多年后端,我的总体感受是:JWT不是一个“配好了就一劳永逸”的东西,它的安全性完全取决于使用方式。选对签名算法、管好密钥、设计好过期与续签策略、处理好注销场景,这些才是JWT安全的核心命题。热搜词里那些“JWT漏洞总结”和“默认密钥绕过”的案例,其实都不是JWT本身的缺陷,而是部署和使用环节的疏漏被放大了。
如果你现在正准备在项目里引入JWT,我的建议是先从密钥管理做起,密钥的安全级别决定了整个认证体系的信任根基。然后画清楚token的生命周期,把过期、续签、注销的时序想明白,再动手写代码。这样即使以后踩坑,也不会是最低级的那一种。