JWT 学习笔记:结构、原理与常见安全风险
前言
在 Web 开发的学习过程中,用户认证是我最早接触的功能之一。最开始做项目时,用的都是 Session 方案,觉得“登录成功后服务端存一份数据,客户端拿一个 session_id,大家认这个 id 就行”——这个逻辑很简单,也很直观。
后来项目从单体拆成了微服务,问题就来了。服务 A 的 Session,服务 B 读不到;服务扩容时,用户刚在服务器 1 登录,下一个请求就被负载均衡转发到了服务器 2,然后被告知“未登录”。为了解决这个问题,我引入了 Redis 做共享 Session,算是勉强撑住了。
直到某个项目被要求“完全不依赖外部存储做认证”时,我才开始认真了解 JWT。用了一段时间后,我逐渐理解了它的结构设计逻辑,也踩过一些坑——比如忘记校验过期时间导致令牌永久有效,以及早期把用户密码放在 Payload 里的“低级错误”。
这篇文章是我对认证方案学习过程的一个梳理,从 Session 的困境出发,延伸到 JWT 的结构原理,再到常见安全风险和实践建议,希望对正在思考认证方案的读者有一些参考价值。
第一部分:从 Session 到 JWT——为什么要关注 JWT
1.1 Session 认证的工作方式
基于 Session 的认证流程,早期项目中基本都是这个模式:
- 用户提交用户名和密码。
- 服务端验证通过后,在服务器内存中创建一个 Session 对象,存放用户信息(如用户 ID、角色)。
- 服务端生成一个唯一的
session_id,通过 Set-Cookie 写入浏览器。 - 浏览器后续的每次请求,都会在 Cookie 中自动携带这个
session_id。 - 服务端根据
session_id找到对应的 Session 数据,识别当前用户。
这个流程很成熟,标准库和框架都提供了完善的支持。在单体应用、用户量不大的场景下,它运行得相当稳定。
1.2 分布式场景下面临的问题
随着业务增长,我开始尝试将一个单体应用拆分为多个微服务,Session 方案逐渐暴露出一些需要解决的问题:
问题一:服务间 Session 无法共享
拆分为多个服务后,用户可能在服务 A 完成登录,但下一个请求被路由到服务 B。服务 B 的内存中没有这份 Session 数据,于是认为用户未登录。
问题二:水平扩展时 Session 同步困难
当单个服务实例需要扩容时,新启动的实例没有存量用户的 Session 数据。传统解决方案是引入 Redis 等外部存储作为共享 Session 中心,所有服务实例都从同一个 Redis 读取 Session。但这也带来了额外的依赖和维护成本,以及每次请求都需要访问 Redis 的网络开销。
问题三:跨域场景下的 Cookie 限制
在前后端分离的架构中,前端应用和后端 API 可能部署在不同的域名下。默认情况下,Cookie 不能跨域发送,需要额外配置 CORS 和 Cookie 的 SameSite 属性。虽然可以解决,但配置稍显繁琐。
问题四:服务端存储压力
当在线用户数量较多时,内存中保存的 Session 数据会占用相当数量的服务器资源。即使用 Redis,也需要为大规模 Session 数据预留足够的存储和带宽。
1.3 JWT 是什么:核心概念
JWT 全称是 JSON Web Token,是一种用于在网络应用之间安全传递信息的令牌格式。它最核心的特点是 自包含 和 紧凑:JWT 本身包含了所有必要的用户身份信息(比如用户 ID、角色等),并且体积很小,适合在 URL 参数、HTTP Header 等场景中传输。
在认证场景中,JWT 的工作方式可以概括为:服务端验证用户身份后,生成一个 JWT 发给客户端;客户端后续请求时携带这个 JWT;服务端通过验证 JWT 的签名来确认其真实性和完整性,从而识别用户身份。整个过程服务端不需要存储会话状态,因此 JWT 是一种 无状态 的认证方案。
1.4 JWT 解决了 Session 的哪些问题
对应问题一与问题二:无需共享存储
JWT 将所有用户信息编码在令牌本身,服务端只需验证签名即可确认身份。无论请求被路由到哪个服务实例,只要该实例能拿到签名密钥,就能独立完成验证。不需要 Redis,不需要共享 Session 存储,水平扩展天然支持。
对应问题三:跨域友好
JWT 通过 HTTP Header 的 Authorization 字段传递,不依赖 Cookie。跨域场景下只需在服务端配置允许 Authorization 头即可,比 Cookie 跨域配置更为直接。
对应问题四:服务端无存储压力
服务端不保存任何会话数据,认证所需的信息都包含在 JWT 中。无论用户量多大,服务端都不需要为存储 Session 预留内存或 Redis 空间。
1.5 从 Session 到 JWT:核心变化总结
| 对比维度 | Session | JWT |
|---|---|---|
| 状态管理 | 服务端存储 Session,有状态 | 客户端存储令牌,服务端无状态 |
| 扩展性 | 需要共享存储(Redis 等)支持水平扩展 | 天然支持水平扩展,无需共享存储 |
| 存储压力 | 服务端承担 Session 存储开销 | 服务端无存储压力 |
| 令牌大小 | 仅一个短 ID,体积小 | 包含完整信息,体积较大 |
| 即时失效 | 支持(删除 Session 即可) | 不支持(有效期前无法撤销) |
| 跨域支持 | 需额外配置 Cookie 跨域 | 通过 Header 传递,跨域友好 |
第二部分:JWT 的结构拆解
一个完整的 JWT 由三个部分组成,用 . 分隔:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
这三个部分分别是 Header、Payload 和 Signature。
2.1 Header(头部)
Header 是一个 JSON 对象,描述令牌的元数据:
{"alg": "HS256","typ": "JWT"
}
alg:签名算法,最常见的是 HS256(HMAC-SHA256)和 RS256(RSA-SHA256)typ:令牌类型,固定为 JWT
这个 JSON 经过 Base64Url 编码后,就是 JWT 的第一部分。
2.2 Payload(载荷)
Payload 是 JWT 的主体,包含了需要传递的声明(Claims)。声明分为三类:
注册声明:一组预定义的推荐字段,虽然不是强制要求,但建议使用以提高互操作性。
| 声明 | 全称 | 含义 |
|---|---|---|
iss |
Issuer | 签发者 |
sub |
Subject | 主题,通常是用户 ID 或唯一标识 |
aud |
Audience | 受众,标识 JWT 的目标接收方 |
exp |
Expiration Time | 过期时间(Unix 时间戳) |
nbf |
Not Before | 生效时间 |
iat |
Issued At | 签发时间 |
jti |
JWT ID | 唯一标识,用于防重放 |
公共声明:为避免命名冲突,建议使用 IANA JSON Web Token Registry 注册的字段,或使用包含命名空间的标识符。
私有声明:业务自定义字段,比如角色、权限等级等。
值得特别留意的一点是:Payload 中的数据仅仅是 Base64Url 编码,并非加密。这意味着任何人都可以解码读取内容,因此 Payload 中不应该存放密码、密钥等敏感信息。
2.3 Signature(签名)
签名是 JWT 安全性的核心。它的生成方式为:
Signature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret
)
简单理解:服务端把 Header 和 Payload 的 Base64Url 编码用 . 拼起来,再用密钥通过指定算法生成一个签名。验证时,服务端用同样的方式重新计算签名,然后与 JWT 携带的签名比对,如果一致,说明令牌没有被篡改过。
第三部分:JWT 的完整工作流程
3.1 签发与验证流程
- 用户登录:客户端将用户名、密码提交给认证服务。
- 身份验证:认证服务验证用户名和密码是否匹配。
- 签发 JWT:验证通过后,构建 Header 和 Payload,用密钥生成签名,将三部分拼接成完整的 JWT 返回给客户端。
- 客户端存储:客户端收到 JWT 后,存储在 localStorage、sessionStorage 或 Cookie 中。
- 请求携带:客户端在后续 API 请求的 HTTP Header 中添加
Authorization: Bearer <JWT>。 - 服务端验证:服务端收到请求后,从 Header 中提取 JWT,验证签名是否有效,检查
exp是否过期,确认iss和aud是否匹配。验证通过后,从 Payload 中读取用户信息,处理业务逻辑。
3.2 关于存储方式的考虑
JWT 存储在客户端,选择不同的存储方式有不同的安全考量:
| 存储方式 | 优点 | 风险 | 建议 |
|---|---|---|---|
| localStorage | 简单,持久化存储 | 易受 XSS 攻击,攻击者可通过注入脚本读取令牌 | 适用于不含敏感信息的场景,或配合 CSP 策略 |
| sessionStorage | 页面关闭即清除,降低泄露窗口 | 同样存在 XSS 风险 | 对敏感度一般的应用较为合适 |
| HttpOnly Cookie | 无法通过 JavaScript 读取,有效防御 XSS 攻击 | 受 CSRF 攻击影响,需额外防御 | 推荐方式,需配合 SameSite 和 CSRF Token |
3.3 关于令牌撤回的思考
JWT 是无状态的,这带来一个固有难题:一旦签发,在有效期之前无法主动撤回。即使管理员封禁了某个用户,只要该用户持有的 JWT 仍在有效期内,依然可以被用于访问系统。
针对这个问题,常见的处理方式有:
- 短有效期:将
exp设置为较短的时间(如 15-30 分钟),缩小令牌被恶意利用的时间窗口 - 配合 Refresh Token:用长期有效的 Refresh Token 换取短期有效的 Access Token,服务端可以撤销 Refresh Token
- 黑名单机制:在服务端维护一个被撤销的 JWT 列表(如 Redis 缓存),验证时先检查是否在黑名单中
- 版本号方案:在用户表中维护一个令牌版本号,每次签发 JWT 时包含该版本,修改密码或封禁用户时增加版本号,验证时检查版本是否匹配
第四部分:常见安全风险与避坑指南
4.1 alg=none 攻击
原理:JWT 规范允许将 Header 中的 alg 字段设为 none,表示“无签名”。如果服务端在验证时没有检查 alg 字段,攻击者可以构造一个 alg=none 的 JWT,删除签名部分,服务端仍然会将其视为有效令牌。
防范方法:在代码中明确指定允许的算法列表(如仅允许 HS256 和 RS256),如果收到 alg=none 或非白名单算法,直接拒绝。
4.2 密钥混淆攻击
原理:当服务端同时支持 HS256(对称加密)和 RS256(非对称加密)时,攻击者可以获取服务端的 RSA 公钥(公钥本身是公开的),然后用公钥作为 HS256 的密钥来签名一个 JWT。如果服务端验证时使用 RS256 的公钥但以 HS256 的方式处理,就有可能误以为这个 JWT 是合法签发的。
防范方法:固定算法列表,不同算法严格区分;在验证时根据 alg 字段选择对应的验证逻辑,HS256 用密钥,RS256 用公钥,不混用。
4.3 未校验 exp 字段
原理:部分 JWT 库在解码时默认不检查 exp 声明,需要开发者显式调用校验方法。如果开发者忘记调用,JWT 将永久有效,后果比较严重。
防范方法:验证 JWT 时,务必显式调用校验过期时间的方法。在 Python 的 PyJWT 库中,可以通过 options={"verify_exp": True} 启用,或调用 decode 时不传入 verify_exp=False。
4.4 弱密钥
原理:HS256 依赖一个共享密钥来签名和验证。如果密钥强度不足,攻击者可以通过暴力破解或字典攻击获得密钥,从而伪造任意 JWT。
防范方法:使用密码学安全的随机数生成器产生密钥,密钥长度至少 32 字节(256 位),定期轮转密钥。
4.5 敏感信息泄露
原理:JWT 的 Payload 仅经过 Base64Url 编码,没有加密。任何拿到 JWT 的人都可以解码并读取 Payload 中的内容。如果开发者将密码、手机号、身份证号等敏感信息放入 Payload,就会造成信息泄露。
防范方法:Payload 中只存放必要的非敏感信息(如用户 ID、用户名、角色),敏感信息通过服务端查询获取,不放入 JWT。
4.6 重放攻击
原理:JWT 一旦签发,在有效期内可以被多次重复使用。如果攻击者截获了一个 JWT,就可以在令牌有效期内冒充用户发送请求。
防范方法:设置较短的过期时间(如 15 分钟),使用 jti(JWT ID)声明配合服务端缓存,记录已使用过的 jti,防止同一令牌被重复使用;始终使用 HTTPS 防止令牌在传输中被截获。
4.7 安全实践总结
| 实践 | 说明 |
|---|---|
| 使用 HTTPS | 防止令牌在传输过程中被中间人截获 |
| 严格控制算法 | 限制 alg 字段为白名单算法,拒绝 none |
| 校验过期时间 | 务必显式调用 exp 校验,不依赖库默认行为 |
| 强密钥 | 至少 32 字节,使用安全随机数生成 |
| 最小化 Payload | 只存必要信息,不存敏感数据 |
| 短有效期 + Refresh Token | Access Token 短有效,Refresh Token 可撤销 |
| 安全存储 | 优先使用 HttpOnly Cookie 存储 JWT |
结语
从 Session 到 JWT,本质上是在“有状态”和“无状态”之间做权衡。Session 方案把状态放在服务端,提供了更好的控制力;JWT 方案把状态交给客户端,换来了更好的扩展性。
在实际项目中,这两种方案并不是非此即彼的对立关系。有些项目会同时使用两者——比如用 Session 管理 Web 应用的登录状态,用 JWT 作为 API 访问令牌。关键在于根据业务需求和技术约束,选择合适的方案。