ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JWT从原理到实战:登录签发、无感刷新与安全漏洞排查全攻略

JWT从原理到实战:登录签发、无感刷新与安全漏洞排查全攻略 去年我给一个客户排查线上故障登录接口在高峰期频繁超时看了半天监控才发现问题不在数据库也不在网关而是业务代码里把整个用户对象塞进了JWT的payload一个token膨胀到4KB再加上每次请求都带着它到处转发带宽和解析耗时全被拖垮了。这种问题本质上不是JWT的错而是对JWT的定位理解偏了。JWTJSON Web Token说到底是用来做认证信息传递和完整性校验的不是用来当万能存储袋的。这篇文章我打算把JWT从原理到落地、从登录签发到续签刷新、从SPA项目实战到安全漏洞排查完整捋一遍。内容会覆盖我在实际项目里踩过的坑、修过的bug以及那些99%的开发者都会遇上的经典问题。无论你是刚接触JWT的新手还是已经被线上token问题折磨过的老手这篇文章都值得花几分钟看完。1. 为什么是JWT认证方案的进化逻辑1.1 传统Session方案的痛点聊JWT之前先搞清楚它要替代的是什么。传统的Session认证流程是用户登录成功后服务端生成一个session id存到服务端内存或Redis里同时把这个id通过Cookie下发给浏览器。后续请求带着Cookie过来服务端查一下session id是否有效有效就放行。这个方案在单体应用时代非常成熟但在两个场景下会让人头疼。第一是分布式部署用户第一次登录落在A机器下一次请求被负载均衡转发到B机器B机器本地没有用户的session只能通过session粘滞或者把session集中存到Redis来解决架构上多了一层依赖。第二是前后端分离的趋势移动端App、小程序、SPA页面可能根本不支持或不方便操作Cookie顶着session id在请求头里手动传来传去稍不留神就出现鉴权失效的问题。1.2 JWT的核心设计理念无状态与自包含JWT的思路完全换了个方向。服务端不再保存用户的会话状态而是把用户身份信息比如用户ID、角色、过期时间经过签名后生成一个字符串发给客户端。客户端后续每次请求都带上这个字符串服务端只需要验证签名是否合法、过期时间是否已过就能确认请求者的身份。这就是所谓的无状态认证。服务端不需要查库、不需要查缓存一个签名校验就能完成鉴权天然适合水平扩容。JWT本身是自包含的所有需要的信息都在token里面减少了服务端的存储开销也让接口层可以做更细粒度的权限校验比如从token里直接读出角色字段判断能否访问某个接口。1.3 JWT不是银弹它的边界在哪里虽然JWT很香但把它当万能方案同样会踩坑。它适合认证场景、适合临时授权场景比如密码重置链接、邮箱验证但并不适合需要服务端实时控制用户状态的场景比如强制踢人下线、封禁用户、检测账号在别处登录。因为token一旦签发在过期之前服务端很难主动让它失效——除非引入额外的黑名单机制那就又回到了有状态的老路。理解了JWT的定位再来看它的内部结构就容易多了。2. 拆解JWT三段式结构Header、Payload与Signature你可以把JWT理解成一个三段式的字符串用点号分隔。长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c一眼看去是一堆乱码但它本质上是三段JSON经过编码和签名后的产物。搞清楚这三段分别是什么你在排查问题的时候就会有完全不同的思路。2.1 Header和Payload只是Base64Url编码的JSON第一段是Header包含签名算法和token类型。最常见的内容是{ alg: HS256, typ: JWT }第二段是Payload用来存放实际的业务数据也叫Claims。官方规定了一些标准字段比如sub主题通常存用户ID、iss签发者、aud受众、exp过期时间戳、iat签发时间戳、nbf生效时间戳、jti唯一标识。除了标准字段你完全可以放自定义数据比如role、tenantId。这里有个新手最容易忽略的认知盲区Header和Payload只是做了Base64Url编码没有加密。所谓Base64Url就是把标准Base64里的换成了-/换成了_去掉了末尾的。这意味着任何人拿到你的token都可以轻松解码出里面的明文信息不需要任何密钥。所以绝对不要往Payload里塞密码、手机号、身份证号这一类敏感数据。想验证并不难你随便打开一个JWT解析网站把token粘进去所有字段立刻原形毕露。2.2 Signature签名完整性的最后防线第三段是Signature它是整个token的安全核心。签名的计算过程可以概括为把前两段编码后的字符串用点号拼接起来再用Header里声明的算法和密钥进行签名。如果用的是HS256对称签名公式相当于HMACSHA256(base64UrlEncode(header) . base64UrlEncode(payload), secret)服务端校验时会拿同样的密钥重新计算一遍签名然后对比token携带的签名是否一致。只要密钥不泄露任何人都无法伪造token——因为你改动了Payload里的任何一个字节签名就会对不上。如果是RS256或ES256非对称签名则是用私钥签名、公钥验证。这在多服务间认证时很有优势认证中心持有私钥签发token各个业务服务只需要拿到公钥就能验证token无需共享同一个对称密钥。2.3 手撕一个真实JWT从字符串到明文信息我习惯在排查问题时手动解码JWT不用在线工具几行代码就能搞定。以Node.js为例const token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c; // 把token按点号拆成三段 const [header, payload, signature] token.split(.); // Base64Url解码 const decodedHeader JSON.parse(Buffer.from(header, base64url).toString(utf-8)); const decodedPayload JSON.parse(Buffer.from(payload, base64url).toString(utf-8)); console.log(Header:, decodedHeader); console.log(Payload:, decodedPayload);输出结果一目了然。这能帮你快速确认token里到底放了什么字段、过期时间是多少、算法声明是什么排查线上问题的时候特别管用。3. 登录签发与用户信息更新从生成Token到重新签发的完整落地理解了结构接下来看真实的业务代码。这一节我会用Node.js的jsonwebtoken库做示例它是最常用的JWT库之一Python的PyJWT、Java的jjwt思路完全一致照着迁移即可。3.1 登录接口如何签发JWT一个标准的登录签发逻辑是这样的校验用户名密码查询用户信息生成token返回给前端。关键代码const jwt require(jsonwebtoken); const SECRET_KEY process.env.JWT_SECRET; // 密钥必须从环境变量读取禁止硬编码 async function login(req, res) { const { username, password } req.body; // 1. 校验用户名密码这里省略查库逻辑 const user await findUserByUsername(username); if (!user || !comparePassword(password, user.passwordHash)) { return res.status(401).json({ message: 用户名或密码错误 }); } // 2. 构建payload const payload { sub: user.id, // 标准字段存用户ID role: user.role, // 自定义字段存角色 ver: user.tokenVersion, // 自定义字段token版本号用于踢人下线 }; // 3. 签发token有效期2小时 const token jwt.sign(payload, SECRET_KEY, { algorithm: HS256, expiresIn: 2h, issuer: my-app, audience: my-app-client, jwtid: crypto.randomUUID(), // jti保证每次签发的token唯一 }); // 4. 返回token和过期时间给前端 res.json({ token, expiresIn: 7200, }); }这里需要注意几点。expiresIn是jsonwebtoken库提供的便利写法底层会自动换算成时间戳写入exp字段。issuer和audience分别对应iss和aud标准字段。jti我建议每次都生成一个UUID后面做token吊销、记录审计日志时都用得上。3.2 用户信息更新旧Token失效与新Token签发这里要聊一个高频业务场景用户改了密码或者管理员修改了用户的角色、封禁了账号之前签发的token怎么办如果token继续有效那用户改了密码旧token依然能访问接口这是典型的安全漏洞。解决思路是在用户表上加一个token_version字段每次用户修改敏感信息时让这个字段自增然后JWT的payload里带上当前的版本号。服务端校验token时对比token里的ver和数据库里的token_version不一致就拒签。代码实现async function updatePassword(userId, newPassword) { // 1. 更新密码 await db.users.update( { passwordHash: hashPassword(newPassword) }, { where: { id: userId } } ); // 2. tokenVersion自增让所有旧token失效 await db.users.increment( { tokenVersion: 1 }, { where: { id: userId } } ); // 3. 读取最新的用户信息 const user await db.users.findByPk(userId); // 4. 重新签发新token返回给前端 const newToken jwt.sign( { sub: user.id, role: user.role, ver: user.tokenVersion, }, SECRET_KEY, { algorithm: HS256, expiresIn: 2h, issuer: my-app, audience: my-app-client, jwtid: crypto.randomUUID(), } ); return newToken; }这里的关键改动是第2步和第4步配合版本号一变所有已签发的token里的ver全部过时重新签发新token前端拿着新token继续访问体验上就是修改密码后自动登录状态依然有效但旧token已经作废。校验中间件里需要去查库拿tokenVersion吗我的做法是只有涉及敏感操作的接口才校验版本号普通读接口不需要。如果每个接口都查一次库无状态的优势就没了。3.3 校验中间件每个请求都要跑的代码必须稳中间件的核心逻辑是从请求头里取token校验签名和有效期解析出用户信息挂到请求对象上。完整示例function authMiddleware(secretKey, options {}) { return (req, res, next) { const authHeader req.headers.authorization; if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ message: 未提供认证令牌 }); } const token authHeader.split( )[1]; try { const decoded jwt.verify(token, secretKey, { algorithms: [HS256], // 关键限制算法防algnone攻击 issuer: my-app, audience: my-app-client, }); req.user { id: decoded.sub, role: decoded.role, tokenVersion: decoded.ver, jti: decoded.jti, }; next(); } catch (err) { if (err.name TokenExpiredError) { return res.status(401).json({ code: TOKEN_EXPIRED, message: 令牌已过期 }); } if (err.name JsonWebTokenError) { return res.status(401).json({ code: INVALID_TOKEN, message: 无效令牌 }); } return res.status(401).json({ message: 认证失败 }); } }; }注意algorithms这个配置必须明确指定允许的算法列表。否则攻击者可以把token的Header改成{alg:none}再用空签名伪造token某些库的旧版本会直接跳过签名校验这是非常经典的JWT漏洞。3.4 标准Claims的正确用法别把exp和iat搞混这些标准化字段各有各的职责我在代码评审里见过不少误用字段含义常见误用subtoken主体的唯一标识存用户ID有人存了用户的邮箱用户改邮箱后token语义就变了iss签发者用于标识token来源不同环境共用同一个iss排查来源时无法区分aud受众标识token给谁用多端共用token后台管理系统和App端拿着同一个token互通极危险iat签发时间有人用它当成最后活跃时间导致功能逻辑错乱exp过期时间有人用字符串格式时间标准要求必须是NumericDate时间戳nbf生效时间用于延时生效的场景比如预约操作大多数人不知道这个字段jtitoken唯一标识不生成导致无法做黑名单和审计我的建议是sub一律存用户数字IDiss和aud按环境设置jti用UUIDiat和exp交给库去生成。自定义字段统一加前缀比如ver、tenant_id避免以后字段多了管理混乱。4. SPA项目实战验证码、续签与无感刷新前端项目里JWT的使用方式和传统服务端渲染完全不同尤其是SPAtoken的存储、携带、刷新都需要仔细设计。这一节我把三个高频问题一起讲清楚。4.1 图形验证码与JWT的协同逻辑SPA登录页通常要加图形验证码防机器人暴力破解有人会问验证码的校验结果能不能也塞进JWT我的建议是不要把验证码本身塞进JWT。常规流程这样设计前端先请求验证码接口后端生成图片和对应的验证码ID比如UUID把验证码文本存到Redis里key是captcha:{id}有效期5分钟图片返回给前端。用户提交登录时带上验证码ID和用户输入的文本后端先校验验证码文本是否匹配匹配通过后再走用户名密码校验最后才签发JWT登录token。验证码ID本身可以和后续流程绑定比如在JWT的payload里加一个captchaId字段但这不是必须的。更干净的思路是把验证码ID作为一次性票据登录接口校验通过后删除Redis里对应的验证码记录保证验证码只能使用一次。这样即使攻击者截获了某次验证码也无法重放。// 验证码生成接口 app.get(/api/captcha, async (req, res) { const captchaId crypto.randomUUID(); const text generateCaptchaText(4); // 生成4位验证码文本 await redis.setEx(captcha:${captchaId}, 300, text.toLowerCase()); const image generateCaptchaImage(text); res.json({ captchaId, image: image.toDataURL() }); }); // 登录接口中的验证码校验 async function login(req, res) { const { captchaId, captchaText } req.body; const storedText await redis.get(captcha:${captchaId}); if (!storedText || storedText ! captchaText.toLowerCase()) { return res.status(400).json({ message: 验证码错误 }); } // 验证码只能用一次校验通过后立刻删除 await redis.del(captcha:${captchaId}); // 继续用户名密码校验和JWT签发... }这套逻辑在SPA里跑起来很顺畅验证码接口和登录接口独立JWT只管登录后的认证各司其职。4.2 Token续签的三种方案双Token、滑动过期和无感代理Token过期是SPA开发里最让人头疼的问题。用户用着用着突然token过期了跳登录页重新输密码体验非常割裂。业界主流的方案有三个方案一双Token机制。签发两个token一个是短期的accessToken15分钟到2小时一个是长期的refreshToken7天到30天。accessToken过期后前端用refreshToken去调用刷新接口换取新的accessToken。refreshToken通常存在HttpOnly Cookie里降低被XSS窃取的风险。方案二滑动过期策略。用户每次请求接口时如果token剩余有效时间低于某个阈值服务端就签发一个新token在响应头里通过自定义Header返回前端拦截器捕捉到后自动替换本地token。相当于用户只要在活跃token永远不过期。方案三无感代理刷新。把过期判断和刷新逻辑都封装在请求拦截器里后端发现accessToken过期就返回特定状态码前端拦截到后先调用刷新接口拿到新token后再重发原请求。用户完全感知不到token替换过程。三种方案各有优劣我重点讲最常见的双Token加拦截器组合。4.3 前端Axios拦截器如何实现一个401自动续签重发我用Vue或React项目里常见的Axios封装来演示这套代码在我的项目里已经稳定跑了很长时间import axios from axios; const request axios.create({ baseURL: /api, timeout: 10000, }); // 是否正在刷新token的标记 let isRefreshing false; // 刷新token期间排队等待重试的请求 let pendingQueue []; // 请求拦截器自动携带accessToken request.interceptors.request.use((config) { const token localStorage.getItem(accessToken); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器处理token过期 request.interceptors.response.use( (response) response, async (error) { const { response, config } error; // 只处理401且不是刷新接口本身的请求 if (response?.status 401 !config.url.includes(/auth/refresh)) { // 如果已经在刷新token就把当前请求排队 if (isRefreshing) { return new Promise((resolve) { pendingQueue.push({ config, resolve }); }); } isRefreshing true; try { // 调用刷新接口refreshToken存HttpOnly Cookie里浏览器自动携带 const refreshResponse await axios.post(/auth/refresh); const newToken refreshResponse.data.accessToken; // 更新本地accessToken localStorage.setItem(accessToken, newToken); // 把排队中的请求挨个重放 pendingQueue.forEach(({ config, resolve }) { config.headers.Authorization Bearer ${newToken}; resolve(request(config)); }); pendingQueue []; // 重放当前请求 config.headers.Authorization Bearer ${newToken}; return request(config); } catch (refreshError) { // 刷新失败清除登录态跳登录页 localStorage.removeItem(accessToken); window.location.href /login; return Promise.reject(refreshError); } finally { isRefreshing false; } } return Promise.reject(error); } );这段代码的核心价值在于并发多个请求同时遇到401时不会每个请求都触发一次刷新接口而是先刷新一次token再把排队的所有请求用新token重放。如果不加isRefreshing和pendingQueue这个排队机制并发场景下会请求十几次刷新接口把刷新接口打爆。服务端的刷新接口逻辑需要对refreshToken做校验检查它是否在黑名单里IPS或者说refreshToken本身也可以带上设备信息用于实现踢掉其他设备这样的功能。这部分逻辑可以结合用户表的token_version字段一起做刷新时顺带检查版本号。5. 那些年我们一起踩过的坑JWT安全漏洞与排错实录这一节是重点中的重点。JWT相关的安全事故太多了而且很多是开发者亲手埋的雷不是黑客的手段多高明。5.1 algnone攻击与算法混淆这是JWT最著名的高危漏洞属于OWASP Top 10里的典型认证绕过场景。攻击者把token的Header从{alg:HS256,typ:JWT}改成{alg:none,typ:JWT}然后把签名部分留空再篡改Payload里的sub等字段比如把自己伪装成管理员ID。某些JWT库的旧版本在alg为none的时候会直接跳过签名校验直接把攻击者伪造的token当成合法token放行。防御手段就是我在3.3里强调的服务端必须显式指定允许的算法列表绝对不能信任Header里的alg声明。// 错误依赖Header里的算法声明 jwt.verify(token, secretKey); // 正确显式锁定算法 jwt.verify(token, secretKey, { algorithms: [HS256] });还有一个更难发现的变种算法混淆攻击。如果服务端用RS256非对称算法签发token公钥是公开的攻击者如果知道服务端某处用同一个密钥做HS256对称校验他可以用公钥内容作为HS256的密钥来伪造token。因为很多不规范的实现里非对称校验的密钥是公钥字符串对称校验的密钥又被错误地配成了同一个公钥字符串攻击者拿公钥就能签出合法的HS256 token。解决方法是千万别在不同算法之间复用一个密钥非对称就用非对称对称就用对称校验时锁定算法列表。这是我在代码评审中最常强调的一条红线。5.2 Payload是明文敏感信息泄露事故很多人以为JWT签名了就等于加密了这是大错特错。签名只保证数据没有被篡改不保证数据不可见。我之前接到过一个事故排查公司的一个App接口返回的JWT里payload放了用户的手机号和邮箱被安全扫描发现后全部用户的手机号等于在网络上裸奔了一圈——只要抓到流量包Base64解一下全出来了。正确做法是payload只放非敏感的身份标识比如用户ID、角色、租户ID。如果非要在token里携带一些用户资料但又有隐私顾虑那就用JWEJSON Web Encryption对payload整体加密而不是用普通的JWS签名。当然绝大多数场景下没必要搞JWE多一次数据库查询换来的安全收益远比泄露几百万条手机号划算。5.3 回放攻击Token被截获后如何滥用JWT无状态是一把双刃剑。攻击者如果截获了一个有效token在token过期之前他可以拿着这个token一直使用因为服务端不存储会话状态无法判断当前请求的token是不是已经被人冒用了。缓解方案有几种。把token有效期缩短比如15分钟缩短被滥用窗口。加jti并配合Redis做短期缓存比如把已使用的jti放进Redis设置一个比token过期时间稍长的TTL服务端校验时发现jti已存在就拒绝。但这种做法会额外引入存储需要评估成本。对安全要求极高的系统直接用refreshToken加黑名单机制让用户被踢下线时refreshToken立即作废。5.4 时钟偏移、时区与exp边界token过期时间戳用的是Unix时间戳按理说不存在时区问题——1970年1月1日以来的秒数是全球统一的。但实际踩坑的场景是这样的服务器A和服务器B之间时间不同步签发token时用服务器A的时间校验时用服务器B的时间如果两台机器时钟漂移超过几十秒就会出现token还没过期就被判过期或者token已过期但还能通过校验的诡异现象。这个问题的应对方案有两个。一是用NTP同步所有服务器的时间这是最基本也是最重要的。二是在校验端增加一个允许的时钟偏移量比如clockTolerance: 30表示允许30秒的时间误差。jwt.verify(token, SECRET_KEY, { algorithms: [HS256], clockTolerance: 30, // 允许30秒时钟偏移 });该配置不是让你随便调大默认30秒足够调太大等于主动把token生命周期拉长。还有一个相关的日期坑exp字段必须是秒级时间戳但有些设计者喜欢把过期时间用ISO字符串格式存进去校验时直接报错。如果是从数据库读出来的时间字段先Math.floor(Date.now() / 1000)转成秒再放payload。5.5 密钥管理硬编码、泄露与轮换策略我见过最离谱的代码是const SECRET_KEY jwt-secret-123456;密钥硬编码在代码里commit推送到Git仓库第三方扫描工具几分钟就能抓到这个pattern。HS256是对称算法密钥泄露等于任何人都能签发合法token整个认证体系形同虚设。建议做法密钥至少256位随机字符串通过环境变量或密钥管理服务注入比如Vault、KMS。密钥必须定期轮换轮换时可以用过去密钥当前密钥双密钥校验模式旧token在旧密钥生命周期内还能验签通过新token统一用新密钥签发。// 支持多密钥校验 const SECRET_KEYS { current: process.env.JWT_SECRET_CURRENT, previous: process.env.JWT_SECRET_PREVIOUS, }; function verifyToken(token) { for (const key of Object.values(SECRET_KEYS)) { try { return jwt.verify(token, key, { algorithms: [HS256] }); } catch (err) { // 继续尝试下一个密钥 } } throw new Error(token验证失败); }这种方案在密钥轮换期间能做到不停机、不影响存量用户。轮换完成后可以把旧密钥从配置里移除安全性和平滑过渡两头都能兼顾。6. 一些写在最后的建议如果在读这篇文章之前你只是会调用jwt.sign和jwt.verify这两个方法那我建议你现在回到第2节把三段式的结构在脑子里拆一遍再对照第3节的中间件代码看看自己项目的实现有没有我提到的问题。如果项目里已经埋了雷我给你一个排雷的优先级先锁算法列表这是最高危的漏洞再查payload有没有敏感信息然后看密钥管理方式硬编码的赶紧换最后是续签方案别让用户的token一过期就被迫重新登录。我个人在使用JWT的过程中最大的体会是JWT的好用建立在正确使用的前提上它负责的是认证信息的传递和防篡改而不是存储敏感数据也不是帮你管理用户状态。搞清楚它的边界把它放在合适的位置上这个方案会非常顺手而用错了地方排查起来让人痛不欲生。希望这篇文章能帮你少走一些弯路。如果你在项目里遇到过其他JWT相关的坑也欢迎在评论里分享出来大家一起把踩坑清单补全。
RELATED READING

延伸阅读

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