ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JWT安全实战指南:从签名算法到密钥管理与常见漏洞防御

JWT安全实战指南:从签名算法到密钥管理与常见漏洞防御 1. JWT到底是干什么的先搞懂它解决的痛点说到JWTJSON 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、RS256typ一般固定为JWT。这里有个经典攻击点很多老旧的库在解析token时会信任Header里的alg字段攻击者如果把alg改成none服务端就可能跳过验签直接信任伪造的Payload。这个漏洞在2015年前后非常流行虽然主流JWT库现在都默认禁用none但如果你维护的是老项目值得自查一遍。Payload是业务数据的载体官方预定义了7个注册声明实际中常用的就几个subSubject用户唯一标识一般是用户IDexpExpiration Time过期时间Unix时间戳iatIssued At签发时间nbfNot Before生效时间早于这个时间的token无效issIssuer签发方标识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。HS256HMAC-SHA256是对称签名签发和验签用的是同一个密钥。优点是实现简单但缺点是所有需要验签的服务都必须持有这把密钥密钥一旦泄露攻击者既能伪造token也能验证token。RS256RSA-SHA256是非对称签名私钥只放在认证中心用于签发各个业务服务只持有公钥用于验签。公钥泄露不影响安全性因为公钥无法伪造签名。这对于微服务架构特别友好新接入的服务只需要配置一个公钥完全接触不到签发私钥。我在实际项目里的推荐是单体应用用HS256问题不大但只要是微服务架构或者有第三方系统接入一律用RS256。把签发能力收口到统一的认证中心下游服务只验签不签发权限边界就清晰了。注意RS256的公钥如果泄露攻击者可以把算法改成HS256用公钥作为HMAC的对称密钥来伪造token。这种“算法混淆攻击”在旧版库中真实存在所以除了选对算法还要在代码里显式锁定允许的算法列表而不是完全信任token里的alg字段。3. JWT常见漏洞与攻击面都是我实际见过的问题3.1 密钥泄露最致命也最常见的洞在所有JWT安全事件里密钥泄露的占比最高。泄露途径五花八门但本质上都是同一个问题密钥没有按机密信息的标准去管理。我盘点一下常见泄露场景开发阶段为了方便把密钥硬编码在代码里随着仓库一起提交.env文件没加.gitignore密钥被推到公开仓库密钥写在前端JS代码里这个属于灾难级前端代码任何人都能看到日志系统把请求头完整打印token和密钥一起进了日志中心离职员工的笔记、分享文档、在线代码片段里残留旧密钥密钥一旦泄露攻击者就可以用同一把密钥签发任意身份的token包括管理员账号。我见过一个真实案例某系统密钥是123456攻方直接在GitHub历史提交里找到了这个密钥然后用它签了一个subadmin的token整个后台如入无人之境。密钥管理的底线要求生产密钥必须存放在配置中心或密钥管理服务如KMS、Vault密钥至少256位32字节不允许使用弱密钥环境隔离测试、预发、生产各用各的密钥定期轮换每3-6个月换一次配合刷新机制平滑过渡3.2 kid注入与算法混淆Header字段的攻防kidKey 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放localStorageXSS一打就全丢优先放内存配合刷新机制或者用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的选型决策表维度HS256RS256签名/验签密钥同一个对称密钥私钥签名公钥验签密钥分发所有服务共享一把密钥只分发公钥私钥单点保存性能快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也要失窃防范可以额外做“设备指纹绑定”记录设备IDRefresh Token只能在本设备使用和“检测到异常刷新立即吊销全部token”的策略。5.3 服务端状态化JWT也能实现主动失效对于需要精确控制的场景用户改密码、管理员封号、用户登出靠JWT本身做不到主动失效因为token是无状态的。但可以引入一层薄薄的黑名单机制服务端维护一份“已注销token IDjti”列表过期时间与token一致每次验签时先查黑名单命中的拒绝访问用户登出、修改密码时把当前token的jti加入黑名单这个方案牺牲了JWT的完全无状态但换来了可管控能力。黑名单可以放Rediskey用jtivalue随意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, ListString 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()); ListString 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之前在身份认证前先解析tokenRestAuthenticationEntryPoint返回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的jtiJWT 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的生命周期把过期、续签、注销的时序想明白再动手写代码。这样即使以后踩坑也不会是最低级的那一种。
返回列表