也说 JWT

网上关于 JWT(JSON Web Token)的技术文章汗牛充栋,但大部分教程都在重复着同一种看似完美的叙事:

“传统的 Session 需要服务器保存状态,集群部署时需要做 Session 共享;而 JWT 最大的优势就是服务端完全无状态,所有数据加密存放在客户端,服务器无需查库,仅凭密码学签名即可完成鉴权,横向扩容极度丝滑……”

当年初学后端时,我也曾被这种“完全无状态”的美好愿景所吸引。然而在真正做过企业级鉴权中心与 OAuth2.0 网关之后,我得出了一个完全相反的结论:

在真实的生产业务中,追求“绝对无状态”的 JWT 几乎是一个灾难;服务端如果对 Token 彻底失去状态感知,系统将处于巨大的安全失控状态。

本文深入聊聊为什么我认为“服务端必须掌握状态”,以及 JWT 在工业界中的真实落地姿势。


一、JWT 到底是什么?

根据 RFC 7519 官方规范,JWT 是一种开放标准,定义了一种紧凑且**自包含(Self-Contained)**的信息传输格式。

一个标准的 JWT 由三部分组成,通过英文句点 . 拼接:

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsInJvbGUiOiJhZG1pbiJ9.d8sF9...
└─────────────────┬─────────────────┘ └──────────────────────┬──────────────────────┘ └──────────┬──────────┘
1. Header 2. Payload 3. Signature

1. Header(头部)

声明令牌类型和所使用的签名算法(如 {"alg": "HS256", "typ": "JWT"})。

2. Payload(载荷)

存放实际业务数据(Claims),例如用户 ID、角色以及内置字段(如过期时间 exp)。

⚠️ 避坑认知: Payload 只是经过了 Base64Url 编码,而不是加密! 任何人在浏览器抓包或拿到字符串后,只需点一下 atob() 就能明文看光所有字段。因此,绝对严禁在 Payload 里存放用户密码、手机号身份证等隐私敏感信息。

3. Signature(防篡改签名)

1
2
3
4
Signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)

签名是利用服务端保管的私钥(secret_key),对前两部分做哈希计算得出的校验码。如果黑客在客户端偷偷把 Payload 里的 role: "user" 改成了 admin,只要他不知道私钥,算出来的签名就无法匹配,服务端便能立刻识别出数据被篡改。


二、致命的“无状态悖论”:为什么服务端必须掌握状态?

很多文章宣称“有了 JWT,服务端就可以把 Redis 和 Session 删了”。我们不妨推演以下几个在任何商业系统里都必须处理的场景:

场景 1:用户主动点击“退出登录”

  • 签发给客户端的 JWT 有效期为 2 小时;
  • 用户在第 10 分钟点击了“退出登录”,前端清除了 LocalStorage 里的 Token;
  • 致命漏洞:如果该 Token 在退出前被恶意脚本窃取、或被员工自己从控制台复制出来,在接下来的 1 小时 50 分钟内,持有该 Token 的任何人依然可以畅通无阻地调用你的所有 API!
  • 因为对于无状态的 JWT 来说,签发即放行,只要没到 exp 设定时间,签发者自己都无法提前注销它。

场景 2:修改密码 / 强制踢人下线

  • 用户发现账号被异地盗用,紧急修改了密码;
  • 此时黑客手里的旧 Token 依然合法有效,照样能拿着旧 Token 继续转账或下载私密资料,直到自然过期;
  • 管理员发现某账号是内鬼,在后台将其“封禁(Disable)”,但该内鬼在 Token 过期前依然拥有管理员权限。

场景 3:权限动态变更延迟

  • 某普通员工晋升为主管,后台授予了审批权限;但由于旧 Token 中固化了 role: "user",他必须先登出重新登录生成新 Token;
  • 反之,降权更是灾难:撤销了某人的权限,旧 Token 里的特权却依然有效。

三、无状态与强管控的博弈

通过上述推演,你会发现一个后端架构的死结(Paradox):

1
2
3
4
5
6
7
8
9
10
11
 ┌────────────────────────────────────────────────────────┐
│ 架构的两难抉择 │
└────────────────────────────────────────────────────────┘
/ \
/ \
▼ ▼
【绝对无状态 JWT】 【服务端引入状态控制】
- 服务端不查任何缓存/数据库 - 引入 Redis 黑名单 / 白名单
- 扩容极其方便 - 能做到秒级踢人、注销、修改权限
- ❌ 无法主动注销与撤销 - ❌ 兜兜转转又做回了 Session
- ❌ 盗用无法防御,安全隐患极大 - 且每次请求还要比 Session 多传上千字节

如果你在服务端搞了个 Redis 黑名单(只要用户退出登录就把 token 塞进 Redis,每次请求都去查 Redis 校验),那这和传统的 Session / Redis Token 还有什么区别? 甚至它的代价比 Session 更大——不仅没省下 Redis 查询,每次 HTTP 请求还要白白在网络上传输大几十倍体积的 Base64 字符串!


四、JWT 的正确打开姿势:工业界成熟架构方案

既然纯无状态 JWT 弊端明显,那么它在各大厂的生产系统里到底是怎么用的?

最佳实践 1:双 Token 机制(短命 Access Token + 长命 Refresh Token)

这是目前工业界(包括 OAuth 2.0、GitHub、Google 等)最标准的解法。我们可以通过泳道图(Swimlane Sequence Diagram)清晰看懂客户端、鉴权网关、Redis 状态库与微服务之间的协作全生命周期:

sequenceDiagram
    autonumber
    participant Client as 客户端
    participant Gateway as API 网关 / 鉴权中心
    participant Redis as Redis 令牌状态库
    participant Service as 业务微服务集群

    Note over Client, Service: 阶段 1:用户登录与双 Token 签发
    Client->>Gateway: POST /login (用户名与密码)
    Gateway->>Gateway: 验证身份并生成短命 Access Token
    Gateway->>Redis: 记录长命 Refresh Token (7天 TTL)
    Redis-->>Gateway: 存储成功
    Gateway-->>Client: 返回 Access Token 与 Refresh Token

    Note over Client, Service: 阶段 2:常规业务请求 (纯无状态,极速放行)
    Client->>Gateway: GET /api/orders (携带 Access Token)
    Gateway->>Gateway: 本地公钥验签与过期时间校验 (不查 Redis)
    Gateway->>Service: 验签通过,转发请求 (携带解析后的用户信息)
    Service-->>Client: 返回业务数据

    Note over Client, Service: 阶段 3:Access Token 自然过期与无感静默刷新
    Client->>Gateway: GET /api/orders (Access Token 已过期)
    Gateway-->>Client: 401 Unauthorized
    Client->>Gateway: POST /refresh (携带 Refresh Token)
    Gateway->>Redis: 校验 Refresh Token 是否有效
    Redis-->>Gateway: 校验通过 (未被吊销)
    Gateway->>Gateway: 签发新的 Access Token
    Gateway-->>Client: 返回新 Access Token
    Client->>Gateway: 重试发起业务请求
    Gateway->>Service: 转发请求
    Service-->>Client: 返回业务数据

    Note over Client, Service: 阶段 4:用户登出 / 修改密码 / 强制下线 (服务端主动管控)
    Client->>Gateway: POST /logout (主动登出)
    Gateway->>Redis: 立即删除对应 Refresh Token
    Redis-->>Gateway: 删除成功
    Gateway-->>Client: 200 OK 登出成功
    Note over Client, Redis: 此时即使旧 Access Token 泄露,最大漏洞窗口仅剩余几分钟,且彻底无法续期!
  • 正常业务高并发访问:带着有效期只有 5~15 分钟的 Access Token(无状态校验),微服务网关凭借本地公钥即可快速验签,享受无状态的高性能;
  • 需要撤销与安全下线:用户点击登出、修改密码或被封号时,服务端直接在 Redis 中注销其 Refresh Token。这样,即使被盗的 Access Token 还有几分钟寿命,在十几分钟后它将因无法换发新 Token 而彻底暴毙,将安全损失窗口强行压缩在可控的数分钟之内。

最佳实践 2:内网微服务间身份传递(Token 降级模式)

  • 在对外暴露的 API 网关层,使用传统的 Session 或轻量 Opaque Token(随机 32 位字符串),网关持有状态;
  • 网关校验通过后,在内部向各个下游微服务(如订单服务、库存服务、风控服务)转发时,动态签发一个极短有效期的内部 JWT(携带 userId、roles、tenants);
  • 内部微服务拿到 JWT 之后直接反序列化拿到上下文信息,无需跨服务反查用户中心。这种模式下,JWT 充当的是安全的内部跨服务上下文传递载体:
flowchart LR
    subgraph ClientLane [客户端]
        Client["前端应用 / APP"]
    end

    subgraph GatewayLane [网关与鉴权]
        Gateway["API 网关 (状态校验)"]
        Redis[("Redis Session")]
    end

    subgraph ServiceLane [后端微服务集群]
        OrderService["订单微服务"]
        UserService["用户微服务"]
        PayService["支付微服务"]
    end

    Client --> Gateway
    Gateway <--> Redis
    Gateway --> OrderService
    Gateway -.-> UserService
    Gateway -.-> PayService

最佳实践 3:短周期的临时委托与文件下载鉴权

比如生成一个“邮件激活链接”、“找回密码验证串”或“OSS 临时私有文件下载 URL”,设置有效期 5 分钟,将文件路径与有效时间签名成 JWT 拼接在 URL 参数后。这种场景完全不需要入库,天然契合 JWT 的无状态自包含特性。


五、JWT 与普通 Token 的本质区别

很多人分不清 JWT 与普通 Token(如 UUID / 随机哈希串)的区别,一张表看懂:

比较维度传统 Token (Opaque Token / Session ID)JWT (Self-Contained Token)
形态本质一串无意义的随机字符串(如 f47ac10b...)结构化的明文数据包 + 数字签名
数据承载不承载任何数据,只是一个“取件码”自带用户 ID、角色、有效期等业务属性(自包含)
鉴权方式服务端必须拿着取件码去 Redis/数据库查询具体信息仅通过算法验证签名是否合法即可信任里面的数据
主动注销极度容易(直接删掉 Redis 对应的 Key 即可)极度困难(除非引入黑名单破坏无状态)
网络开销极小(通常 32 ~ 64 字节)较大(通常 500 ~ 2000 字节,增大 Header 开销)

总结

当年阮一峰老师的文章确实推动了国内开发者对 JWT 的认知普及,但“所有数据都存在客户端,服务器完全不存”的理念,更偏向于理想化的学术模型。

软件架构从来没有银弹,只有权衡(Trade-off):

  • 不要为了“时髦”而盲目在单体或普通 Web 项目中抛弃 Session 改用纯无状态 JWT;
  • 接受客户端永远不可信的现实。在涉及用户核心权限、金钱交易与登出控制的场景下,服务端保留对状态的终极控制权,永远是系统安全最坚实的一道防线。

参考资料与延伸阅读

1. IETF 核心协议标准规范

2. 业界深度分析与安全指引


也说 JWT
https://bubao.github.io/posts/20c24ba5.html
作者
一念
发布于
2021年3月11日
许可协议