我见过一个团队用 JWT 做鉴权,后来遇到用户投诉"明明退出登录了,账号还是被人登进去了"。排查了半天,发现他们只是在前端删掉了 token,服务端根本没有任何注销逻辑——因为他们以为 JWT"无状态"就代表不需要处理退出。这个误解差点酿成一个安全事故。

三段式结构:header.payload.signature

JWT 是由三段 Base64 编码的字符串拼起来的,用点号分隔。实际看起来大概是这样:

// header(算法声明)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

// payload(用户数据)
eyJ1c2VySWQiOiIxMjM0Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzQ4MDAwMDAwfQ

// signature(签名)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

三段拼在一起就是完整的 token,放在请求头的 Authorization: Bearer <token> 里传给服务端。

签名保证的是"没被篡改",不是"保密"

这是最容易搞错的一点。payload 那段只是 Base64 编码,不是加密——任何人拿到 token,在浏览器控制台里直接 atob() 就能解码出来,里面的用户 ID、角色信息一览无余。

签名的作用是验证 payload 没有被第三方修改过。服务端用密钥对 header 和 payload 做 HMAC 签名,如果有人改了 payload 里的数据,签名就对不上,服务端会拒绝这个 token。所以:敏感信息不要放进 payload,密码、手机号这类绝对不行。

无状态很美好,但代价是什么

JWT 的核心优势是无状态——服务端不需要存 session,每个节点拿到 token 自己就能验签,天然适合分布式部署。这确实省了很多事。

但代价是:token 一旦签发,在过期之前服务端没有办法让它失效。用户改密码了?token 还有效。用户被封号了?token 还有效。除非你在服务端维护一个"黑名单",但那样又引入了状态,和 session 方案的区别就变小了。

短 access token + refresh token 的工程实践

业界常见的做法是双 token 策略:access token 有效期设很短(15 分钟到 1 小时),refresh token 有效期长(7 天或 30 天),存在服务端数据库里可以主动吊销。

access token 过期后,客户端用 refresh token 换一个新的 access token。如果 refresh token 也失效,就强制重新登录。这样即便 access token 泄漏,攻击窗口最多只有几十分钟;而注销账号、强制下线这类操作,直接把 refresh token 从数据库删掉就好。

一段简单的 Node 签发示例

const jwt = require('jsonwebtoken');

const SECRET = process.env.JWT_SECRET;

// 签发 access token
function signAccessToken(userId, role) {
  return jwt.sign(
    { userId, role },
    SECRET,
    { expiresIn: '15m' }
  );
}

// 验签(抛异常说明无效或过期)
function verifyToken(token) {
  return jwt.verify(token, SECRET);
}

生产环境里 SECRET 一定要从环境变量读取,不要硬编码进代码。泄露了 SECRET 就等于签名机制完全失效。

我们团队曾经把 JWT_SECRET 提交进了 Git 仓库,还好是私有库。发现之后立即轮换了密钥,同时所有在线 token 全部失效,用户被迫重新登录。那一天接到了不少投诉,是一次代价不小的教训。

小结

JWT 不是万能的,也不是"安全的"——它只是一种轻量的身份验证协议。用好它的关键是:payload 不放敏感数据,短期 access token 配合可吊销的 refresh token,SECRET 严格保密。把这三条记住,能避开大多数坑。