文章

Token与JWT机制深度解析:从结构原理到刷新方案的全链路实战

Token 与 JWT 是现代鉴权基石:讲清 JWT 结构(Header/Payload/Signature)、无状态校验与 Refresh Token 刷新方案。 掌握后能说清「为什么用 JWT 替代 Session」及过期刷新链路。

Token与JWT机制深度解析:从结构原理到刷新方案的全链路实战

一句话概括

JWT(JSON Web Token)是一个自包含的、经过签名的 JSON 令牌,它让服务端能在不查数据库的情况下验证”你是谁”和”你能做什么”——但它的无状态特性也是一把双刃剑:Token 一旦发出,就跟”泼出去的水”一样难以收回。

核心知识点

1. JWT 的三段式结构

JWT 由三部分组成,用 . 分隔:Header.Payload.Signature

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 用 Node.js 原生 crypto 手写一个 JWT,彻底理解签名过程
import crypto from 'crypto';

const base64url = (str) =>
  Buffer.from(str).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

function sign(payload, secret, expiresIn = 3600) {
  const header = { alg: 'HS256', typ: 'JWT' };
  const now = Math.floor(Date.now() / 1000);
  const body = { ...payload, iat: now, exp: now + expiresIn, jti: crypto.randomUUID() };

  const h64 = base64url(JSON.stringify(header));
  const p64 = base64url(JSON.stringify(body));
  const sig = crypto.createHmac('sha256', secret).update(`${h64}.${p64}`).digest('base64url');

  return `${h64}.${p64}.${sig}`;
}

function verify(token, secret) {
  const [h64, p64, sig] = token.split('.');
  const expected = crypto.createHmac('sha256', secret).update(`${h64}.${p64}`).digest('base64url');
  if (sig !== expected) throw new Error('签名不匹配');

  const payload = JSON.parse(Buffer.from(p64, 'base64url').toString());
  if (payload.exp < Date.now() / 1000) throw new Error('Token 已过期');
  return payload;
}

// 试试
const token = sign({ sub: 'user_42', role: 'admin' }, 'my-256-bit-secret!!');
console.log(verify(token, 'my-256-bit-secret!!'));
// { sub: 'user_42', role: 'admin', iat: ..., exp: ..., jti: '...' }

关键点:Payload 只是 Base64 编码,不是加密!任何人都能解码看到内容,但不能伪造——因为不知道密钥算不出签名。

2. 对称签名 vs 非对称签名:选哪个?

1
2
3
4
5
6
7
// HS256:同一个密钥签发和验证,适合单体应用
const token = sign(payload, SHARED_SECRET);  // 签发 & 验证都用 SHARED_SECRET

// RS256:私钥签发、公钥验证,适合微服务
import { generateKeyPairSync, privateDecrypt, publicEncrypt } from 'crypto';
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
// Auth 服务用 privateKey 签发 → 其他服务用 publicKey 验证,无需共享密钥
维度HS256RS256
密钥管理所有服务共享同一个 secret私钥只在一处,公钥到处分发
适用场景单体应用微服务、第三方集成
性能快慢(RSA 签名耗时约 HS256 的 5-10 倍)
风险密钥泄露 = 全崩私钥泄露才崩,公钥可以公开

3. Token 刷新的正确姿势:Refresh Token 旋转

前端面试高频题,但真正能讲清楚”旋转”机制的不到 20%:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 普通刷新(❌ 有风险)
// 用 refreshToken1 换 accessToken2 → refreshToken1 依然有效
// 如果 refreshToken1 被窃取,攻击者可以一直用

// 旋转刷新(✅ 关键:每次刷新发新的 refresh token,旧的同时失效)
async function rotateToken(oldRefreshToken, userId) {
  // 用 Lua 脚本保证原子性:检查旧 token → 存入新 token
  const script = `
    local stored = redis.call('GET', KEYS[1])
    if stored ~= ARGV[1] then return redis.error_reply('STOLEN') end
    redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
    return 'OK'
  `;
  const newToken = crypto.randomBytes(32).toString('hex');
  await redis.eval(script, [`refresh:${userId}`], [oldRefreshToken, newToken, 7 * 86400]);
  return newToken;
}
// 如果返回 STOLEN → 说明这个 refresh token 已被消费 → 可能被窃取 → 立即拉黑用户所有 session

旋转的精妙之处:正常用户和攻击者同时持有 refreshToken1 时,谁先用谁拿到新 token,另一个人的立即失效,从而自动暴露窃取行为。

4. Access Token 和 Refresh Token 的生命周期设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const TOKEN_CONFIG = {
  accessToken:  '15min',   // 短(泄露窗口小)
  refreshToken: '7d',      // 长(但支持旋转和吊销)
  // 原则:access token 过期了用 refresh token 换,refresh token 过期了只能重新登录
};

// 前端 Axios 拦截器:自动无感刷新
api.interceptors.response.use(
  res => res,
  async err => {
    if (err.response?.status === 401 && !err.config._retry) {
      err.config._retry = true;
      const { accessToken, refreshToken } = await refreshTokens();
      // 存新的、重试原请求
      err.config.headers.Authorization = `Bearer ${accessToken}`;
      return api(err.config);
    }
    return Promise.reject(err);
  }
);

5. JWT 常见安全雷区

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 雷区1:Payload 放敏感信息
{ "credit_card": "6222..." }  // Base64 不是加密!

// ❌ 雷区2:密钥太短
const secret = 'abc123';  // HS256 要求至少 256 位(32 字节)

// ❌ 雷区3:不校验算法
jwt.verify(token, secret); // 老版本库可能接受 alg: "none"(无签名!)
// ✅ 明确指定允许的算法
jwt.verify(token, secret, { algorithms: ['HS256'] });

// ❌ 雷区4:Token 永不过期
{ exp: Math.floor(Date.now()/1000) + 365*86400 }  // 一年?太危险

其实你每天都在用

  • 登录态保持:打开任何网站登录后刷新页面仍然保持登录——背后就是 access token 存在 localStorage/sessionStorage,每次请求带在 Authorization 头里
  • OAuth 第三方登录:用微信/GitHub 登录某个网站时,微信返回给网站的就是一个 JWT,里面包含了你的 openId 和基本信息
  • API 网关鉴权:公司内部微服务之间的调用,网关拿到 JWT 后直接解析角色和权限,无需每次查数据库
  • 邮件验证/密码重置链接:那些”点击这里重置密码”的一次性链接,本质就是一个短时效 JWT,包含用户 ID 和操作类型
  • 移动端自动登录:App 首次登录后下次打开不用重新输入密码,就是因为 Refresh Token 存在了 Keychain/Keystore 里

常见误解(FAQ)

❌ 误区:JWT 是加密的,所以可以存放敏感信息

JWT 的标准是签名(JWS),不是加密。加密版本叫 JWE(JSON Web Encryption),实际使用极少。Payload 内容是 Base64URL 编码的明文,任何人都可以解码。敏感信息应该存服务端,JWT 里只放用户 ID。

❌ 误区:JWT 完全无状态,比 Session 方案更好

“无状态”意味着 Token 发出去后无法主动吊销。如果公司裁员需要立刻收回权限,在纯 JWT 方案下只能等 Token 自然过期(或者上黑名单,这本质就是”有状态”了)。没有银弹——高安全场景把 JWT 当 Session ID 用(Redis 存映射),普通场景用纯 JWT + 短 TTL。

❌ 误区:用 localStorage 存 Token 就够了

localStorage 可以被同一域名下的任何 JS 读取,包括被注入的恶意脚本。更安全的做法是 Access Token 存内存(变量),Refresh Token 存 httpOnly + Secure + SameSite=Strict 的 Cookie。这样即使 XSS 注入也只能拿到 Access Token(很快过期),拿不到 Refresh Token。

❌ 误区:JWT 越大越方便,把所有用户信息都塞进去

每次 HTTP 请求都带着 JWT,如果 Token 有 5KB,每个请求就多 5KB 开销。一天 100 万次请求就是 5GB 额外流量。Payload 尽量精简——用户 ID + 角色足矣,详细信息走 API 查询。

一句话总结

JWT 不是加密,是签名——它让”你是谁”这个答案不需要问数据库就能被信任,但这份信任的代价是”说出去的话收不回来”。

本文由作者按照 CC BY 4.0 进行授权