ARTICLE DETAIL

资讯详情

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

JWT认证实战:双Token续签、安全加固与漏洞排查

JWT认证实战:双Token续签、安全加固与漏洞排查 1. 先把JWT这个概念掰开揉碎第一次接触 JWT 的人八成都会经历这么一段文档里说它是无状态认证方案同事说就是把用户信息编码进 Token 里然后你打开浏览器一看登录接口确实返回了一长串eyJhbGciOi...开头的字符串看起来跟乱码一样。更让人困惑的是这串东西明明什么都没存到服务器为什么下次请求带上它后端就知道你是谁我当年接手第一个前后端分离项目的时候就是被这个问题卡了整整两天最后发现是我把Token和JWT当成了同一个东西。这篇文章我打算按真实项目落地的顺序来讲JWT 到底解决什么问题、它的三段结构分别装了什么、签名是怎么防止别人改数据的、签发和校验的代码怎么写、Token 快过期了怎么续签、SPA 项目里验证码和登录怎么串起来、以及那些年踩过的漏洞坑。不管你是刚学完 Express 或者 FastAPI 想做登录功能的新手还是已经上线过几个项目、想回头把认证这块加固一遍的老手下面这些内容应该都能直接拿去用。我尽量不写成说明书而是按为什么这么设计、我实际怎么写的、哪里容易翻车这个顺序展开。1.1 从一次用户明明登录了却提示未登录说起传统的登录方案是 Session。用户提交账号密码服务端校验通过后在内存或者 Redis 里生成一条记录比如session:abc123 - {userId: 42}然后把abc123这个随机串塞进 Cookie 返回给浏览器。之后浏览器每次请求自动带上 Cookie服务端拿这个串去查存储查到就认识你是谁查不到就返回未登录。这套东西用了很多年没什么大毛病但它在两种场景下会变得别扭。第一种是服务端要扩容。你部署了三台机器用户在 A 机器登录Session 存在 A 机器的内存里下一次请求被负载均衡打到 B 机器B 机器查不到这条 Session用户就被踢成未登录了。解决办法是把 Session 挪到 Redis 这类集中存储里多台机器共享。这确实能用但等于你的认证服务强依赖一个外部组件Redis 一挂所有人都登不进去。第二种是客户端形态变多了。除了浏览器还有小程序、移动 App、桌面客户端这些端对 Cookie 的支持参差不齐。小程序虽然有自己的存储但每次请求要手动带上凭证App 更是完全没有 Cookie 这套机制。你想让同一套认证逻辑同时服务这么多端Session Cookie 就得改造成 Session 自定义请求头改到最后你会发现你其实已经在自己实现一套 Token 机制了。JWT 的思路就是把这个存的动作从服务端挪到客户端。服务端不再保存你是谁而是把你是谁、什么时候签发的、什么时候过期这些信息写进一张纸条加盖一个只有服务端能验证的印章然后把纸条交给客户端保管。下次请求客户端把纸条递回来服务端只要验一下印章对不对、有没有过期就行了。整个过程服务端不需要存任何东西这就是无状态三个字的实际含义。当然天下没有白吃的午餐。信息放在客户端手里就意味着服务端失去了立刻让它失效的能力。这是 JWT 最大的软肋后面我专门用一节讲怎么用黑名单和双 Token 机制把这个坑补上。1.2 JWT的三段结构长什么样随便找一个 JWT 看看它长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. eyJzdWIiOiI0MiIsIm5hbWUiOiJ0b20iLCJpYXQiOjE3MTIzNDU2NzgsImV4cCI6MTcxMjM0NjU3OH0. TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ三个部分用点号隔开从左到右分别是Header头部、Payload载荷、Signature签名。前两段都是 Base64URL 编码的 JSON第三段是二进制签名转成的 Base64URL 字符串。Header 通常就两个字段{ alg: HS256, typ: JWT }alg声明签名用的算法typ声明这是一个 JWT。这里有个新手常犯的误解很多人以为alg是服务端用来判断该怎么验签的依据。实际上恰恰相反服务端必须提前固定好自己支持的算法不能听 Header 里的。原因在后面的漏洞章节细说总之先记住一句话alg是我打算用什么签不是你可以用什么验。Payload 就是你想传的数据官方给了一批注册字段也叫 Claim常用的有字段全称含义是否建议填issIssuer签发方多服务场景建议填subSubject主体一般放用户 ID强烈建议audAudience接收方前后端分离建议填expExpiration Time过期时间戳秒必须填nbfNot Before生效时间戳按需iatIssued At签发时间戳建议填jtiJWT ID令牌唯一 ID需要防重放时必填除了这些你可以随便加业务字段比如role、tenantId。但有一类东西绝对不能放密码、身份证号、手机号、银行卡号。原因很简单Payload 只是 Base64URL 编码不是加密。任何人把它粘到在线解码工具里或者本地跑一行代码就能看到明文。我见过有人把用户手机号放进 JWT前端调试的时候随手一贴整个团队的群聊里就出现了一串真实号码。Signature 是对前两段做签名得到的结果作用只有一个证明这张纸条是服务端发的且中间没被人改过。它不提供保密性这点一定要分清楚。1.3 Token和JWT不是一回事这是搜索热度很高的问题我用一句话说清Token 是凭证这个概念的统称JWT 是 Token 的一种具体实现格式就像车和SUV的关系。你登录后服务端返回的那个字符串可以叫它 Token。至于这个 Token 是什么形态有好几种做法不透明 TokenOpaque Token服务端随机生成一串a1b2c3d4...本身不含任何信息校验时只能去 Redis 或者数据库查。Session ID 本质上就是这个东西。JWT字符串自带信息校验时只需验签 看时间不用查存储。自研格式见过有人用userId:timestamp:md5拼一个字符串也算一种 Token只是缺少标准约束容易埋安全隐患。所以当有人问JWT 和 Token 哪个好这个问法本身就不成立。真正该问的是我要无状态还是要可控性JWT 的优点是省一次查询、天然支持多端缺点是签发之后到过期之前服务端管不住它。不透明 Token 恰好相反每次校验都要查一次存储但你想踢谁就踢谁。实际项目里最常见的折中是外层用 JWT 做短期凭证内层挂一个存在服务端的 refresh token 做长期凭证。这样既享受了 JWT 无状态校验带来的性能优势又保留了主动失效的能力。这套方案我在后面第 3 节会给出完整代码。2. JWT防篡改的底层原理JWT 如何防止数据被篡改是被问得最多的问题之一。很多人知道因为有签名但说不清签名的输入到底是什么、为什么改了 Payload 就会验不过。这一节我把计算过程拆开用 Python 和 Node 两套代码对照着看看完你应该能给同事讲明白。2.1 签名到底签了什么以最常用的 HS256 为例签名的计算过程分这么几步。第一步把 Header 和 Payload 各自做 Base64URL 编码。注意是 Base64URL不是普通 Base64区别在于把换成-、/换成_并且去掉末尾的填充。这么改是为了让结果能安全地放进 URL 和 HTTP 头里。第二步把两段编码结果用点号拼起来得到signingInput也就是base64url(header) . base64url(payload)。第三步对这个signingInput用密钥做 HMAC-SHA256 运算。HMAC 是一种带密钥的哈希算法它的特点是有密钥的人能算出结果没有密钥的人算不出来而且输入改一个比特输出就会面目全非。第四步把哈希结果做 Base64URL 编码得到第三段。你完全可以不用任何库手写一遍感受一下import base64 import hashlib import hmac import json def b64url(data: bytes) - str: return base64.urlsafe_b64encode(data).rstrip(b).decode() def manual_sign(): header {alg: HS256, typ: JWT} payload {sub: 42, name: tom, exp: 1712346578} secret bmy-secret-key # 注意JSON 不能有空格键的顺序要和签名时完全一致 h b64url(json.dumps(header, separators(,, :)).encode()) p b64url(json.dumps(payload, separators(,, :)).encode()) signing_input f{h}.{p}.encode() sig hmac.new(secret, signing_input, hashlib.sha256).digest() return f{h}.{p}.{b64url(sig)} print(manual_sign())这里有个容易踩的细节json.dumps默认会在逗号和冒号后面加空格而标准库和绝大多数 JWT 实现都是紧凑输出。你手写的时候如果没加separators(,, :)算出来的签名跟库算出来的对不上排查半天找不到原因。我第一次手写的时候就栽在这儿最后一行一行比对 Base64 才发现是空格的问题。2.2 为什么改一个字符就验不过假设有个攻击者拿到了 JWT想把 Payload 里的sub从42改成1冒充管理员。他需要怎么做他会先解码第二段改掉sub重新编码。但问题来了签名是对header.payload整体算的Payload 一变signingInput就变了签名自然也就对不上了。他想伪造新签名就必须知道密钥。而 HS256 的安全性完全建立在密钥只有服务端知道这个前提上。那如果攻击者不改 Payload只改 Header 声明alg: none呢这就是历史上非常出名的一个漏洞。早期一些库看到alg是none认为这是不需要签名的合法模式直接放行攻击者就可以随意伪造任意用户身份。修复方式很简单但在代码里极其容易被忽略// 错误写法不指定 algorithms库可能会信任 header 里的 alg jwt.verify(token, secret); // 正确写法白名单固定 jwt.verify(token, secret, { algorithms: [HS256], issuer: api.example.com });我之前在一个接手的老项目里就见过第一种写法当时用的是jsonwebtoken的旧版本测试的时候我手动构造了一个alg: none的 token真的通过了。那一瞬间后背是发凉的——线上跑的可是真实用户数据。还有一个更隐蔽的场景算法混淆攻击。如果服务端同时支持 RS256 和 HS256并且验签时根据 Header 里的alg动态选择密钥攻击者可以把alg改成 HS256然后拿服务端公开的 RSA 公钥当 HMAC 密钥去算签名。因为公钥是公开的攻击者能算出来服务端一验还真能过。防御办法同样是固定算法白名单绝不根据客户端输入切换验签方式。2.3 HS256 和 RS256 该怎么选HS256 是对称算法签名和验签用同一个密钥。RS256 是非对称算法私钥签名、公钥验签。选哪个取决于你的服务架构。对比项HS256RS256密钥类型一个共享密钥私钥 公钥签名速度快慢约慢一个数量级验签方是否需要密钥需要且就是签名密钥只需公钥适合场景单体服务、内部服务网关统一签发、多服务验签密钥泄露影响可伪造任意 token私钥泄露才可伪造判断标准就这么一条验签的一方需不需要具备签发能力如果只是单体应用签发和校验都在同一个进程里HS256 完全够用性能还更好。如果是网关签发、下游十几个微服务各自验签那必须用 RS256否则你就得把可以伪造 token 的密钥复制到十几台机器上任何一台被攻破都是全线崩溃。RS256 的密钥生成用 openssl 两行命令就行openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pemNode 里用 RS256 的写法const fs require(fs); const jwt require(jsonwebtoken); const privateKey fs.readFileSync(./private.pem); const publicKey fs.readFileSync(./public.pem); function sign(userId) { return jwt.sign({ sub: String(userId) }, privateKey, { algorithm: RS256, expiresIn: 15m, issuer: api.example.com, }); } function verify(token) { return jwt.verify(token, publicKey, { algorithms: [RS256], issuer: api.example.com, }); }注意verify里传的是公钥algorithms依然要显式指定。有些同学以为用了非对称算法就万事大吉结果漏了算法白名单照样能被混淆攻击打穿。3. 从零实现签发、校验、续签与验证码前面讲的是原理这一节直接上代码。我以 Node.js Express 为例因为前后端分离的 SPA 项目用这套组合最多。整套东西包括密钥管理、签发与校验、双 Token 续签、图形验证码最后给出路由挂载方式。代码我尽量写得能直接跑注释里会标出那些不写会出事的地方。3.1 依赖与密钥管理需要三个包npm install jsonwebtoken express cookie-parser ioredis npm install svg-captchasvg-captcha用来生成图形验证码纯 SVG 输出不需要额外装字体库比 Canvas 方案轻得多。密钥这块必须单独说。绝对不要把密钥硬编码在代码里然后提交到 Git。正确的做法是通过环境变量注入并且保证长度足够# 生成一个 64 字节的随机密钥 node -e console.log(require(crypto).randomBytes(64).toString(hex))得到类似9f3c...的 128 位十六进制字符串塞进.envJWT_ACCESS_SECRET9f3c... JWT_REFRESH_SECRET7a2b... REDIS_URLredis://127.0.0.1:6379为什么要 64 字节因为 HS256 的密钥空间越大暴力破解的成本越高。网上那些教程里写的secret、123456、mysecretkey用 hashcat 配合常见字典几秒钟就能跑出来。密钥一旦被爆破攻击者可以给任意用户 ID 签发 token你的整个认证体系就形同虚设。我建议 access 和 refresh 用两个不同的密钥这样即使其中一个泄露另一个还能兜底。jsonwebtoken在密钥短于 32 字节时会抛警告生产环境务必确保你的密钥远超这个下限。3.2 签发与校验的核心代码先写两个工具函数把签发和校验封装起来// auth/token.js const jwt require(jsonwebtoken); const crypto require(crypto); const ACCESS_SECRET process.env.JWT_ACCESS_SECRET; const REFRESH_SECRET process.env.JWT_REFRESH_SECRET; const ISSUER api.example.com; const AUDIENCE web-client; const ACCESS_TTL 15 * 60; // 15 分钟 const REFRESH_TTL 7 * 24 * 3600; // 7 天 function signAccessToken(userId, extra {}) { return jwt.sign( { sub: String(userId), token_use: access, jti: crypto.randomUUID(), ...extra, }, ACCESS_SECRET, { algorithm: HS256, expiresIn: ACCESS_TTL, issuer: ISSUER, audience: AUDIENCE, } ); } function signRefreshToken(userId) { return jwt.sign( { sub: String(userId), token_use: refresh, jti: crypto.randomUUID(), }, REFRESH_SECRET, { algorithm: HS256, expiresIn: REFRESH_TTL, issuer: ISSUER, audience: AUDIENCE, } ); } function verifyAccessToken(token) { const payload jwt.verify(token, ACCESS_SECRET, { algorithms: [HS256], issuer: ISSUER, audience: AUDIENCE, }); if (payload.token_use ! access) { throw new jwt.JsonWebTokenError(token 用途不匹配); } return payload; }几个细节值得展开。第一我加了一个自定义字段token_use取值为access或refresh。为什么因为如果不加区分攻击者可以拿 refresh token 当 access token 用。两种 token 用的密钥不同理论上验不过但万一哪天为了简化把密钥统一了这个字段就是最后一道防线。校验时显式比对用途是最省事也最稳妥的做法。第二jti用的是 UUID v4每次签发都是新的。这个字段在续签和登出环节至关重要它是我们识别这一个具体 token的唯一标识。第三expiresIn用的是字符串或者秒数jsonwebtoken会自动换算成exp时间戳。不要自己手算时间戳再塞进 payload因为服务器时间如果和标准时间有偏差你算出来的值可能当场就过期了。我见过一个项目因为服务器时区设置错误签发的 token 一生成就是过期的前端一直提示登录失效查了一下午才发现是date命令输出的时区不对。第四校验时必须传algorithms、issuer、audience三个参数。algorithms防算法混淆issuer和audience防跨服务重放——比如你的测试环境签发的 token 被拿到生产环境用如果两个环境的 issuer 不同就能挡住。3.3 双Token续签方案完整实现这是搜索热度最高的部分。核心问题是access token 如果设得太短用户老是掉线设得太长一旦泄露危害窗口就很大。双 Token 方案的解决思路是access token 短15 分钟负责日常请求refresh token 长7 天只用来换新的 access token且存在服务端可以随时作废。完整的续签流程是这样的用户登录成功服务端签发 access refresh把 refresh 的jti写进 Rediskey 为refresh:{jti}value 是 userId过期时间设为 7 天。前端把两个 token 都保存好存哪里后面单独说。每次请求带 access token。access token 过期后端返回 401并且响应体里带一个明确的错误码比如TOKEN_EXPIRED。前端拦截到这个码自动拿 refresh token 去调/auth/refresh。后端校验 refresh token 签名、检查 Redis 里refresh:{jti}是否存在。存在则先删除这条记录然后签发一对全新的 access refresh把新 refresh 的 jti 写进 Redis。这叫Refresh Token Rotation刷新令牌轮换。如果发现某个 jti 已经不存在于 Redis 但签名合法说明这个 refresh token 被重复使用了存在泄露风险。此时直接清空该用户所有的 refresh 记录强制重新登录。第 5、6 两步是这个方案的精髓我写成代码// auth/refresh.js const jwt require(jsonwebtoken); const redis require(./redis); const { signAccessToken, signRefreshToken } require(./token); const REFRESH_SECRET process.env.JWT_REFRESH_SECRET; async function refreshHandler(req, res) { const { refreshToken } req.body; if (!refreshToken) { return res.status(400).json({ code: REFRESH_MISSING }); } let payload; try { payload jwt.verify(refreshToken, REFRESH_SECRET, { algorithms: [HS256], issuer: api.example.com, audience: web-client, }); } catch (e) { return res.status(401).json({ code: REFRESH_INVALID }); } if (payload.token_use ! refresh) { return res.status(401).json({ code: REFRESH_INVALID }); } const userId payload.sub; const key refresh:${payload.jti}; const stored await redis.get(key); if (!stored) { // 签名合法但 Redis 里没有极可能是令牌重放 await redis.del(refresh:user:${userId}); return res.status(401).json({ code: REFRESH_REUSED }); } // 轮换删旧发新 await redis.del(key); const newAccess signAccessToken(userId); const newRefresh signRefreshToken(userId); const newPayload jwt.decode(newRefresh); await redis.set(refresh:${newPayload.jti}, userId, EX, 7 * 24 * 3600); await redis.sadd(refresh:user:${userId}, newPayload.jti); return res.json({ accessToken: newAccess, refreshToken: newRefresh, }); }这里还用了refresh:user:{userId}这个 set 来记录某个用户当前所有有效的 refresh jti方便一键踢下线。登出、改密码、检测到异常时遍历这个 set 把所有 jti 对应的 key 删掉用户的所有设备就都失效了。注意Redis 里存的 refresh 记录一定要设过期时间且要略大于 JWT 自身的有效期。如果 Redis 里的记录比 token 还早过期用户会遇到token 还没到期但续签失败的诡异情况。还有一个细节access token 的续签不要等到过期才做。可以在中间件里判断剩余时间如果小于 5 分钟就在响应头里带一个新的 access tokenfunction authMiddleware(req, res, next) { const token (req.headers.authorization || ).replace(Bearer , ); if (!token) return res.status(401).json({ code: TOKEN_MISSING }); try { const payload verifyAccessToken(token); req.user { id: payload.sub, jti: payload.jti }; // 剩余时间不足 5 分钟主动续一个 const remain payload.exp - Math.floor(Date.now() / 1000); if (remain 300) { res.setHeader(X-New-Access-Token, signAccessToken(payload.sub)); res.setHeader(Access-Control-Expose-Headers, X-New-Access-Token); } next(); } catch (e) { if (e.name TokenExpiredError) { return res.status(401).json({ code: TOKEN_EXPIRED }); } return res.status(401).json({ code: TOKEN_INVALID }); } }前端用 axios 拦截器接这个头悄悄把本地 token 换掉用户完全无感知。这种滑动续期配合自动刷新体验上基本感觉不到登录这回事。3.4 SPA里的图形验证码怎么接登录接口如果不加验证码就等于把密码爆破的大门敞开。SPA 项目里验证码的标准实现是后端生成图片 一个captchaId答案存 Redis前端提交时带上captchaId和用户输入的答案。// auth/captcha.js const svgCaptcha require(svg-captcha); const crypto require(crypto); const redis require(./redis); async function getCaptcha(req, res) { const captcha svgCaptcha.create({ size: 4, noise: 3, color: true, background: #f5f5f5, ignoreChars: 0o1ilI, }); const captchaId crypto.randomUUID(); // 答案统一转小写校验时也转小写避免大小写困扰 await redis.set(captcha:${captchaId}, captcha.text.toLowerCase(), EX, 300); res.type(svg); res.setHeader(X-Captcha-Id, captchaId); res.setHeader(Access-Control-Expose-Headers, X-Captcha-Id); res.send(captcha.data); } async function verifyCaptcha(captchaId, input) { if (!captchaId || !input) return false; const key captcha:${captchaId}; const answer await redis.get(key); // 无论对错都删除防止同一个验证码被反复尝试 await redis.del(key); if (!answer) return false; return answer String(input).toLowerCase(); }三个关键点ignoreChars去掉容易混淆的字符减少用户输错验证码有效期设 300 秒够用户看清再输入校验时无论对错必须删除否则攻击者可以拿同一个captchaId无限次撞答案验证码就白做了。登录接口里先验验证码再验密码顺序不能反。如果先验密码攻击者就能通过响应时间差异来判断账号是否存在。app.post(/auth/login, async (req, res) { const { username, password, captchaId, captcha } req.body; const ok await verifyCaptcha(captchaId, captcha); if (!ok) return res.status(400).json({ code: CAPTCHA_WRONG }); const user await findUser(username); // 统一错误信息不暴露账号是否存在 if (!user || !(await bcrypt.compare(password, user.passwordHash))) { return res.status(400).json({ code: AUTH_FAILED }); } const accessToken signAccessToken(user.id, { role: user.role }); const refreshToken signRefreshToken(user.id); const payload jwt.decode(refreshToken); await redis.set(refresh:${payload.jti}, user.id, EX, 7 * 24 * 3600); await redis.sadd(refresh:user:${user.id}, payload.jti); res.json({ accessToken, refreshToken }); });Token 存哪里也是个常见纠结。localStorage 简单但有 XSS 风险任何一段被注入的脚本都能读走httpOnly Cookie 对 JS 不可见但要额外处理 CSRF。我的实际做法是access token 放内存变量页面刷新就重新用 refresh 换refresh token 放 httpOnly Secure SameSiteLax 的 Cookie。这样即使有 XSS攻击者也拿不到长期凭证最多在页面存活期内盗用当前 access token。4. JWT漏洞总结与安全加固清单这一节是防御视角的整理。我把这些年见过的、以及在安全通告里高频出现的 JWT 问题归成几类每类都给出原理和修复方式。内容偏防御目的是让你上线前能对照着检查一遍。4.1 六类高频漏洞速查漏洞名称触发条件造成后果修复手段alg: none绕过验签时未固定算法任意伪造身份algorithms白名单算法混淆同时支持 HS/RS 且动态选算法用公钥伪造签名固定算法不信任 header弱密钥爆破密钥短、取自常见字典伪造任意 token64 字节随机密钥kid注入kid参与文件路径或 SQL读取任意文件、注入kid白名单映射敏感信息泄露Payload 放了隐私字段数据泄露Payload 只放 ID 和角色无法主动失效只依赖 exp被盗后长期可用jti 黑名单 短 TTLkid这个字段很多人不知道。它是 Header 里的可选字段用来指明用哪个密钥验签主要用在多密钥轮换场景。如果服务端把kid直接拼进文件路径去读密钥文件攻击者传一个../../etc/passwd之类的值就可能读到敏感文件如果拼进 SQL 查询就是注入。修复方式是把kid当成一个索引键去固定的映射表里查真实密钥绝不直接参与 IO。这个漏洞相对小众但一旦存在危害很大多密钥场景的项目要格外注意。另外还有一个被反复提及的点JWT 不提供任何保密性。有些人以为把敏感信息放进 Payload 就安全了实际上 Base64URL 解码是零成本的。我见过一个电商项目在 Payload 里放了用户的完整收货地址和手机号只要抓到一个 token 就全泄露了。正确的做法是 Payload 里只放sub这种无意义的 ID需要用到的用户信息在服务端根据 ID 查。4.2 加固代码与配置把上面这些修复手段落到代码里就是一份认证模块的加固模板// auth/secure.js const jwt require(jsonwebtoken); const ALLOWED_ALG [HS256]; function secureVerify(token, secret, expectedUse) { // 1. 限制 token 长度防止超长 payload 造成资源消耗 if (typeof token ! string || token.length 4096) { throw new Error(token 长度非法); } // 2. 手动检查 header提前拒绝可疑算法 const decoded jwt.decode(token, { complete: true }); if (!decoded || !ALLOWED_ALG.includes(decoded.header.alg)) { throw new Error(算法不被允许); } // 3. 若 header 里出现 kid必须是已知的安全值 if (decoded.header.kid !KNOWN_KEYS[decoded.header.kid]) { throw new Error(未知密钥标识); } // 4. 白名单验签 校验签发方和接收方 const payload jwt.verify(token, secret, { algorithms: ALLOWED_ALG, issuer: api.example.com, audience: web-client, clockTolerance: 5, }); // 5. 校验用途防止 refresh 当 access 用 if (expectedUse payload.token_use ! expectedUse) { throw new Error(token 用途不匹配); } return payload; }clockTolerance: 5是给集群环境留的时间容差。多台服务器之间时钟可能有几秒偏差没有容差的话A 机器刚签发的 token 到 B 机器可能被判成还没生效或者已过期。5 秒对绝大多数场景足够设太大反而会削弱exp的意义。登出接口也要实现用 jti 加入黑名单async function logoutHandler(req, res) { const payload req.user; // 中间件已解析 if (payload payload.jti payload.exp) { const ttl payload.exp - Math.floor(Date.now() / 1000); if (ttl 0) { await redis.set(blocklist:${payload.jti}, 1, EX, ttl); } } // 同时清掉该用户所有 refresh const jtis await redis.smembers(refresh:user:${payload.id}); if (jtis.length) { await redis.del(...jtis.map((j) refresh:${j})); await redis.del(refresh:user:${payload.id}); } res.json({ ok: true }); }黑名单的过期时间设为该 token 的剩余有效期这个细节很关键。因为 token 一旦自然过期就不再需要检查黑名单了设成永久会导致 Redis 无限膨胀。这样设计黑名单的大小天然被 token 的 TTL 控制住。中间件里检查黑名单const blocked await redis.get(blocklist:${payload.jti}); if (blocked) { return res.status(401).json({ code: TOKEN_REVOKED }); }这一查就打破了纯无状态但代价很小——一次 Redis 查询比查数据库快得多。要不要上黑名单取决于你的业务对秒级踢下线的需求有多强。后台管理系统、支付类业务建议加上普通的内容浏览类产品用短 access refresh 轮换其实已经够。提示黑名单方案里key 一定要带业务前缀比如blocklist:、refresh:避免和其他业务的 Redis key 撞车。我们团队曾经因为两套系统共用一个 Redis 实例、key 命名太随意导致测试环境的操作把生产环境的会话清掉了。4.3 上线前自查表把这份表打印出来逐条打勾能避掉绝大多数低级问题[ ] 密钥长度HS256 密钥不小于 32 字节实际建议 64 字节通过环境变量注入不在代码仓库中出现。[ ] 算法白名单所有verify调用都显式传algorithms不接受客户端指定的算法。[ ] 有效期设置access token 不超过 30 分钟refresh token 不超过 30 天两者都设置exp。[ ] 签发方校验iss和aud都填写并在验签时校验多环境用不同的 issuer。[ ] Payload 脱敏不含密码、手机号、身份证、地址等隐私字段。[ ] 传输安全只在 HTTPS 下传输Cookie 加Secure、HttpOnly、SameSite。[ ] 登出与踢人有黑名单或 refresh 清理机制改密码后全部会话失效。[ ] 错误信息不向客户端暴露签名错误密钥未找到等具体原因统一返回模糊错误码。[ ] 日志脱敏网关和应用的访问日志里不能完整打印 token。[ ] 依赖版本jsonwebtoken、pyjwt等库保持最新历史版本存在已知算法绕过问题。其中日志脱敏这条被忽略得最多。很多框架默认会把完整请求头打进日志包括Authorization。日志系统一旦被拖库等于所有活跃会话白送。建议在日志中间件里统一把Bearer xxx替换成Bearer [REDACTED]。5. 实战踩坑与排查手册理论讲完最后这部分是我这些年实际排查过的问题汇总。JWT 相关的问题有个特点报错信息往往很笼统invalid signature可能有一二十种原因只能靠经验和排查顺序一点点缩小范围。5.1 问题速查表现象可能原因排查动作一直提示 invalid signature密钥不一致 / 密钥编码方式不同打印密钥长度和哈希值比对两端刚签发就报过期服务器时间不同步执行date -u对比标准时间本地正常线上 401环境变量没注入 / 多环境 issuer 不同检查容器环境变量和 issuer 配置前端拿不到响应头里的新 token跨域未暴露自定义头设置Access-Control-Expose-Headers刷新接口偶发 REFRESH_REUSED前端并发请求同时触发刷新前端加刷新锁只允许一个刷新在飞Cookie 里带不上 tokenSameSite 策略 / 跨域子域检查SameSite、Domain、Secure中文放 Payload 后解码乱码编码时没按 UTF-8 处理确认编码链路别手工拼接移动端时间偏移导致过期客户端时间被用户修改过期判断只以服务端时间为准其中并发刷新导致 REFRESH_REUSED是双 Token 方案最常见的坑。用户打开页面五个接口同时返回 401前端五个请求同时去调刷新接口第一个成功并删掉了旧 jti后面四个带着旧 jti 的请求全部失败还触发了重放检测直接把用户踢下线。这个 bug 我在压力测试的时候才复现出来正常点击根本发现不了。解决办法是在前端加一个刷新锁let refreshing null; axios.interceptors.response.use(null, async (error) { const { config, response } error; if (response?.data?.code ! TOKEN_EXPIRED) return Promise.reject(error); if (!refreshing) { refreshing doRefresh().finally(() { refreshing null; }); } // 所有并发请求都等同一个刷新 Promise const newToken await refreshing; config.headers.Authorization Bearer ${newToken}; return axios(config); });核心思路是把刷新这个动作收敛成一个共享的 Promise谁先来谁创建后来的都await同一个。这样无论多少并发请求只会触发一次真正的刷新。5.2 我踩过的几个坑第一个是 Base64URL 和 Base64 混用。有个同事写了个小脚本从 token 里解析用户信息用的是普通 Base64 解码结果遇到含-或_的 token 就报错。正确的做法是把-换成、_换成/再补齐到 4 的倍数。这个错误在 Python 里也会出现base64.b64decode不认识 URL 安全字符。第二个是时间戳单位搞错。有些语言的时间戳是秒有些是毫秒。有一次对接某个 SDK它要求exp传毫秒我按秒传了结果算出来的过期时间是 1970 年token 一签发就是过期的。排查方式是先把exp解码出来人肉转换成年月日看一眼比盯着代码猜快得多。第三个是密钥里的换行符。用openssl生成的 PEM 文件末尾自带换行。如果直接读文件内容当密钥末尾那个\n会影响 HMAC 计算结果。本地测试和生产环境的文件生成方式稍有不同就会出现本地能跑线上502的情况。稳妥的做法是读完之后.trim()一下或者干脆转成单行的 Base64 存储。第四个是测试环境捏造的 token 漏到生产。有次做压测脚本里硬编码了一个测试账号的 token忘了删就提交了。虽然那是测试环境的密钥生产环境验不过但这件事提醒我测试和生产必须用完全不同的密钥并且生产环境的密钥要定期轮换。轮换的时候服务端要同时支持新旧两个密钥验签用kid来区分等旧 token 全部过期后再停用旧密钥。这个轮换流程最好写成文档否则半年后没人记得怎么操作。第五个是刷新接口没有限流。刷新接口本身不需要密码如果被人拿到一个有效 refresh token就能无限刷新下去。我在网关上加了一层限流同一个 userId 每分钟最多刷新 5 次超过直接封禁并要求重新登录。这个阈值不需要太精细挡住脚本级别的滥用就够了。第六个是 localStorage 里残留旧 token。用户改密码后服务端把所有 refresh 清掉了但前端 localStorage 里那串 access token 还在用户点任何页面都提示未登录又不知道去哪清。解决方式是在检测到TOKEN_REVOKED或者刷新失败时前端主动清空所有本地凭证并跳转登录页。这个逻辑要写在最外层的响应拦截里别指望每个页面自己处理。把这几个坑串起来看你会发现 JWT 的坑基本集中在三个地方密钥管理、时间处理、并发控制。原理层面它其实很简单就那么几步计算真正花时间的是工程细节。我的建议是如果你正在搭建认证模块先按第 3 节的代码把双 Token 和刷新锁实现出来然后对照第 4.3 节的自查表过一遍最后用压测工具模拟一下并发登录和并发刷新基本就能避开绝大多数问题。剩下的交给日志和监控慢慢发现就好。
返回列表