为什么 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) 的签发和验证用同一个密钥。问题在于:
- 任何能验证令牌的服务都能伪造令牌
- 微服务架构下密钥需分发给所有服务,泄漏面增大
- 一旦密钥泄漏,攻击者可签发任意身份的令牌
非对称算法(RS256 / EdDSA) 用私钥签发、公钥验证:
- 只有认证中心持有私钥,可签发令牌
- 其他服务只用公钥验证,即使被攻破也无法伪造令牌
- 公钥可公开分发(如通过 JWKS 端点)
致命陷阱:alg: none
JWT 规范允许 alg 头部为 none,表示不签名。攻击者可构造:
{"alg":"none","typ":"JWT"}.{"sub":"admin","role":"superadmin"}.
部分早期库会直接接受这种令牌。防御措施:
- 升级到主流且持续维护的库(jose、PyJWT 2.x、jjwt)
- 验证时显式指定期望算法,不信任头部中的
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 并作废旧的。这样:
- 每个 Refresh Token 只能用一次
- 若攻击者窃取了已使用过的 Refresh Token,使用时会触发"重用检测"
- 检测到重用后,可吊销该用户所有令牌链,强制重新登录
实现要点
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 最大的威胁——攻击者注入恶意脚本,直接读取并外传令牌。
防御层级:
- 输出转义:所有用户输入渲染到 HTML 时转义(框架如 React/Vue 默认转义)
- CSP(内容安全策略):限制脚本来源
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'
- HttpOnly Cookie:令牌不暴露给 JavaScript(见上文)
- Trusted Types:现代浏览器 API,强制输入净化
CSRF 防护
CSRF(跨站请求伪造)利用浏览器自动携带 Cookie 的特性,诱导用户在已登录状态下发起非自愿请求。
注意:CSRF 只威胁 Cookie 方案,如果令牌通过 Authorization 头部手动携带则不受影响。但 Cookie 方案的 XSS 优势太大,不能因噎废食,需配套 CSRF 防护:
- SameSite Cookie(首选)
Set-Cookie: ...; SameSite=Strict # 跨站完全不发 Cookie
Set-Cookie: ...; SameSite=Lax # 允许顶层导航的 GET 请求(默认值)
Strict 最安全,但会影响从外部链接登录的体验。Lax 是合理折中。
- CSRF Token
服务端生成随机 Token,嵌入表单或响应头,前端提交时回传并校验:
<meta name="csrf-token" content="abc123">
fetch('/api/data', {
headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content }
});
- 双重提交 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 |