JWT 原理:从结构到实战

JWT 这东西,说白了就是一段自包含的令牌。我刚开始接触它的时候,觉得不就是个加密字符串嘛,有啥好研究的?后来在项目中踩了几次坑,才真正理解它的设计精妙之处。

咱们先看它的全称——JSON Web Token。注意这个"Web",它天生就是为了在 HTTP 这种无状态协议里传递身份信息而生的。你想想看,每次请求都要查 Session,服务器压力多大?JWT 的思路就是:把用户信息直接塞进令牌里,服务器拿到后自己验证就行,不用查数据库。

JWT 结构:三段式

一个 JWT 长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

看到那两个点了吗?它把令牌分成了三部分:Header、Payload、Signature。每部分都是 Base64URL 编码的 JSON。

核心要点:JWT 的三段结构不是随便设计的。Header 告诉你怎么签的,Payload 存实际数据,Signature 保证没人篡改。缺一不可。

Header(头部)

Header 通常包含两个字段:令牌类型(typ)和签名算法(alg)。

{
  "alg": "HS256",
  "typ": "JWT"
}

alg 字段很关键。我见过有人把 alg 设成 "none",结果服务端没校验,直接放行了所有伪造令牌。嗯,这属于低级错误,但确实发生过。

Payload(载荷)

Payload 才是真正存数据的地方。它分三种声明:

  • 注册声明(Registered Claims):预定义的标准化字段,比如 iss(签发者)、sub(主题)、exp(过期时间)、iat(签发时间)。
  • 公共声明(Public Claims):自定义字段,但建议在 IANA 注册或使用命名空间避免冲突。
  • 私有声明(Private Claims):双方约定的自定义字段,比如 userId、role。

举个例子:

{
  "sub": "user_12345",
  "name": "张三",
  "role": "admin",
  "iat": 1700000000,
  "exp": 1700003600
}

注意:Payload 只是 Base64 编码,不是加密的!任何人都能解码看到里面的内容。千万别把密码、信用卡号这类敏感信息放进去。我曾经在一个项目里接手过遗留代码,发现他们把用户手机号直接塞进 Payload……赶紧改掉。

Signature(签名)

签名是 JWT 防篡改的核心。它的生成过程:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

说白了,就是把前两段拼起来,用密钥算个哈希。只要密钥不泄露,别人改了一丁点数据,签名就对不上了。

JWT 签发与验证

咱们用 Node.js 的 jsonwebtoken 库演示一下,这样更直观。

签发令牌

const jwt = require('jsonwebtoken');

const payload = {
  userId: 'u_1001',
  role: 'editor'
};

const secret = 'my-secret-key-2024';

const token = jwt.sign(payload, secret, {
  expiresIn: '2h',
  issuer: 'my-app'
});

console.log(token);
// 输出:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1XzEwMDEiLCJyb2xlIjoiZWRpdG9yIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDcyMDB9.xxxxx

我个人习惯把过期时间设短一点,比如 1-2 小时。太长的话,令牌泄露风险大;太短的话,用户老得重新登录,体验不好。

验证令牌

try {
  const decoded = jwt.verify(token, secret, {
    issuer: 'my-app'
  });
  console.log(decoded.userId); // u_1001
} catch (err) {
  // 过期、签名错误、发行者不匹配都会抛异常
  console.error('Token invalid:', err.message);
}

验证的时候,库会自动检查 exp 是否过期、iss 是否匹配。你只需要保证密钥一致就行。

避坑指南:我曾经在生产环境遇到过一个问题——签发和验证用的密钥不一致。原因是配置中心更新了密钥,但服务没重启。后来我改用密钥轮换机制,旧密钥保留一个缓冲期,新密钥逐步切换,才彻底解决。

JWT vs Session:怎么选?

很多新手会问:到底用 JWT 还是 Session?我的答案是:看场景。没有银弹。

对比维度 JWT Session
存储位置 客户端(浏览器/App) 服务端(内存/Redis/数据库)
扩展性 天然支持分布式,无共享存储 需要共享 Session 存储(如 Redis)
安全性 Payload 可解码,敏感信息需加密 数据在服务端,相对安全
吊销能力 难!签发后无法主动失效(除非维护黑名单) 容易,直接删 Session 即可
性能 服务端无状态,验证快(只需计算签名) 每次请求查存储,有 I/O 开销
适用场景 微服务、移动端、单点登录(SSO) 传统 Web 应用、需要即时吊销的场景

我个人的经验是:内部系统、微服务架构、移动端 API 优先用 JWT。因为它无状态,服务随便扩缩容,不用操心 Session 同步。但如果是金融、政务这类对安全要求极高、需要随时踢人下线的场景,Session 更合适——毕竟 JWT 一旦签发,在过期前你拿它没办法。

我的建议:别把 JWT 和 Session 对立起来。很多项目是混合用的——JWT 做 API 鉴权,Session 做 Web 页面登录态。各取所长,才是正道。

JWT 完整流程

下面这张图展示了 JWT 从签发到验证的完整链路:

JWT 签发与验证流程 客户端 认证服务 业务服务 1. 登录请求 验证用户名密码 生成 JWT 2. 返回 JWT 存储 JWT 3. 请求携带 JWT(Authorization: Bearer xxx) 验证签名 检查过期时间 通过 → 处理请求 关键点: • 服务端不存 Session • 所有信息在令牌里 • 签名保证数据完整性

流程其实就三步:

  1. 用户登录,认证服务验证身份后签发 JWT
  2. 客户端存下 JWT(通常放 localStorage 或 Cookie)
  3. 后续请求带上 JWT,业务服务验证签名和过期时间

你看,业务服务根本不需要查数据库。这就是 JWT 最大的优势——无状态。服务随便重启、随便扩容,令牌照样能用。

小技巧:JWT 的 exp 字段一定要设。我见过有人不设过期时间,结果令牌泄露后永久有效……那场面,简直灾难。另外,iat 字段也建议加上,方便排查问题。