尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JWT安全实战:从原理到Token续签的API认证完整指南

JWT安全实战:从原理到Token续签的API认证完整指南 做了几年API后端我见过太多“JWT跑通就以为安全了”的项目。登录接口会签token中间件会验token但一上线就出问题token被伪造、密钥硬编码在代码里、用户改密码后旧token还能用、多端登录互相踢下线……这些坑我基本都踩过。这篇文章针对“用户认证与授权使用JWT保护你的API”这件事从JWT原理、完整实现、安全加固、Token续签到实战排坑把关键细节一次讲透。适合刚接触JWT的后端开发也适合已经在项目里用了JWT、想系统查漏补缺的同学。1. JWT的核心机制先搞懂它再动手网上教程大多直接给代码很少讲清楚JWT内部到底怎么工作。结果就是出问题时不知道怎么排查。所以先从Token本身说起。1.1 把Token拆开Header、Payload、Signature三个部分一段标准JWT长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImFkbWluIiwiaWF0IjoxNzE4MDAwMDAwLCJleHAiOjE3MTgwODA0MDB9.BjSmBNSg3G5zJ9KxwL7bKQcQp4dHZcP7vlgVhG6bJ3E它是三段Base64URL编码的字符串用两个点分隔Header头部、Payload载荷、Signature签名。Header是描述元信息的JSON经过Base64URL编码之后就是第一段。内容大概长这样{ alg: HS256, typ: JWT }alg声明签名算法typ声明这是一个JWT。Payload是核心数据区存放claims声明。它也是JSON编码后的第二段{ sub: 1234567890, name: admin, iat: 1718000000, exp: 1718080400 }这里有几个标准claim要知道sub是主体标识通常是用户IDiat是签发时间exp是过期时间nbf表示在这个时间之前token不可用。业务自定义数据也可以放进来比如role、tenant_id但前提是这些数据不敏感。Signature是对前两段的签名。以HS256为例它的计算过程是HMACSHA256( base64UrlEncode(header) . base64UrlEncode(payload), secret )也就是说服务端校验token时会用自己持有的密钥重新算一遍签名如果算出来的结果和token里带的签名一致就认为token没有被篡改。这里有个基础但必须知道的知识点Base64URL是Base64的URL安全变体把换成-把/换成_并且去掉末尾的。有朋友在调试时直接拿标准Base64工具解码JWT结果解码失败就是因为忽略了这一点。1.2 签名算法怎么选HS256还是RS256算法选型是个容易被忽视的决策点它直接影响密钥管理方式。HS256是HMAC-SHA256对称签名。签发和校验用同一个密钥。优点是实现简单、性能好适合单体应用、内部服务。缺点是密钥不能泄露一旦泄露攻击者可以自己签发任意token。RS256是RSA-SHA256非对称签名。私钥负责签发公钥负责校验。好处是公钥可以放心分发到各个资源服务器或第三方客户端即使公钥泄露也只能校验不能伪造。缺点是密钥生成和管理相对复杂性能比HMAC稍差。ES256ECDSA-SHA256也是非对称签名签名长度更短适合受限环境但实现细节里有不少坑比如随机数重用导致私钥泄露的问题一般项目如果没有特殊需求不建议优先选。我个人的选择标准是这样的场景推荐算法原因单体API只有自己一个服务校验HS256简单性能好多微服务多个服务都要独立校验RS256公钥下发私钥不落地方便对外提供第三方接入的开放APIRS256或ES256客户端只需拿公钥验签不管选哪种算法白名单必须在校验时写死这个在安全部分会深入讲。1.3 为什么JWT能成为API认证的主流又有什么局限性传统Session认证是有状态的用户登录后服务端把session存到内存或Redis客户端拿session id每次请求带上服务端查一下有没有记录。JWT认证是无状态的服务端校验token只需要验签和检查过期时间不查数据库、不查缓存。这个特性带来的好处非常明显天然适合分布式服务任何一个节点都能独立校验token不用共享session存储。减少数据库查询认证过程本身不产生IO开销。API、移动端、小程序都能统一使用不受Cookie跨域限制。但局限性同样来自无状态。最典型的就是撤销困难token签发了就有效除非它过期。你要主动踢人、封号、改密码之后让旧token失效纯JWT做不到必须借助额外的存储手段。这个问题在后面讨论吊销机制时展开。还有一个反直觉的点JWT不能主动失效也不能覆盖已签发token的保密性。Payload只是Base64编码等于明文。所以不要往里面放密码、手机号、身份证号这类敏感信息。2. 完整落地从登录签发到接口鉴权的全链路实现原理看完了开始动手。这里用Node.js Express来演示其他语言如Python的FastAPI、Go、Java Spring Boot思路完全相同换一下库API即可。2.1 项目要准备哪些依赖结构怎么摆需要安装的核心库就两个npm install express jsonwebtoken管理配置用dotenv密码哈希用bcrypt如果需要把刷新令牌存到Redis再装ioredisnpm install dotenv bcrypt ioredis项目结构可以参考这个src/ ├── server.js ├── config.js ├── middleware/ │ └── auth.js ├── routes/ │ ├── auth.js │ └── users.js └── services/ └── userService.js规模不大的项目不用追求复杂目录但中间件和路由一定要从入口文件拆出来不然后期改鉴权逻辑会非常痛苦。2.2 登录接口签发访问令牌和刷新令牌登录接口的逻辑并不复杂但token怎么签有讲究。先看一个最小实现const jwt require(jsonwebtoken); require(dotenv).config(); function signAccessToken(user) { return jwt.sign( { sub: user.id, username: user.username, role: user.role, }, process.env.JWT_ACCESS_SECRET, { algorithm: HS256, expiresIn: 15m, } ); } function signRefreshToken(user) { return jwt.sign( { sub: user.id }, process.env.JWT_REFRESH_SECRET, { algorithm: HS256, expiresIn: 7d, } ); } async function login(req, res) { const { username, password } req.body; // 实际项目这里要查数据库 const user await findUserByUsername(username); if (!user || !(await verifyPassword(password, user.passwordHash))) { return res.status(401).json({ message: 用户名或密码错误 }); } const accessToken signAccessToken(user); const refreshToken signRefreshToken(user); res.json({ accessToken, refreshToken }); }这里有两个细节值得解释。第一访问令牌和刷新令牌用不同的密钥。JWT_ACCESS_SECRET管短期tokenJWT_REFRESH_SECRET管长期token。这样即使访问令牌的密钥泄露刷新令牌环节还保有独立防线处置时可以只轮换一个密钥。没有明确理由不建议两个token共用同一个密钥。第二访问令牌有效期设为15分钟刷新令牌设为7天。这不是拍脑袋而是安全与体验的平衡。访问令牌有效期短意味着即使它泄露攻击者的利用窗口被压缩到十几分钟内。刷新令牌有效期长但只用于一个固定接口而且后面会讲怎么让它可吊销。2.3 鉴权中间件大家都会写但细节决定生死鉴权中间件的任务很简单从请求里拿出token验证它有效把解析出来的用户信息挂到请求对象上。常见错误也集中在这一步。function authMiddleware(req, res, next) { const header req.headers.authorization; if (!header || !header.startsWith(Bearer )) { return res.status(401).json({ message: 未提供认证凭据 }); } const token header.slice(7); try { const payload jwt.verify(token, process.env.JWT_ACCESS_SECRET, { algorithms: [HS256], }); req.user payload; next(); } catch (err) { return res.status(401).json({ message: 令牌无效或已过期 }); } }几个重要细节algorithms: [HS256]必须显式声明。这是安全底线不是可选项。如果不写某些版本的jsonwebtoken会把Header里声明的alg当作可信输入给算法混淆攻击留下机会。后面安全章节会详细讲。用startsWith(Bearer )而不是split( )[1]。后者如果header里没有按规范带Bearer或者token本身前后有空格解析就会出错。统一格式处理可以让错误信息更可控。校验失败统一返回401不区分“签名错误”和“已过期”。有些初学者会分别返回错误码等于告诉攻击者token是因过期失效还是被篡改没必要暴露这种信息。中间件写好后业务路由这样挂即可router.get(/profile, authMiddleware, getProfile);2.4 权限控制角色、权限和scope的落地写法认证过了不等于授权通过。认证是证明“你是谁”授权是判断“你有没有权限做这件事”。JWT里的role、permissions、scope就是授权的数据来源。最简单的角色控制是在中间件之上再包一层function requireRole(...roles) { return function (req, res, next) { if (!req.user || !roles.includes(req.user.role)) { return res.status(403).json({ message: 权限不足 }); } next(); }; }用法router.post(/admin/cleanup, authMiddleware, requireRole(admin), cleanupHandler);如果系统权限维度更多建议引入scope令牌权限范围的概念。OAuth2里常见的是read:order、write:order这类粒度。相比只判断一个role字段scope能细到“这个token可以读订单但不能修改订单”。对于面向第三方开发者的开放平台scope是必须设计的。实际项目里权限判断可以做成中间件链也可以集中到一个can(user, action, resource)的函数里。中小项目手写就够不用为此引入重型框架。规模上来了再考虑casbin这类权限库。2.5 一张权限矩阵表把“谁能干什么”说清楚设计授权体系时权限矩阵能帮团队快速对齐预期。下面是一个订单系统的例子角色查看自己资料修改自己资料创建订单查看所有订单修改订单状态管理用户guest否否否否否否user是是是否否否operator是是是是是否admin是是是是是是这张表写完后代码里每个接口挂什么中间件就一目了然。比如查看所有订单对应requireRole(operator, admin)修改订单状态对应requireRole(operator, admin)管理用户只允许admin。文档维护这个矩阵代码实现落实这个矩阵权限就不会散落在各个接口逻辑里。3. JWT安全漏洞盘点为什么你的API会被白嫖JWT相关的安全漏洞在热搜词里反复出现是有原因的。很多项目跑通了流程但防线千疮百孔。这里把最危险的几个漏洞逐个拆开讲每个都是我见过真实案例的。3.1 algnone和算法混淆攻击者如何伪造任意身份algnone攻击的思路很直接攻击者把Header里的alg字段改成none删掉Signature部分然后伪装成合法token发给服务端。早期不少JWT库在解析时如果Header声明none就会跳过签名验证直接信任Payload里的内容。算法混淆攻击更难察觉。假设服务端用的是RS256公钥是公开的。攻击者把Header里的alg改成HS256然后用公开的RSA公钥当作HMAC的对称密钥给伪造的Payload签名。如果服务端校验时不限制算法而是跟着Header里的alg走就会尝试用JWT_ACCESS_SECRET作为密钥去执行HS256验签而这个密钥配置的恰好就是RSA公钥。结果就是攻击者用公开信息伪造了一个服务端承认的token。这两种攻击的共同根源是没有固定算法白名单。所以我在中间件里强调的algorithms: [HS256]就是用来堵这个洞的。服务端以自己配置的算法为准完全忽略Header里声明的alg。3.2 弱密钥你的密钥能不能从字典里被爆破出来HS256是对称签名密钥就是签名的唯一秘密。如果密钥太短、太简单攻击者拿到一个合法token完全可以在离线状态下跑字典攻击暴力试出密钥。市面上那些JWT破解工具本质就是先抓一个token然后拿字典里的候选密钥去算HMAC签名看哪个能匹配上。密钥的最低要求是32字节256位的随机数生成命令很直接openssl rand -base64 48同时一定要放在环境变量里而不是写死在代码里更不能提交进git仓库。还有一点经常被忽略密钥要用不同环境区分开。开发环境一个密钥、测试环境一个密钥、生产环境一个密钥不能同一个密钥走天下。我接手过一个项目开发环境密钥和生产环境完全一样等于内部人员随便签发生产token。3.3 payload不是加密敏感信息泄露现场这是JWT最容易犯的认知错误。JWT的Payload是Base64编码不是加密。谁拿到token用atob或任何Base64工具就能把内容原样读出来。我见过有人把用户手机号、邮箱、甚至MD5后的密码放进payload。这等于把敏感信息直接写在身份证的正面任何人捡到都能看。Payload里能放什么用户ID、角色、租户ID这类业务标识没问题。这些信息丢失不造成直接危害而且每次请求都要用。真正敏感的数据留在服务端接口按需查询返回。如果非要放进token就要考虑JWEJWT加密但为这点需求引入加密token通常并不值得。3.4 吊销能力的缺失账号被封了Token却还活着前面说了JWT无状态带来的最大软肋就是撤销难。账号被封禁、用户改密码、管理员踢人这些场景都要求“让旧token立即失效”。纯JWT做不到因为验签和过期检查根本不依赖服务端状态。业界常见的补法有以下几种黑名单方案在Redis里存被吊销token的jtiJWT ID或过期时间过期时间对齐token的exp。每次校验时先查一下Redis命中就拒绝。适合短期黑名单存储开销可控。用户版本号方案给用户表加一个token_version字段token里带上这个版本号。改密、封禁、踢人时把版本号加一校验时对比token里的版本号与数据库当前值是否一致。不一致就拒绝。这个方案的优点是精确吊销缺点是校验时要查一次数据库或缓存失去了一部分无状态优势。短期令牌刷新令牌方案访问令牌15分钟过期刷新令牌可吊销。管理员封号时直接吊销刷新令牌访问令牌撑死再活15分钟。这是我在大多数项目里推荐的做法安全性和体验兼顾。没有银弹具体选型取决于业务对“立即失效”的容忍度。4. Token续签设计让用户无感刷新又不牺牲安全“jwt实现token续签”这个热搜词反映了大多数后端开发的真实痛点。访问令牌设太短用户频繁重新登录设太长泄露风险直线上升。出路就是双Token方案。4.1 为什么访问令牌要短命泄露窗口与风险权衡访问令牌每次请求都会带出去。移动端请求可能经过多个代理前端有可能被XSS攻击token被截获的概率并不低。token的有效期越长被截获后的利用窗口越大。所以安全实践是把访问令牌控制在15分钟到2小时之间缩短泄露影响面。但用户不可能每15分钟登录一次。这就需要刷新令牌作为“后备通行证”。用户在最开始登录时拿到一个长生命周期的刷新令牌访问令牌过期后用刷新令牌换新的访问令牌全程对用户无感。4.2 刷新令牌的架构一次性的长命令牌怎么用刷新令牌有四个设计要点独立密钥签发绝不用访问令牌的密钥。携带jti并在Redis里存一份记录。这样吊销和轮换才有地方下手。一次性使用每次刷新时检查记录存在然后立即删除。换发新刷新令牌。这样可以及时发现刷新令牌是否被复用一旦攻击者和用户同时用同一个刷新令牌先到者成功后到者收到失败配合告警就能察觉异常。有效期要配合业务默认7天记住登录状态的可以30天安全要求高的可以缩短到24小时。刷新接口的实现可以这样写router.post(/refresh, async (req, res) { const { refreshToken } req.body; if (!refreshToken) { return res.status(401).json({ message: 缺少刷新令牌 }); } let payload; try { payload jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, { algorithms: [HS256], }); } catch (err) { return res.status(401).json({ message: 刷新令牌无效 }); } // 检查Redis里是否还有这条记录没有说明已被使用或吊销 const record await redis.get(refresh:${payload.jti}); if (!record) { return res.status(401).json({ message: 刷新令牌已失效 }); } // 删除旧记录换发新token await redis.del(refresh:${payload.jti}); const user await getUserById(payload.sub); const newAccessToken signAccessToken(user); const newRefreshToken signRefreshToken(user); res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken, }); });刷新的exp检查仍然靠JWT本身但吊销能力靠Redis。这套设计把无状态验签和有状态吊销结合起来了。4.3 双Token方案的前端配合拦截器和并发刷新后端设计得再好前端不配合也白搭。用户操作到一半token过期如果前端只是机械地跳登录页体验就废了。正确做法是在HTTP客户端里做统一拦截。前端在收到401时先判断是不是访问令牌过期然后调用刷新接口换新token用新token重放刚才失败的请求。但这里面有个高频问题用户开了多个标签页同时收到401同时发起刷新刷新令牌被并发请求反复使用导致后面几次刷新失败。解法是在前端维护一个全局的刷新Promise让并发请求共享同一个刷新操作let refreshPromise null; async function refreshAccessToken() { if (!refreshPromise) { refreshPromise axios .post(/auth/refresh, { refreshToken }) .then((res) { localStorage.setItem(accessToken, res.data.accessToken); localStorage.setItem(refreshToken, res.data.refreshToken); return res.data; }) .finally(() { refreshPromise null; }); } return refreshPromise; }这样无论同时有多少个401真正发出的刷新请求只有一个。这个细节很关键我就有同事因为没处理并发刷新线上经常出现“莫名其妙的掉线”投诉。4.4 刷新令牌被偷怎么办轮换和异常检测刷新令牌是长期凭证一旦泄露比访问令牌危险得多。除了前面说的一次性轮换机制还可以加两道检测设备指纹绑定刷新令牌签发时把设备的UA、IP、登录时间写入Redis。刷新时对比当前请求的设备信息发现IP段剧变、设备特征不符直接拒绝并告警。异地登录提醒同一账号短时间内从不同城市刷新系统提示用户并可选强制下线。这两步不需要做得很重但能显著提升账号安全性。我的原则是访问令牌防渗透刷新令牌防盗用。5. 实战排坑实录我在项目中遇到的那些JWT问题最后分享几个真实踩坑场景。这些不一定写在官方文档里但每个都导致过线上事故或用户投诉。5.1 过期时间与签发时间的“时差”陷阱JWT的exp和iat都是Unix时间戳秒本身没有时区问题。但我在一个跨时区项目里见过有人为了方便调试在签发时把过期时间写成了本地时间的字符串比如2025-06-01 12:00:00。结果是token在不同地域的服务器上校验时因为服务器时区不同过期判断相差了几个小时。用户一会儿被正常访问一会儿提示登录过期。解决很简单在签发和校验时都只依赖Unix时间戳不要手动拼接时间字符串。jsonwebtoken库的expiresIn参数和verify方法已经把时间处理封装好了业务代码不要绕过去自己写时间逻辑。5.2 多端登录、互踢、设备管理的实现思路纯JWT做多端登录默认情况是多个设备各自持有token互不影响。但很多业务需要“同一账号只能在一台设备登录”或“用户可以在设备列表里主动踢掉某台设备”。我的做法是设计一个session概念每个设备登录后在Redis里创建一个会话记录key是session:{userId}:{deviceId}value是对应的token版本号。token里带上device_id和session_version校验时做三次匹配校验签名和过期时间检查Redis里该设备的session记录是否存在比对token里的session_version和Redis里存的是否一致。用户踢掉某台设备就是把对应的session记录删掉或版本号加一。这个方案比单纯改用户级版本号灵活不同设备互不干扰。5.3 日志里的Token泄露事故有一次我排查线上问题翻服务端日志发现请求日志里完整打印了Authorization头。管理员为了调试方便加的一行日志把用户的token明文写进了日志文件。日志会进ELKELK可能有多人查看权限token一旦泄露攻击者就能冒充用户。这类教训的结论很简单任何日志都不要打印完整token和密码。调试需要时可以只记token的前四位和后四位用于关联请求但不要输出完整串。5.4 上线前的安全自查清单每次JWT相关功能做完我都会拿着这张表过一遍推荐直接抄[ ] 密钥是否用随机数生成长度至少32字节并且按环境隔离[ ] 校验时是否显式声明算法白名单[ ] 访问令牌有效期是否在15分钟到2小时之间[ ] 刷新令牌是否独立密钥签发[ ] 刷新令牌是否实现了一次性轮换和Redis存储[ ] Payload是否只包含用户ID、角色等非敏感信息[ ] 是否实现了封号、改密后的token吊销机制[ ] 登录接口是否做失败次数限制和验证码防护[ ] 前端是否处理了401并发刷新[ ] 日志是否避免输出完整token和敏感信息这套清单是我在项目里一次次被坑之后总结出来的。JWT的学习曲线不长原理也不难但到了生产环境决定它能不能安全扛住线上流量的往往就是这些细节。你可以照着把项目里现有的认证逻辑梳理一遍大概率能在安全上发现一两个需要补的缺口。
返回列表