☰
JWT从原理到实战:登录签发、无感刷新与安全漏洞排查全攻略
2026/10/7 17:06:14 网站建设 项目流程

去年我给一个客户排查线上故障,登录接口在高峰期频繁超时,看了半天监控才发现问题不在数据库,也不在网关,而是业务代码里把整个用户对象塞进了JWT的payload,一个token膨胀到4KB,再加上每次请求都带着它到处转发,带宽和解析耗时全被拖垮了。这种问题本质上不是JWT的错,而是对JWT的定位理解偏了。JWT(JSON 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"], // 关键!限制算法,防alg=none攻击 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一律存用户数字ID,iss和aud按环境设置,jti用UUID,iat和exp交给库去生成。自定义字段统一加前缀,比如ver、tenant_id,避免以后字段多了管理混乱。

4. SPA项目实战:验证码、续签与无感刷新

前端项目里JWT的使用方式和传统服务端渲染完全不同,尤其是SPA,token的存储、携带、刷新都需要仔细设计。这一节我把三个高频问题一起讲清楚。

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,一个是短期的accessToken(15分钟到2小时),一个是长期的refreshToken(7天到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 alg=none攻击与算法混淆

这是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里携带一些用户资料,但又有隐私顾虑,那就用JWE(JSON 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相关的坑,也欢迎在评论里分享出来,大家一起把踩坑清单补全。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询