JWT 认证最佳实践:算法、存储与吊销实战

安全指南

JWT 被滥用的现状

JWT 几乎成了「做登录」的默认选项,但我在 code review 里见过太多误用:把用户密码塞进 payload、用 alg: none 的库版本、把 token 存 localStorage 还奇怪为什么被 XSS 偷走、token 永远不失效所以没法吊销。

JWT 本身没问题,问题在「以为它是万能的 session 替代品」。这篇文章把生产环境里真正要命的几个点讲清楚。


一、JWT 到底是什么

JWT 由三部分组成,用 . 分隔:header.payload.signature

  • header:算法和类型,如 {"alg":"HS256","typ":"JWT"}
  • payload:声明(claims),如 {"sub":"123","name":"Ada","exp":1700000000}
  • signature:对前两部分用密钥签名,防篡改

关键点:payload 只是 Base64Url 编码,任何人都能解码看内容。它不是加密。所以千万别往里放密码、身份证号、token 本身。

# payload 是这样被解码的(人人可读)
echo 'eyJzdWIiOiIxMjMifQ' | base64 -d
# → {"sub":"123"}

二、最常见的三个误区

误区一:把 JWT 当 session 用

传统 session 把状态存在服务端,吊销一个用户只要在服务端删掉 session。JWT 默认是无状态的——服务端不存它,所以「吊销」变得困难。如果你需要频繁吊销、踢人下线,JWT 并不比 session 简单,反而更麻烦(见第六节)。

误区二:payload 塞敏感数据

因为 payload 可读,放密码、邮箱明文、内部权限位都是泄露风险。只放必要的、非敏感的标识:sub(用户 ID)、roleexp 等。

误区三:token 永不过期

不设 exp,或者设了几年后过期,等于发了一张长期通行证。一旦泄露,攻击者能一直用。


三、算法安全:攻击都从这来

none 算法攻击

早期某些库在 alg: none 时会跳过签名验证。攻击者把 header 改成 {"alg":"none"},把 signature 留空,就能伪造任意 payload。

防御:服务端必须显式指定允许的算法白名单,拒绝 none

// 用 jsonwebtoken 时显式声明算法
jwt.verify(token, secret, { algorithms: ["HS256"] });

绝不让客户端决定用哪个算法。

RS256 / HS256 混淆攻击

这是经典的「算法混淆」:服务端用 RSA 公钥验证(期望 RS256),但攻击者把 header 改成 HS256,然后用公钥当 HMAC 的密钥去签名。因为公钥往往是公开可获取的,攻击者就能伪造合法 token。

防御:

  • 验证时同时锁定算法和密钥类型:RS256 用公钥验证,HS256 用对称密钥。
  • 不要把 RSA 公钥当 HMAC 密钥使用。
  • 用成熟库(jsonwebtoken、jose)并显式传 algorithms
// RS256:用公钥验签,且只接受 RS256
jwt.verify(token, publicKey, { algorithms: ["RS256"] });

密钥管理

  • 对称算法(HS256)的 secret 要足够长、随机、不进代码仓库(用环境变量/密钥管理)。
  • 非对称(RS256/ES256)的私钥只在签发服务端,公钥可分发。
  • 支持密钥轮换(kid 头标识当前密钥)。

四、过期与刷新:access + refresh 设计

最佳实践是把 token 分两种:

  • access token:短期(如 15 分钟),用于访问接口,泄露窗口小。
  • refresh token:长期(如 7 天),只用来换新的 access token,存得要更安全。
// 签发
const accessToken = jwt.sign({ sub: user.id, role }, accessSecret, {
  algorithm: "RS256",
  expiresIn: "15m",
});
const refreshToken = jwt.sign({ sub: user.id, jti: refreshId }, refreshSecret, {
  algorithm: "RS256",
  expiresIn: "7d",
});

刷新接口只接受 refresh token,且每次刷新都发新的 refresh token(轮换),旧的作废。这样即使 refresh token 泄露,用一次就被取代。


这是 XSS 与 CSRF 的权衡题。

localStorage 的风险

localStorage 里的 token 任何 JS 都能读。一旦有 XSS(哪怕是一个有漏洞的第三方脚本),token 就被偷走发到攻击者服务器。而且 localStorage 无法设置 HttpOnly,不可被标记成「只走 HTTP」。

Cookie(HttpOnly + Secure + SameSite)更稳

把 access token 放进 Cookie:

  • HttpOnly:JS 读不到,挡住 XSS 窃取。
  • Secure:只走 HTTPS。
  • SameSite=Strict/Lax:挡住大多数 CSRF。

代价是要处理 CSRF(见第七节)。对大多数 Web 应用,这是比 localStorage 更安全的默认选择。

// 伪代码:Set-Cookie
res.setHeader("Set-Cookie", [
  `access=${accessToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`,
]);

折中方案

  • SPA + 后端同域:用 HttpOnly Cookie 存 access,前端通过 fetch 自动带 cookie。
  • 移动端 / 第三方调用:用 Authorization 头 + 短期 access token,refresh 走安全存储。

六、吊销与黑名单:jti

access token 短期 + refresh 轮换,已经能控制大部分风险。但「立刻踢人下线」怎么办?

给每个 token 一个唯一 jti(JWT ID),维护一个「吊销列表」(Redis 黑名单,带 TTL 等于 token 剩余寿命):

// 吊销
await redis.set(`blacklist:${jti}`, "1", "EX", remainingSeconds);

// 校验时先查黑名单
if (await redis.get(`blacklist:${jti}`)) throw new Error("revoked");

这样不需要在服务端存所有 token,只存「被吊销的」,代价极小。注意:黑名单本身只覆盖「需要主动吊销」的场景(封号、改密码),正常过期仍靠 exp


七、CSRF 防护与 SameSite

用 Cookie 就必须考虑 CSRF:攻击者诱导用户浏览器向你的域名发请求,浏览器自动带上 Cookie,于是以用户身份执行操作。

  • SameSite=Lax/Strict:现代浏览器默认就能挡掉大多数跨站请求带 Cookie。优先用 SameSite=Lax
  • CSRF Token:对敏感写操作,要求请求带服务端下发的随机 token(double-submit)。
  • 重要写操作要求重新认证:改密码、转账等,除了 Cookie 还要二次验证。
// 敏感操作额外校验
app.post("/api/transfer", requireReauth, handler);

八、实战:Node/Express 中间件

一个最小但安全的校验中间件:

import jwt from "jsonwebtoken";

export function authGuard(publicKey: string) {
  return (req, res, next) => {
    const token = req.cookies?.access; // HttpOnly Cookie
    if (!token) return res.status(401).json({ error: "no token" });

    let payload;
    try {
      // 显式锁定算法,拒绝 none / 混淆
      payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
    } catch {
      return res.status(401).json({ error: "invalid token" });
    }

    // 查吊销黑名单
    if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });

    req.user = { id: payload.sub, role: payload.role };
    next();
  };
}

配套的刷新接口:

app.post("/api/refresh", async (req, res) => {
  const rt = req.cookies?.refresh;
  if (!rt) return res.status(401).json({ error: "no refresh" });
  const payload = jwt.verify(rt, refreshPublicKey, { algorithms: ["RS256"] });
  if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });

  // 轮换:吊销旧 refresh,发新的一对
  blacklist.add(payload.jti);
  const newAccess = issueAccess(payload.sub);
  const newRefresh = issueRefresh(payload.sub); // 新 jti
  res.setHeader("Set-Cookie", [accessCookie(newAccess), refreshCookie(newRefresh)]);
  res.json({ ok: true });
});

常见问题

Q1:JWT 和 session 哪个好?

看需求。需要服务端集中控制、频繁踢人、存大量会话状态——session 更合适。需要无状态、跨服务、移动端友好——JWT 更合适。别因为「流行」就无脑选 JWT。

Q2:access token 过期时间设多长?

Web 应用 5~15 分钟较稳;配合 refresh 轮换。太长泄露窗口大,太短刷新频繁影响体验。

Q3:为什么我的 JWT 库报「invalid algorithm」?

基本是服务端期望 RS256 但收到了 HS256,或反过来——算法没锁死。务必 algorithms: [...] 显式声明,且 RS256 用公钥验证。

Q4:前端怎么拿用户信息?

不要解码 JWT 拿敏感数据(payload 可读但不可信,且可能过期)。让用户接口(/api/me)返回权威的用户信息,前端只存「用户 ID」这类非敏感标识。

Q5:JWT 能加密吗?

能,用 JWE(JSON Web Encryption)可以加密 payload。但多数场景只需要签名(防篡改)即可,加密会增加复杂度。敏感数据就别放进去,比加密更省事。


工具推荐

调试 token、看它到底装了什么时,这些 ToolsKu 工具顺手:

  • JWT 解码 — 粘贴 token 看 header/payload(不校验签名,纯查看)
  • JWT 生成器 — 自己填 header/payload/密钥生成测试 token
  • HMAC — 手工验证 HS256 签名是否对得上

JWT 不是 session 的替代品,而是另一种权衡:用「无状态、可分发」换来了「吊销麻烦、payload 可读」。想清楚你换来了什么、又失去了什么,才算真正用好了它。

本站提供浏览器本地工具,免注册即可试用 →

#JWT#认证#安全#Token#鉴权