ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JWT认证从入门到安全落地:结构、续签、漏洞与SPA实践

JWT认证从入门到安全落地:结构、续签、漏洞与SPA实践 1. 从一个凌晨三点的告警说起JWT到底解决了谁的痛那是几年前的一个电商大促前夜我们的订单服务在扩容到第六个实例之后用户开始零星反馈刚登录就被踢下线。排查到天亮才发现认证服务还在用单机内存里的Session存登录态新扩容的实例压根不认识老实例发的会话ID。那次事故之后我们把认证方案整体换成了JWT也踩了后面我会一个个讲到的坑。JWT全称JSON Web Token翻译过来就是JSON格式的Web令牌。它本质上是一串用点号分隔的字符串长得像xxxxx.yyyyy.zzzzz这样。服务端用它来证明这个请求确实是某个已登录用户发来的而关键在于——验证这个过程不需要查数据库、不需要共享内存只要双方约定好一个密钥任何一台机器都能独立完成校验。这一点决定了它在多实例部署、跨服务调用、前后端分离这些场景里的天然优势。这篇文章适合三类人看正在做前后端分离项目、纠结登录态怎么存的后端同学被token过期了怎么办折磨过的全栈同学以及想搞清楚JWT到底安不安全、漏洞出在哪的架构同学。我会从结构拆解、密钥与算法选型、签发校验的完整代码、续签方案对比、防篡改加固、SPA落地这六个角度把我这些年踩过的坑和验证过的方案都摊开讲。全程给可运行的代码参数取值也给理由不做你先这么配就行这种糊弄。先说一个反直觉的结论JWT本身不是登录方案它只是一种带签名的数据载体。很多人把它当成Session的替代品直接套用结果做出一个既不能主动踢人、又不能防止重放的伪登录系统。理解它的能力边界比会调它的API重要得多。2. 拆开那三段字符串结构、签名与三个易混概念2.1 Header、Payload、Signature各自装了什么JWT的三个部分用点号隔开。第一部分是Header第二部分是Payload第三部分是Signature。Header是个JSON描述这个token用什么算法签的典型长这样{ alg: HS256, typ: JWT }Payload也是JSON装的是你要传递的业务数据官方管里面的字段叫声明claim。声明分三种预定义声明比如iss签发者、exp过期时间、sub主体、iat签发时间、公共声明自己定义但语义通用的比如role、私有声明业务专用的比如userId、tenantId。Signature是把前两段Base64URL编码后的字符串拼起来加上密钥用指定算法算出来的。以HS256为例公式大致是HMACSHA256(base64UrlEncode(header) . base64UrlEncode(payload), secret)这里有个新手最容易犯的误解Header和Payload只是Base64URL编码不是加密。Base64URL做的是把二进制转成URL安全的字符任何人都能解码还原出原文。所以Payload里塞手机号、身份证、密码这类敏感信息等于当街喊出来。2.2 编码、签名、加密三件事别混为一谈我见过不少同学把JWT是加密的当成常识面试时也这么说这就危险了。编码EncodingBase64URL 属于编码目的是让数据能安全地放进URL和HTTP头谁都能解没有任何保密性。签名Signing用密钥和算法生成Signature目的是防篡改——谁改了Payload签名就对不上。但它不隐藏内容。加密EncryptionJWEJSON Web Encryption才涉及加密能把Payload真正隐藏起来。日常业务里用JWS就是普通JWT的场景占绝大多数用JWE的很少。所以一个准确的表述是标准JWT保证的是完整性和来源可信不保证机密性。要机密性要么别往Payload里放敏感数据要么上JWE要么自己再对敏感字段做一层对称加密。2.3 签名到底怎么防篡改一次手工验算光说原理容易虚我们手工算一遍你就能彻底记住。假设Header是{alg:HS256,typ:JWT}Payload是{userId:1,exp:1735660800}。第一步各自Base64URL编码header - eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 payload - eyJ1c2VySWQiOjEsImV4cCI6MTczNTY2MDgwMH0第二步拼成header.payload用HMAC-SHA256加密钥算出签名再Base64URL编码Signature - 3Xk9... 一段44字符左右的串最终token就是三段拼起来。现在攻击者把Payload改成{userId:999,...}想把自己伪造成999号用户。他一改第二段变了但他没有密钥算不出对应的新签名。服务端拿着新前两段和旧签名一对HMAC结果不匹配直接拒绝。注意整个防篡改能力完全押在密钥保密上。密钥一旦泄露攻击者可以伪造任意用户的token。这就是为什么后面要专门讲密钥管理。从这个手工过程能看出一个关键点服务端校验时必须用原始字符串重新算签名不能先用库把token解析成对象、再自己拼回去算。因为解析再序列化字段顺序、空格、转义都可能变签名必然对不上。这是排查签名校验莫名失败时的经典陷阱后面第7节会再展开。3. 密钥、算法、过期时间配置里最容易埋雷的三处3.1 HS256还是RS256对称与非对称的取舍选算法是JWT配置的第一个岔路口。HS256是对称算法签发和校验用同一个密钥。特点是快、实现简单适合单体应用或者签发方和校验方是同一套系统的场景。RS256是非对称算法私钥签发、公钥校验。特点是校验方拿不到私钥也能验签适合多服务共用认证的场景——认证中心持私钥签发其他业务服务只拿公钥校验即使某个业务服务被攻破也伪造不出token。我把两者的对比列清楚方便你拍板维度HS256RS256密钥类型对称单密钥非对称公私钥对签发速度快慢一些约慢数倍校验速度快慢一些密钥分发风险高校验方也拿到签发密钥低校验方只拿公钥密钥长度建议≥32字节RSA建议2048位以上适用场景单体、内部服务多服务、第三方对接、开放平台我的经验是只要校验方不止一个或者校验方不完全可信就上RS256。别为了那点性能在安全上偷懒。如果确实用了HS256密钥长度必须够——HS256的密钥建议至少256位32字节别用secret123这种。判断标准很简单密钥的熵要远超暴力破解的成本。3.2 Payload里什么该放、什么绝对别放Payload的体积也要控制。因为token要在每个请求的Header里带上太大就会白白吃掉带宽——HTTP头通常有8KB限制JWT放在里面超了直接报错。该放的用户ID、角色、租户ID、会话标识jti、过期时间。这些字段体积小、校验时常用。不该放的密码、密码哈希一旦泄露直接完蛋。手机号、身份证、银行卡Base64能解等于明文。特别大的权限列表塞几百个权限码进去token体积失控而且权限一改旧token就过期了反而更麻烦。频繁变动的状态JWT是无状态的改了Payload里的数据已签发的旧token不会自动更新。放进来的动态数据只会让你更头疼。有个折中做法Payload里只放userId和role这类变化不频繁的字段权限细粒度控制放到服务端缓存里查。这样token小权限调整也能生效。代价是校验时多一次缓存查询但对绝大多数业务来说这点开销完全可以接受。3.3 密钥管理把secret从代码里赶出去我见过最离谱的一次是把JWT密钥硬编码在Git仓库的config.js里仓库还是公开的。那套系统的token等于任何人都能伪造。正确做法密钥放在环境变量或配置中心不进代码仓库。用.env也要确保.env在.gitignore里。不同环境用不同密钥。开发、测试、生产的密钥必须隔离绝不能让开发密钥能签发生产token。支持密钥轮换。密钥不是设一次就永远不变的要有轮换机制。JWT有个kidKey ID字段专门用来标识这个token是用哪个密钥签的。服务端维护一个kid - 密钥的映射轮换时新token用新kid旧token仍能用旧密钥校验平滑过渡。定期审计。谁动过密钥什么时候动的要有记录。给一个用kid的Header示例{ alg: RS256, typ: JWT, kid: 2024-11-key-01 }校验时先读kid从密钥库里取对应的公钥验签。轮换期可以配置成新密钥已生效但旧密钥仍可验签30天30天后旧token自然过期旧密钥就能下线了。4. 从零跑通签发与校验Node与Java两套代码4.1 环境准备与依赖选择Node侧用jsonwebtoken社区里用得最多API简单或jose更现代支持Promise和更多算法。Java侧用jjwtio.jsonwebtoken集成方便或 Nimbus JOSE JWT。安装npm install jsonwebtoken!-- Java: jjwt -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.5/version scoperuntime/scope /dependency为什么用jjwt而不是java-jwt两者都能用jjwt对Spring生态更友好文档也更全我在Spring项目里习惯用它。选型上其实差别不大关键是别自己手写Base64和HMAC除非你在学习。手写极容易在padding、字符集、Base64URL这些细节上出错。4.2 签发token把参数取值讲清楚Node版本const jwt require(jsonwebtoken); function signToken(user, secret) { const payload { userId: user.id, role: user.role, jti: require(crypto).randomUUID() }; return jwt.sign(payload, secret, { algorithm: HS256, expiresIn: 15m, issuer: auth.center, audience: web.api }); }这里几个参数值得说expiresIn: 15m有效期15分钟。取值逻辑是——access token要短因为一旦泄露攻击者能用的时间窗口就是这个有效期。我一般把access token设成15分钟到2小时越敏感的系统越短。issuer和audience签发者和受众。校验时一起验能防止这个token本来是A系统发的被拿到B系统用。跨系统场景必须配。jti唯一标识为后面主动踢人预留。Java版本import io.jsonwebtoken.Jwts; import java.security.Key; import java.util.Date; public String signToken(Long userId, String role, Key key) { long now System.currentTimeMillis(); return Jwts.builder() .subject(String.valueOf(userId)) .claim(role, role) .id(java.util.UUID.randomUUID().toString()) .issuer(auth.center) .audience().add(web.api).and() .issuedAt(new Date(now)) .expiration(new Date(now 15 * 60 * 1000)) .signWith(key) .compact(); }4.3 校验中间件里必须处理的异常分支校验不是对/错两个结果而是有一堆异常分支要分别处理。下面这个Node中间件把常见分支都覆盖了function authMiddleware(secret) { return (req, res, next) { const header req.headers.authorization || ; if (!header.startsWith(Bearer )) { return res.status(401).json({ code: NO_TOKEN }); } const token header.slice(7); try { const decoded jwt.verify(token, secret, { algorithms: [HS256], issuer: auth.center, audience: web.api }); req.user decoded; next(); } catch (err) { if (err.name TokenExpiredError) { return res.status(401).json({ code: TOKEN_EXPIRED }); } if (err.name JsonWebTokenError) { return res.status(401).json({ code: TOKEN_INVALID }); } return res.status(401).json({ code: TOKEN_ERROR }); } }; }两个细节我必须强调第一algorithms: [HS256]这个白名单是必须写的。如果不写库会信任token Header里的alg字段这就给了algnone攻击的可乘之机第5节展开讲。白名单等于把用哪个算法的决定权从攻击者手里夺回来。第二TokenExpiredError和JsonWebTokenError要分开返回。前者代表token是真的但过期了前端应该去走刷新流程后者代表token压根不合法前端应该跳登录页。混在一起返回前端没法区分用户体验会很差——过期了却要重新登录明明可以静默刷新。4.4 过期时间、时钟偏移与nbfexp过期时间和nbfnot before生效时间都是时间戳单位秒。这里有两个坑一个是时钟偏移。分布式系统里各机器时间不完全同步签发机器的exp到了校验机器可能还没到或者校验机器觉得已经过期。解决方案是在校验时留一点容差Node里叫clockTolerancejwt.verify(token, secret, { algorithms: [HS256], clockTolerance: 5 // 容忍5秒偏差 });另一个是别只依赖exp做业务时效控制。JWT的exp是绝对时间点如果你需要用户30分钟无操作才过期这种滑动过期JWT本身做不到得靠续签机制下一节讲。5. Token续签的三种方案别让用户每15分钟登录一次5.1 双Token方案access refresh这是目前最主流的方案。核心思路是拆成两种tokenaccess token有效期短15分钟~2小时每次请求都带用来访问业务接口。refresh token有效期长7天~30天只在刷新时用用来换新的access token。它不该在每个请求里传来传去。流程是这样的用户登录服务端同时下发access token和refresh token。前端正常请求带access token。access token过期接口返回TOKEN_EXPIRED。前端用refresh token调刷新接口换回一对新的token。刷新接口校验refresh token是否有效无效就要求重新登录。为什么这么设计因为access token频繁在网络上传输暴露面大所以有效期要短refresh token只在刷新时用暴露面小有效期可以长。两者配合既安全又不用频繁登录。refresh token的存储我强烈建议放HttpOnly Cookie而不是localStorage原因是JS读不到它能有效缓解XSS窃取。第6节详细说。5.2 滑动续期的并发坑同一时刻多个请求都去刷新双Token方案有个几乎必然会踩的坑并发刷新。场景是这样的用户在页面上打开了三个标签页或者一个页面同时发了5个请求。access token在同一时刻过期这5个请求同时收到TOKEN_EXPIRED于是5个刷新请求同时打向刷新接口。如果服务端对refresh token做了一次性使用刷新后旧refresh token立即失效这是安全最佳实践那么只有第一个刷新请求成功后面4个拿到的是refresh token已失效前端就会误判为登录失效把用户踢出去。这个坑我第一次遇到时排查了很久因为日志看起来就是refresh token莫名其妙失效了。解决方案有两个方向前端侧加刷新锁。用一个Promise作为锁第一个触发刷新的请求拿到锁去执行后面的请求都等待这个Promise的结果拿到新token后再重放原请求。let refreshPromise null; async function refreshToken() { if (refreshPromise) return refreshPromise; refreshPromise doRefresh().finally(() { refreshPromise null; }); return refreshPromise; }服务端侧宽限期。旧refresh token在刷新后不立即删而是打上一个已轮换标记保留一个短窗口比如30秒在这个窗口内用旧refresh token来刷新返回同一个新token对不报错。这样即使并发大家拿到的结果也一致。我倾向于两个都做前端加锁减少无效请求服务端加宽限期兜底。双保险。5.3 主动踢人与黑名单jti配RedisJWT是无状态的这是优点也是缺点。缺点就是——你没法主动让一个还没过期的token失效。用户改了密码、管理员封了账号、用户点了退出登录那个token在过期前依然能用。解决方案是引入一个有状态的补丁用jti Redis。签发时给每个token一个唯一jti同时往Redis写一条记录key是jtivalue可以是空或者一些元信息过期时间设成和token的exp一致。校验时除了验签名还要查Redis里jti是否存在。要踢人就把对应jti从Redis删掉。async function verifyWithBlacklist(token, secret, redis) { const decoded jwt.verify(token, secret, { algorithms: [HS256] }); const exists await redis.exists(jwt:${decoded.jti}); if (!exists) throw new Error(TOKEN_REVOKED); return decoded; }这么做相当于每个请求多一次Redis查询JWT的无状态优势被削弱了。所以要不要用取决于业务对能主动踢人没硬要求的比如内部工具可以不做靠短有效期兜底。对安全敏感的金融、后台管理必须做。Redis的这点开销换来的是可控性值。还有个更细粒度的做法不按单个token踢而是按用户维度维护一个token签发时间下限。用户改密码时记录一个passwordChangedAt时间戳校验时如果token的iat早于它就判定失效。这样一次操作能踢掉该用户所有旧token不用逐个删jti。适合改密码后全端下线这种需求。6. 让篡改无处可逃签名之外的加固清单6.1 algnone攻击把算法决定权夺回来这是JWT最经典的漏洞之一必须讲清楚。早期不少JWT库在校验时会读取token Header里的alg字段来决定用什么算法验签。攻击者就构造一个alg:none的token签名段留空。如果服务端的库支持none算法并且没做白名单校验那这个token就会被当成合法的、没有签名的token放行。攻击者可以随意伪造任何用户身份。防御其实就一句话校验时显式指定允许的算法白名单。jwt.verify(token, secret, { algorithms: [HS256] });Java侧同理jjwt较新的版本已经默认拒绝none但老版本要注意。另外还有一类变种攻击把RS256的token改成HS256然后用公钥当HS256的密钥去签名。因为服务端如果用的是RS256的公钥校验而校验逻辑又信任了token里的algHS256它就会拿公钥去当HMAC密钥验证——而公钥是公开的攻击者能拿到于是签名就对上了。同一个防御原则算法白名单写死绝不信任token自带的alg。6.2 敏感字段二次加密给Payload加一层保险标准JWT的Payload能被任何人解开这是前面反复强调的点。如果你确实需要传一些敏感标识又不想上JWE可以手动对敏感字段做一层对称加密再放进Payload。const crypto require(crypto); function encryptField(plain, key) { const iv crypto.randomBytes(12); const cipher crypto.createCipheriv(aes-256-gcm, key, iv); const enc Buffer.concat([cipher.update(plain, utf8), cipher.final()]); const tag cipher.getAuthTag(); return Buffer.concat([iv, tag, enc]).toString(base64url); }放出的是密文校验方用同一个密钥解密。这样即使token被截获敏感字段也不暴露。代价是Payload变大密文比明文长以及多一次加解密开销。用不用看你的敏感程度。6.3 常见JWT漏洞与修复对照表把常见的坑整理成表方便你对照自查漏洞成因危害修复algnone信任token自带alg未做白名单伪造任意身份校验时指定算法白名单算法混淆RS256转HS256用公钥当HMAC密钥验签伪造token白名单按kid取正确密钥类型弱密钥密钥太短或可猜测暴力破解后伪造密钥熵足够HS256≥32字节密钥硬编码secret写死在代码/仓库泄露即沦陷环境变量/配置中心轮换无过期时间未设exptoken永久有效必设expaccess短有效期Payload放敏感数据误解为加密信息泄露敏感字段加密或放服务端无Token撤销机制纯无状态无法踢人jtiRedis或iat下限7. SPA里落地JWT存储位置的抉择与验证码配合7.1 localStorage、sessionStorage还是HttpOnly Cookie前端分离项目最纠结的就是token存哪。三个选项各有优劣存储位置XSS风险CSRF风险跨标签页共享备注localStorage高JS能读低能最方便但最危险sessionStorage高JS能读低不能关闭标签页即清HttpOnly Cookie低JS读不到需防CSRF能需配SameSite很多人图省事把access token和refresh token都塞localStorage这其实是把安全押在我的代码没有XSS漏洞上——而现实中很难保证。我的实践方案是分离存储access token放内存变量比如Vuex/Pinia的state里页面刷新后丢失靠refresh token重新换取。JS能读到但它有效期短即使被XSS偷走危害有限。refresh token放HttpOnly CookieJS读不到专门用来刷新并且配SameSiteLax或Strict防CSRF。这样即使XSS发生攻击者能偷到access token15分钟就过期但偷不到refresh token没法长期维持访问。注意refresh token用Cookie一定要配Secure仅HTTPSHttpOnlySameSite。这三个属性一个都不能少缺一样防护就打折。7.2 图形验证码和JWT怎么配合登录接口是最容易被撞库和暴力破解的入口加图形验证码是标配。它和JWT的配合流程要理清楚前端请求验证码接口服务端生成一个图形验证码返回一张图片或base64和一个captchaId。服务端把captchaId - 验证码答案存进Redis设一个短过期比如2分钟。用户在登录页输入账号、密码、验证码一起提交带上captchaId。服务端先校验验证码用captchaId从Redis取出答案比对。无论对错立刻删除这条记录防止一个验证码被重复利用。验证码通过后再校验账号密码。都通过才签发JWT。这里的关键点是验证码必须先于密码校验且一次性使用。如果先校验密码攻击者能通过响应时间或错误信息判断密码对不对验证码就形同虚设了。另外登录失败次数要有计数超限就锁定该账号或该IP一段时间。// 伪代码示意 String cached redis.get(captcha: captchaId); redis.del(captcha: captchaId); // 立即删除一次性 if (cached null || !cached.equalsIgnoreCase(inputCaptcha)) { throw new BizException(验证码错误或已过期); } // 验证码通过再走密码校验和签发逻辑7.3 前端拦截器与401处理前端要统一处理token过期一般用axios拦截器axios.interceptors.response.use( res res, async err { const { response, config } err; if (response response.status 401) { const code response.data response.data.code; if (code TOKEN_EXPIRED !config._retry) { config._retry true; const newToken await refreshToken(); // 内部带并发锁 config.headers.Authorization Bearer ${newToken}; return axios(config); // 重放原请求 } if (code TOKEN_INVALID || code TOKEN_REVOKED) { redirectToLogin(); } } return Promise.reject(err); } );两个细节_retry标记防止无限重试重放请求时要把原始config带上否则请求体和参数会丢。我见过重放后变成GET请求的bug就是没带config导致的。8. 线上排查记录签名错误与过期错误怎么区分8.1 一条排查思路先看时间再看签名最后看密钥线上遇到JWT报错很多人第一反应是密钥配错了吧其实顺序反了。我总结的排查顺序是第一步看错误类型。库一般会区分签名错误SignatureException / JsonWebTokenError和过期错误ExpiredJwtException / TokenExpiredError。类型不同排查方向完全不同。第二步确认时间。过期错误先对时间——服务器时间准不准签发方和校验方时间差多少exp取值是不是太短很多莫名过期其实是服务器时钟漂移。第三步检查签名输入。签名错误的高频原因是对token做了二次处理。比如网关在转发前把token重新拆解又拼回去、或者URL解码时把Base64URL里的-_改了、或者把token存到某个地方时截断了。校验必须用客户端传来的原始字符串中途任何一次解析再序列化都可能破坏签名。第四步才是查密钥。确认验签用的密钥和签发时是不是同一个。特别是配置了kid的场景要确认取到的是对应kid的密钥而不是拿错了。8.2 常见报错对照表报错信息大概率原因处理方向SignatureException密钥不一致/中途改过token核对密钥与原始串ExpiredJwtException已过期/时钟偏差检查exp与服务器时间MalformedJwtException格式错误/被截断检查三段是否完整UnsupportedJwtException算法不支持/algnone配置算法白名单InvalidClaimExceptioniss/aud校验失败核对签发者与受众配置签名校验偶发失败多实例密钥不一致统一密钥源/配置中心最后这张表里最后一行我单独提一下。多实例部署时如果每个实例加载的密钥来源不一致比如有的读环境变量有的读本地文件就会出现在A实例校验通过、在B实例失败的诡异现象看起来像随机失败。统一密钥源是分布式场景下JWT最基础也最容易被忽略的一条纪律。密钥轮换期间如果你用了kid还要确保所有实例的密钥库都同步到位了再切流量否则正在轮换的那一刻会出现一批验签失败。这个经验是我在一次灰度发布时用真实的用户投诉换来的后来我们把密钥同步做成了配置中心推送切流量前先确认所有实例拉取成功。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进