← 返回博客首页

JWT 安全最佳实践:从算法到存储全指南

为什么 JWT 安全如此重要?

JWT(JSON Web Token)因无状态、跨域友好、移动端适配好等优点,已成为现代 Web 认证的事实标准。但 JWT 的灵活性也意味着:用错一步,整个认证体系就会形同虚设

历史上因 JWT 实现不当导致的高危漏洞比比皆是:alg: none 绕过、密钥混淆攻击、永不过期的令牌泄漏……本文从算法、过期、刷新、存储、攻击防护五个维度,系统梳理 JWT 安全的最佳实践。

一、算法选择

推荐算法排序

算法 类型 推荐度 适用场景
EdDSA (Ed25519) 非对称 ⭐⭐⭐⭐⭐ 新项目首选,速度快、密钥短
RS256 / RS384 / RS512 非对称(RSA) ⭐⭐⭐⭐ 微服务、多服务验证场景
ES256 / ES384 / ES512 非对称(ECDSA) ⭐⭐⭐⭐ 移动端、对性能敏感场景
HS256 / HS384 / HS512 对称(HMAC) ⭐⭐ 单体应用、内部服务
none 永远禁用

为什么优先选非对称算法?

对称算法(HS256) 的签发和验证用同一个密钥。问题在于:

  1. 任何能验证令牌的服务都能伪造令牌
  2. 微服务架构下密钥需分发给所有服务,泄漏面增大
  3. 一旦密钥泄漏,攻击者可签发任意身份的令牌

非对称算法(RS256 / EdDSA) 用私钥签发、公钥验证:

  • 只有认证中心持有私钥,可签发令牌
  • 其他服务只用公钥验证,即使被攻破也无法伪造令牌
  • 公钥可公开分发(如通过 JWKS 端点)

致命陷阱:alg: none

JWT 规范允许 alg 头部为 none,表示不签名。攻击者可构造:

{"alg":"none","typ":"JWT"}.{"sub":"admin","role":"superadmin"}.

部分早期库会直接接受这种令牌。防御措施

  1. 升级到主流且持续维护的库(jose、PyJWT 2.x、jjwt)
  2. 验证时显式指定期望算法,不信任头部中的 alg
// ❌ 危险:信任头部 alg
jwt.verify(token, secret);

// ✅ 安全:显式指定算法
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

密钥混淆攻击(Algorithm Confusion)

攻击者把 RS256 令牌改成 HS256,并用公钥当作 HMAC 密钥重新签名。如果服务端验证时未固定算法,就会用公钥做 HMAC 校验——而公钥是公开的,攻击者可伪造任意令牌。

防御:显式指定算法(见上),且公钥/私钥不要混用。

密钥强度

  • HS256:密钥至少 256 位(32 字节)随机字节,不要用短密码或可预测字符串
  • RS256:至少 2048 位,推荐 3072 位
  • EdDSA:255 位密钥即可,性能与安全性俱佳
# 生成 32 字节随机密钥
openssl rand -base64 32

# 生成 RSA 密钥对
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem

# 生成 Ed25519 密钥对
openssl genpkey -algorithm Ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem

二、过期策略

核心原则:令牌必须有过期时间

永不过期的 JWT 是重大安全隐患——一旦泄漏无法挽回。exp 声明是第一道防线。

推荐有效期

令牌类型 推荐有效期 说明
Access Token 15 分钟 ~ 1 小时 短有效期,泄漏窗口小
Refresh Token 7 ~ 30 天 用于换取新 Access Token
ID Token (OIDC) 5 ~ 10 分钟 仅用于一次性身份传递
// 签发 Access Token
const accessToken = jwt.sign(
  { sub: userId, role: 'user' },
  privateKey,
  {
    algorithm: 'RS256',
    expiresIn: '15m',  // 15 分钟
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com'
  }
);

为什么不直接设长有效期?

令牌一旦签发,在 exp 之前无法撤销(除非维护黑名单,这违背了 JWT 无状态的初衷)。短有效期 + Refresh Token 是平衡安全与体验的标准方案:

  • Access Token 短命,即使泄漏影响有限
  • Refresh Token 可主动撤销(存储在服务端,可拉黑)
  • 用户无感知——Access Token 过期时前端自动用 Refresh Token 换新的

三、Refresh Token 轮换

工作流程

客户端                    认证服务
  │                          │
  │── 登录 ─────────────────▶│
  │◀─ Access Token (15m) ────│
  │   + Refresh Token (7d) ──│
  │                          │
  │   ... 15 分钟后 ...       │
  │                          │
  │── 用 Refresh 换新 AT ───▶│
  │◀─ 新 Access Token ───────│
  │   + 新 Refresh Token ────│  ← 旧 Refresh 立即失效
  │                          │

轮换(Rotation)的意义

每次使用 Refresh Token 换取新 Access Token 时,同时签发新的 Refresh Token 并作废旧的。这样:

  1. 每个 Refresh Token 只能用一次
  2. 若攻击者窃取了已使用过的 Refresh Token,使用时会触发"重用检测"
  3. 检测到重用后,可吊销该用户所有令牌链,强制重新登录

实现要点

async function refresh(refreshToken) {
  // 1. 验证 Refresh Token 签名与有效期
  const payload = jwt.verify(refreshToken, publicKey, {
    algorithms: ['RS256']
  });

  // 2. 检查是否在已撤销列表中(需 Redis 等存储)
  const isRevoked = await redis.get(`revoked:${payload.jti}`);
  if (isRevoked) {
    // 检测到重用 → 吊销整个令牌家族
    await revokeTokenFamily(payload.family_id);
    throw new Error('Token reuse detected');
  }

  // 3. 将当前 Refresh Token 加入撤销列表
  await redis.setex(`revoked:${payload.jti}`, payload.exp - now, '1');

  // 4. 签发新的 Access Token + Refresh Token
  const newAccess = signAccessToken(payload.sub);
  const newRefresh = signRefreshToken(payload.sub, payload.family_id);

  return { accessToken: newAccess, refreshToken: newRefresh };
}

主动撤销机制

虽然 JWT 本身无状态,但 Refresh Token 必须有服务端撤销能力:

  • 黑名单:用户登出、修改密码、检测到异常时,把 Refresh Token 的 jti 写入 Redis 黑名单
  • 白名单:每个用户维护有效的 Refresh Token 列表,登出时删除
  • 推荐黑名单方案,存储量小,性能好

四、令牌存储方案

这是前端最容易犯错的地方。三种主流方案对比:

方案 A:localStorage(❌ 不推荐)

// ❌ 危险
localStorage.setItem('token', accessToken);

问题localStorage 可被任何同源 JavaScript 读取,一旦页面存在 XSS 漏洞,攻击者可直接窃取令牌。永远不要把令牌存到 localStorage。

方案 B:HttpOnly Cookie(✅ 推荐)

服务端通过 Set-Cookie 把令牌写入 HttpOnly + Secure + SameSite 的 Cookie:

Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900

优势

  • JavaScript 无法读取(document.cookie 拿不到),天然防 XSS 窃取
  • 浏览器自动随请求携带,无需前端手动处理

注意:需配合 CSRF 防护(见下文)。

方案 C:内存中(✅ 配合方案 B)

// Access Token 存内存
let accessToken = null;

// 页面刷新后用 Refresh Token(存在 HttpOnly Cookie)重新获取

优势:页面刷新后令牌消失,攻击窗口极小。 劣势:页面刷新需重新换取,有额外请求开销。

推荐组合

令牌 存储位置
Access Token 内存(JavaScript 变量)
Refresh Token HttpOnly + Secure + SameSite=Strict Cookie

这样 XSS 只能窃取内存中的短命 Access Token,无法触及 Refresh Token;CSRF 又被 SameSite Cookie 拦截。

移动端 App

原生 App 不受浏览器同源策略保护,建议:

  • Access Token 存安全存储区:iOS Keychain / Android Keystore
  • Refresh Token 同上,并启用生物识别保护
  • 不要存到 UserDefaults / SharedPreferences(明文)

五、XSS 与 CSRF 防护

XSS 防护

XSS(跨站脚本)是 JWT 最大的威胁——攻击者注入恶意脚本,直接读取并外传令牌。

防御层级

  1. 输出转义:所有用户输入渲染到 HTML 时转义(框架如 React/Vue 默认转义)
  2. CSP(内容安全策略):限制脚本来源
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'
  1. HttpOnly Cookie:令牌不暴露给 JavaScript(见上文)
  2. Trusted Types:现代浏览器 API,强制输入净化

CSRF 防护

CSRF(跨站请求伪造)利用浏览器自动携带 Cookie 的特性,诱导用户在已登录状态下发起非自愿请求。

注意:CSRF 只威胁 Cookie 方案,如果令牌通过 Authorization 头部手动携带则不受影响。但 Cookie 方案的 XSS 优势太大,不能因噎废食,需配套 CSRF 防护:

  1. SameSite Cookie(首选)
Set-Cookie: ...; SameSite=Strict   # 跨站完全不发 Cookie
Set-Cookie: ...; SameSite=Lax      # 允许顶层导航的 GET 请求(默认值)

Strict 最安全,但会影响从外部链接登录的体验。Lax 是合理折中。

  1. CSRF Token

服务端生成随机 Token,嵌入表单或响应头,前端提交时回传并校验:

<meta name="csrf-token" content="abc123">
fetch('/api/data', {
  headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content }
});
  1. 双重提交 Cookie:Cookie 中存一份 CSRF Token,请求头再带一份,服务端比对两者一致。

六、其他安全要点

1. 声明(Claims)最小化

Payload 只放必要信息,绝不存敏感数据(密码、身份证号、支付信息)。JWT 的 Payload 是 Base64 编码而非加密,任何人都能解码。

// ❌ 危险
{ sub: '123', password: 'abc123', creditCard: '4111...' }

// ✅ 安全
{ sub: '123', role: 'user' }

2. 验证所有声明

不要只验签名,要校验全部标准声明:

jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com',     // 验签发者
  audience: 'https://api.example.com',    // 验受众
  clockTimestamp: Date.now() / 1000        // 防时钟偏移绕过
});
// 库会自动校验 exp、nbf、iat

3. 使用 jti 防重放

为每个令牌生成唯一 ID(jti),敏感操作时记录已使用的 jti,防止重放攻击。

4. 密钥轮换

定期更换签名密钥(如每 90 天),并在过渡期支持新旧密钥同时验证:

function verify(token) {
  // 尝试新密钥
  try { return jwt.verify(token, newKey, { algorithms: ['RS256'] }); }
  catch { /* fallthrough */ }
  // 回退到旧密钥
  return jwt.verify(token, oldKey, { algorithms: ['RS256'] });
}

通过 JWKS 端点暴露公钥集合,用 kid 头部匹配,可优雅实现轮换。

5. 日志脱敏

日志中永远不要打印完整令牌

// ❌
console.log(`Auth: ${token}`);

// ✅
console.log(`Auth: jti=${payload.jti}, sub=${payload.sub}`);

总结速查表

维度 最佳实践
算法 EdDSA 或 RS256,显式指定,禁用 none
密钥 非对称优先,密钥足够长,定期轮换
过期 Access Token 15 分钟,Refresh Token 7-30 天
刷新 Refresh Token 轮换 + 重用检测
撤销 Refresh Token 黑名单(Redis)
存储(Web) Access Token 内存,Refresh Token HttpOnly Cookie
存储(App) Keychain / Keystore + 生物识别
XSS 防护 HttpOnly Cookie + CSP + 输出转义
CSRF 防护 SameSite Cookie + CSRF Token
Payload 最小化,不存敏感数据
验证 校验签名 + exp + iss + aud + nbf
← 返回博客首页