☰
JWT原理与实战:从手写生成到安全避坑指南
2026/10/10 7:28:26 网站建设 项目流程

先说个可能有点反直觉的结论:JWT 学起来最难的部分其实不是 JWT 本身,而是你得先把“无状态鉴权”这件事想明白。我自己刚接触后端鉴权时,被一堆术语绕得晕头转向——Bearer Token、Session、Cookie、Refresh Token……每个名词单独看都懂,串起来就混。后来我把 JWT 官方文档从头到尾啃了一遍,又动手手写了一个生成和校验 JWT 的库,才算真正开了窍。这篇内容就围绕我啃 JWT、手写 JWT、最后把它用到项目里的完整过程展开,适合刚入行后端、写接口想做登录鉴权、以及已经用过 jsonwebtoken 但没搞懂底层逻辑的朋友。

这篇可以当学习笔记看,也可以当避坑手册查。核心关键词集中在 JWT、Token、签名、Payload、无状态鉴权这几件事上,我会把生成流程、校验流程、选型逻辑和踩过的坑全部铺开讲清楚,保证看完能自己动手写一个最小可用的 JWT,也能回答“JWT 到底安不安全”这种面试必问题。

1. 我为什么绕不开 JWT:从 Session 到 Token 的演变

1.1 Session 方案在多服务场景下的尴尬

早期做 Web 应用,大家用得最多的是 Session + Cookie。流程很简单:用户登录成功后,服务端把用户信息存在自己的内存里,生成一个 sessionId,通过 Set-Cookie 发给浏览器;浏览器下次请求自动带上 Cookie,服务端一查 sessionId 就知道是谁了。

单体应用这么玩没毛病,但一旦服务拆成多个,问题就来了。用户登录请求打到 A 服务,session 存在 A 服务的进程里,下一次请求被负载均衡转发到 B 服务,B 服务压根不认识这个 sessionId。要么做 Session 粘滞(sticky session),把同一用户的请求固定打到同一台机器;要么引入Redis 这类外部存储,让所有服务共享 Session 数据。前者在服务扩缩容时会出各种幺蛾子,后者等于给登录状态引入了一个中心化存储节点,既要处理高可用,又要面对网络开销。

我当时在某项目里就踩过这个坑:用一个能撑起单体的状态方案硬套微服务架构,结果每次请求都要查一次 Redis,高峰期 Redis 的压力反而成了瓶颈。后来才意识到,这种“服务端存状态、请求来查状态”的思路,本质上还是在做有状态鉴权,JWT 的出发点恰恰是想把状态从服务端挪走。

1.2 JWT 和随手生成的随机 Token 有什么区别

那有人会问,我不做 Session 了,登录成功以后随机生成一串 UUID 当 Token 返回给前端,之后每次请求前端带着这个 Token,服务端拿它在 Redis 里查用户信息,这不也是无状态吗?

严格说,这不叫无状态。因为服务端保存了 Token 和用户的对应关系,Token 只是“钥匙”,用户信息还是要回数据库或者缓存里取。真正无状态的意思是:服务端不保存任何会话数据,请求来了,它只靠请求自身携带的信息就能完成身份认证。JWT 就是这个思路的产物——它把用户身份信息和过期时间直接签成一段不可篡改的字符串,服务端拿到这段字符串,验签、看时间,就能确定“你是谁、有没有过期”,不需要查库,也不需要查缓存。

我习惯用一个比喻来理解 JWT:它像一张防伪通行证。通行证上直接印着持有人的姓名、部门、有效期,门卫不需要打电话回公司确认,只需要验证防伪标记和有效期,就能决定放不放行。随机 Token 则更像一把钥匙,门卫拿到钥匙得先去查这把钥匙能开哪扇门,查表的动作是少不了的。

1.3 一句话先建立直观印象

JWT 就是一段由三个部分组成的字符串,每个部分用点号分隔:Header.Payload.Signature。第一部分声明算法和类型,第二部分放用户身份信息,第三部分是签名,用来保证前两部分没被篡改。

你不需要把它想得太玄乎,完全可以把它理解成“带签名的 JSON 编码数据”。接下来的几个章节,我会把它拆开揉碎,先讲清楚三段结构,再手写一遍生成和校验的过程,最后重点说那些文档里不会明说、但实战里一定会踩的坑。

2. 拆开 JWT 的三段结构:Header、Payload、Signature 各管什么

2.1 Header:算法声明

JWT 的第一段叫 Header,实际是一个 JSON 对象,经过 Base64Url 编码后的结果。最常见的 JWT,Header 长这样:

{ "alg": "HS256", "typ": "JWT" }
  • alg是签名算法的缩写,常见值有HS256(对称密钥 HMAC-SHA256)、RS256(非对称 RSA-SHA256)、ES256(椭圆曲线签名)等。
  • typ是类型声明,固定写JWT,表示这是一个 JWT。

这段信息并不神秘,因为 Base64Url 是可以解码的。你去解任何一个 JWT 的第一段,看到的都是这些内容。有人听到“可解码”就慌了,觉得不安全。但要注意,可解码不等于可篡改,签名部分就是用来防止篡改的,这一点在 2.3 里我会重点讲。

2.2 Payload:三类 Claims

Payload 是 JWT 的核心信息区,同样是一段 Base64Url 编码的 JSON。我们在生成 Token 时想塞进 Token 里的用户信息,都放这里。

按照标准,Payload 里的字段叫 Claim(声明),分三类:

  • 注册声明(Registered Claims):预定义好的、有标准含义的字段。最常用的是exp(过期时间)、iat(签发时间)、sub(主题,通常填用户ID)、iss(签发者)、aud(受众)。这些字段名是 JWT 规范里固定好的,大家都认这个约定,方便跨系统协作。
  • 公开声明(Public Claims):为了防止和别人的字段冲突,规范建议大家加上 URL 前缀命名,比如"https://api.example.com/role": "admin"。实际上中小项目很少这么讲究,我们一般不用 URL 直接起名也能跑。
  • 私有声明(Private Claims):双方约定好的自定义字段,比如"userId": 123、"username": "zhang3"、"role": "editor"。

有一点我必须强调:Payload 只是编码,没有加密。谁拿到 JWT,都能直接解出这段 JSON,所以千万别把密码、身份证号、手机号、银行卡号这类敏感数据放进去。JWT 保证的是“信息不被篡改”,不是“信息不被看到”。我在项目里见过有人把用户手机号直接塞进 Payload 的,虽然系统是走 HTTPS,但 Token 可能会出现在日志、调试工具、前端 LocalStorage 里,任何一个环节泄漏,用户隐私就全暴露了。

2.3 Signature:真正的防伪关键

Signature 是 JWT 的第三段,也是整个 JWT 安全的基石。它和 Header、Payload 不同,不是简单编码出来的,而是通过签名算法对前两段内容计算生成的。

以 HS256 为例,签名的计算公式是:

HMACSHA256( base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secret )

也就是说,服务端会用自己保存的密钥(secret),对“Header 编码结果 + 点号 + Payload 编码结果”这串内容做 HMAC 计算,再把计算结果做 Base64Url 编码,这就是 Signature。

这句话背后的逻辑值得想清楚。任何人只要改了 Header 或 Payload 里的任意一个字符,哪怕只是把"exp"改大一天,重新用 Base64Url 编码后,整段待签名内容就变了。而攻击者不知道服务端的密钥,不可能重新生成一个合法的签名。服务端收到 Token 后,用同样的密钥对前两段内容重新计算签名,再和收到的 Signature 做比较,完全一致才代表这段 Token 是可信的、没被改过的。

一句话总结三段结构的分工:Header 告诉你怎么算,Payload 藏承载什么信息,Signature 保证前两段没人动过手脚。三者缺一不可,只有前两段没有 Signature,充其量就是一个能解码的 JSON,谁都能伪造。

3. 不依赖第三方库,手写一版最小可用的 JWT 生成与校验

3.1 为什么建议先手写一遍

我刚学 JWT 时,第一时间就去搜了 jsonwebtoken 这个库,看着文档里jwt.sign()和jwt.verify()的两个 API,自以为已经会了。直到有一天在真实项目里遇到一个问题:同一个 JWT,服务端时不时报签名校验失败,排查了半天也没头绪。后来才发现问题出在密钥配置上——测试环境和生产环境的密钥不一致,根本不是 JWT 本身的问题。

这件事给我一个教训:用库的时候,如果你能理解库底层做了什么,很多“莫名其妙”的错误一眼就能定位;如果只是黑盒调用,出了问题就只能瞎猜方向。所以我自己手写了一个最小的生成和校验函数,虽然不会用于生产环境,但对我理解 JWT 帮助极大。建议你也这么做一次,工程里当然可以继续用成熟的库,但原理必须亲手跑通。

3.2 生成流程与关键代码

JWT 的生成流程总共三步:

  1. 构造 Header 和 Payload 的 JSON,分别做 Base64Url 编码。
  2. 把编码后的 Header、点号、编码后的 Payload 拼起来,做 HMAC-SHA256 签名。
  3. 把签名结果做 Base64Url 编码,再拼接到第 2 步字符串的后面。

我用 Node.js 写了一个最小版本,不用任何第三方依赖,只靠 Node.js 内置的crypto模块。Base64Url 编码和标准 Base64 略有区别:标准 Base64 里的+和/在某些场景会引起歧义,所以 JWT 规定把它们换成-和_,同时去掉结尾的=填充符。

const crypto = require('crypto'); // 将普通字符串做 Base64Url 编码 function base64UrlEncode(input) { return Buffer.from(input) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } // 将 Buffer(比如签名结果)做 Base64Url 编码 function base64UrlEncodeBuffer(buffer) { return buffer .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } // 生成 JWT function createJWT(payload, secret, expiresInSeconds) { const header = { alg: 'HS256', typ: 'JWT' }; const now = Math.floor(Date.now() / 1000); const body = { ...payload, iat: now, // 签发时间,单位秒 exp: now + expiresInSeconds // 过期时间,单位秒 }; const encodedHeader = base64UrlEncode(JSON.stringify(header)); const encodedPayload = base64UrlEncode(JSON.stringify(body)); const signingInput = `${encodedHeader}.${encodedPayload}`; const signature = crypto .createHmac('sha256', secret) .update(signingInput) .digest(); const encodedSignature = base64UrlEncodeBuffer(signature); return `${signingInput}.${encodedSignature}`; } // 使用示例:有效期设为 2 小时 const token = createJWT( { userId: 123, role: 'admin' }, 'my-secret-key', 2 * 60 * 60 ); console.log(token);

这段代码跑起来,你就能看到一串典型的 JWT。把它粘贴到 jwt.io 这类调试工具里,左侧输入密钥,就能看到 Header 和 Payload 被解析出来的内容,和我们在第 2 节讲的完全对得上。

3.3 校验流程的完整逻辑

校验 JWT 的逻辑比生成稍微多一点,但本质就三步:拆段、验签、查过期。

  • 第一步,按点号把 JWT 拆成Header.Payload.Signature三部分,如果拆出来不是三段,直接判定非法。
  • 第二步,用服务端密钥对“第一段.”“第二段”拼接出来的字符串重新计算 HMAC-SHA256 签名,再和第三段做比较。这一步要特别注意,比较的时候最好使用常量时间比较函数,避免时序攻击。
  • 第三步,解码 Payload,检查exp字段是否大于当前时间,如果过期了,Token 直接失效。

Node.js 实现如下:

function verifyJWT(token, secret) { const parts = token.split('.'); if (parts.length !== 3) { throw new Error('JWT 格式不正确'); } const [encodedHeader, encodedPayload, encodedSignature] = parts; // 重新计算签名 const signingInput = `${encodedHeader}.${encodedPayload}`; const expectedSignature = crypto .createHmac('sha256', secret) .update(signingInput) .digest(); // 解码收到的签名,并与重新计算的签名做常量时间比较 const actualSignature = Buffer.from(encodedSignature, 'base64'); const valid = crypto.timingSafeEqual(actualSignature, expectedSignature); if (!valid) { throw new Error('签名校验失败,Token 可能被篡改'); } // 解码 Payload const payloadJson = Buffer.from(encodedPayload, 'base64').toString('utf8'); const payload = JSON.parse(payloadJson); // 检查过期时间 const nowInSeconds = Math.floor(Date.now() / 1000); if (payload.exp && payload.exp < nowInSeconds) { throw new Error('Token 已过期'); } return payload; } // 使用示例 const payload = verifyJWT(token, 'my-secret-key'); console.log(payload);

写完这段再回头看 jsonwebtoken 库的jwt.verify(token, secret),就知道它内部不过就是这几步。生产环境我不会自己实现 JWT,毕竟要考虑的边界情况太多——比如算法固定、异常处理、密钥轮换,成熟库都帮你兜底了,但有了手写经验之后,库报的任何错我都能映射到底层哪一步出了问题。

3.4 生产环境建议直接用的库方式

如果你做实际项目,Node.js 直接推荐jsonwebtoken。两行代码完成签发和校验:

const jwt = require('jsonwebtoken'); // 签发 const token = jwt.sign( { userId: 123, role: 'admin' }, 'my-secret-key', { expiresIn: '2h' } ); // 校验 const decoded = jwt.verify(token, 'my-secret-key', { algorithms: ['HS256'] });

Python 项目对应的是PyJWT:

import jwt token = jwt.encode( {"userId": 123, "role": "admin", "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=2)}, "my-secret-key", algorithm="HS256" ) payload = jwt.decode(token, "my-secret-key", algorithms=["HS256"])

注意verify里我额外传了algorithms参数,强制指定算法白名单,这是防止算法混淆攻击的手段,后面我还会详细说。

4. 从 Demo 到上线:JWT 必踩的四个坑与解法

4.1 过期时间与时间戳:毫秒还是秒?

这个坑可以说人人都会遇到。JWT 规范里exp和iat的取值是UTC 时间戳,单位是秒,但 JavaScript 里Date.now()返回的是毫秒,Python 的time.time()返回的也是浮点数秒。如果你直接把Date.now()塞进exp,那么你的 Token 过期时间是 1970 年,直接当场失效;反过来,如果你以为单位是毫秒,实际写入的是秒,Token 的有效期会被无限拉长。

我的建议是,所有 JWT 相关时间戳统一定义成“秒”:

const now = Math.floor(Date.now() / 1000); const expiresIn = 7200; // 2小时

过期时长怎么定?没有标准答案,完全看业务场景。典型的内部管理系统,2 到 4 小时比较合适,用户不会频繁重新登录;金融、支付、权限变更频繁的系统,15 到 30 分钟更稳妥,配合 Refresh Token 刷新体验也不会太差。关键原则是:过期时间越短越安全,但刷新机制必须配合到位。宁可设置短一点,再上 Refresh Token,也不要一上来就签发 7 天有效期的 Token,一旦泄漏,等于把账号访问权送出去一个星期。

4.2 密钥管理:最容易被忽略的隐蔽风险

HS256 使用对称密钥,签发和校验都用同一个 secret。这个 secret 一旦泄漏,任何人都能伪造任意身份的 JWT。这种“一把钥匙开所有锁”的模式,导致密钥管理成为 JWT 最大的风险点。

我见过不少团队把密钥直接写在配置文件里,然后配置文件传到 Git 仓库,这等于把密码贴在门上。实践里至少要做到:

  • secret 放环境变量或者密钥管理服务,比如云厂商的密钥管理产品,本地开发则用.env文件,务必加入.gitignore。
  • 定期轮换密钥,比如每 3 到 6 个月换一次。轮换时要考虑“旧 Token 还能不能验证”的问题。最简单的方式是生成密钥时带上版本号,把新旧密钥都放在验签路径上,旧密钥用来验证旧 Token,新密钥签发新 Token,等旧 Token 全部过期后再下线旧密钥。
  • 多环境密钥独立。测试环境、预发环境、生产环境的密钥必须不同,否则测试环境泄露就等于生产环境泄露。

如果服务端拆了微服务,还要考虑对称密钥在所有服务里分发同步的成本。这种情况下更适合 RS256:私钥只放在认证服务器上,其他业务服务只需要保存公钥用来验签。签发和验签能力分离,安全边界更清晰,公钥甚至可以公开,完全不怕泄露。

4.3 Token 注销难:JWT 的最大先天短板

JWT 是无状态的,这句话的另一面就是:Token 一旦签发,在过期之前几乎无法主动让它失效。用户改了密码,想知道之前发的所有 Token 立刻无效吗?做不到。管理员踢掉某个用户会话?也做不到。唯一的办法是等 Token 自己过期。

这不代表无解,实际项目里有几种可行的妥协方案:

  • 黑名单方案:服务端维护一个黑名单(通常放 Redis),存被注销 Token 的jti(JWT ID)或者 Token 哈希,校验时先查黑名单。缺点是引入了状态存储,失去了 JWT 完全无状态的意义,但能解决强制注销的问题。
  • Token 版本号方案:用户表里加一个token_version字段,签发 JWT 时把这个版本号写进 Payload。校验时查用户表对比版本号,不一致就拒绝。用户改密码或踢离线时把版本号加一,所有旧 Token 立刻失效。
  • 缩短有效期 + Refresh Token 方案:主 Token 有效期设短(比如 30 分钟),Refresh Token 有效期长(比如 7 天)且只用于换新 Token。Refresh Token 可以存在服务端做成可撤销的,每次换新的时候做一次校验。这样折中以后,主 Token 泄漏了危险时间窗口很小,Refresh Token 泄漏了服务端还能主动干掉它。

我在项目里综合下来最常用的是第三种。用户体验接近长期登录,安全可控性也远好于纯无状态方案。

4.4 Payload 体积膨胀:几十字节到几 KB 的失控

JWT 的每一部分都经过 Base64Url 编码,Payload 里每多一个字段,Token 整体就会变大。你可能觉得几百字节没什么,但如果把用户的权限列表、昵称、头像、部门全都塞进去,Token 轻松涨到 1KB 以上。每个接口请求都要带这个越来越大的 Authorization 头,流量开销成倍上涨,还会触碰网关的单请求头大小限制(很多网关默认限制在 8KB,有的甚至只有 1KB)。

另外,网络传输的字节数和 Base64 编码的膨胀系数有关,编码后体积大约是原始数据的 1.33 倍。你塞 100KB 的数据,实际传输就接近 133KB,再加上每个请求都带,这个成本非常可观。

我的原则很简单:JWT 只放“非它不可”的字段。用户 ID、角色、Token 版本号,这三个基本够了,其他任何数据都应当让服务端通过用户 ID 去查。权限信息如果频繁变化,放进 Token 里还容易造成“权限改了但 Token 没刷新”的尴尬,要么定时刷新,要么干脆不塞。

4.5 算法混淆攻击:为什么必须锁定 algorithms

这是很多教程不太会讲、但安全测试里很常见的攻击手法。JWT 的 Header 里alg字段是客户端可读可改的。如果服务端校验时直接信任 Header 里的alg,攻击者把算法改成none,然后删掉签名部分,某些实现不完善的库可能就会把这个没有签名的 JWT 当成合法 Token 放行。

另一个变体是:两种算法共用同一个密钥,比如签发用 RS256(私钥签名),校验方用公钥验签。攻击者把alg从 RS256 改成 HS256,然后用 RS256 的公钥当 HS256 的对称密钥来伪造签名。如果服务端没做算法白名单,它就会用公钥验签,结果攻击者成功伪造了管理员 Token。

防御手段就是在调用库的校验函数时,强制传入算法白名单:

const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] // 只允许 HS256,其他一律拒绝 });

PyJWT 同理,jwt.decode()时必须传algorithms=["HS256"],否则宽松模式下可能存在类似风险。这是一个一行代码就能堵住的漏洞,但容易被忽略。

5. 我现在实际项目里的 JWT 使用约定

5.1 写入 Payload 的铁律:只放必要,绝不放敏感

经过前面那些坑之后,我给自己定了一条硬性规则:每次往 JWT 里塞字段前,先问两个问题——这个字段是不是每个请求都要用的?如果这个字段被任何人看见,会不会有问题?两个条件必须同时满足,才允许放进去。

比如用户 ID 符合条件:每个接口鉴权后都要知道是谁,而且用户 ID 本身不是高度敏感的机密信息。角色角色名也算可以放,但如果权限粒度很细(资源级权限),就不要塞权限列表了,接口鉴权时动态查。用户名可以放,手机号、身份证号、邮箱、密码摘要这些绝对不要放。

记住前面说的:JWT 的 Payload 是可解码的明文。它叫“签名令牌”,不叫“加密令牌”。凡是不能让人看到的字段,一律不放;凡是体积大的字段,一律不放;凡是能查到的字段,一律不放。

5.2 一定要分清楚:JWT 是防篡改,不是防偷窥

我在网上经常看到有人说“JWT 是加密的,安全得很”,这是不对的。标准 JWT(JWS)只做签名,不做加密。服务端为了读 Payload 里的用户信息,本来就是设计成让对方能解码的,它防的是用户把userId改成别人的、把exp改到十年后。

如果你的业务场景真的要求 Token 里的内容不能被第三方看到,那就得用 JWE(JSON Web Encryption),把整个 Payload 加密,不持有解密密钥的人根本读不出内容。但 JWE 在常见 Web 鉴权场景里用得不多,因为服务端自己需要读 Payload 做业务判断,再做一次解密纯属增加负担。先想清楚你的需求到底是防篡改还是防偷窥,再选择方案,比盲目追求“更安全”要靠谱得多。

5.3 我最后沉淀下来的完整清单

我把自己项目里所有 JWT 相关的实践整理成了一张清单,每次新建服务都会过一遍:

  1. 传输层强制 HTTPS,JWT 不在 HTTP 明文里跑。
  2. 校验算法固定白名单,绝不信任 Header 里alg字段。
  3. HS256 密钥选至少 32 字节的随机字符串,放环境变量或密钥管理服务,不进代码库。
  4. 设置合理的exp,单位是秒,不设exp的 JWT 视为漏洞。
  5. Payload 只放用户 ID、角色、Token 版本号,绝不放敏感信息。
  6. 主 Token 有效期短,配合 Refresh Token 刷新,Refresh Token 必须可撤销。
  7. Token 发送走Authorization: Bearer <token>,不放 URL 参数,避免日志泄漏。
  8. 前端存 Token 优先用内存或适度的存储方案,避免 XSS 直接端走 Token。
  9. 验签失败要区分“过期”和“篡改”,日志里记录但不打印完整 Token。

这套约定不是我一天想出来的,是踩过坑、翻过车、线上出过问题之后慢慢沉淀的。现在每次看到别人的项目里 JWT 的配置,我基本扫一眼就知道哪里会有风险,哪里只是风格偏好。

这也是我写这篇文章的初衷——JWT 看着简单,真正用对门道不少。如果你正卡在“看着文档会用,离开文档发蒙”的阶段,强烈建议抽出半小时,照着第 3 节代码手写一遍。等你亲手生成第一个 JWT、亲手验签成功一次、亲手改一个字符看到校验失败,再回头看任何 JWT 相关的报错,都会觉得眼前一片敞亮。

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

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

立即咨询