JWT 安全风险与防范

2015 年,Stripe 工程师在博客上分享了一个惊心动魄的案例:他们的某个客户通过修改 JWT 的 sub 字段,将自己的用户 ID 改成了管理员的 ID,成功绕过了身份验证拿到了管理员权限。更可怕的是,这个问题在他们的系统中存在了整整一年才被发现。

JWT 的设计本身是安全的,但错误的实现方式会让它成为系统的致命漏洞。本文将剖析 JWT 最常见的安全风险,并给出经过实战验证的防护方案。

一、算法篡改攻击(Algorithm Tampering)

攻击原理

JWT Header 中的 alg 字段告诉验证方「用什么算法验证签名」。正常情况下,签发方用私钥签发,验证方用对应的公钥验证。但问题出在——有些验证方会根据 Header 中的 alg 动态选择验证算法。

攻击流程:

sequenceDiagram
    participant Attacker as 攻击者
    participant Server as 目标服务器

    Attacker->>Attacker: 获取正常 JWT,修改 Header 的 alg 为 "HS256"
    Attacker->>Attacker: 将 Payload 的 sub 改为 "admin"
    Note over Attacker: 使用服务器的公钥作为 HS256 密钥
    Attacker->>Server: 发送修改后的 JWT
    Server->>Server: 误用公钥作为 HMAC 密钥验证签名
    Server-->>Attacker: 返回管理员权限

攻击代码演示

原始 Token:

{
  "alg": "RS256",
  "typ": "JWT"
}
{
  "sub": "12345",
  "role": "user"
}

攻击者篡改后:

{
  "alg": "HS256",
  "typ": "JWT"
}
{
  "sub": "admin",
  "role": "admin"
}

攻击者将 alg 改为 HS256,然后用自己的服务器验证(服务器会用 alg 指定的 HS256 算法和同一个公钥去验证签名,从而绕过验证)。

防护措施

方案一:强制指定算法。验证时明确要求只能使用特定算法,拒绝其他算法:

SecureJwtValidator.java
public class JwtValidator {
    // 强制要求只使用 RS256
    private static final String EXPECTED_ALGORITHM = "RS256";
    private static final Set<String> FORBIDDEN_ALGORITHMS = Set.of("none", "HS256", "HS384", "HS512");

    public DecodedJWT validate(String token) {
        DecodedJWT jwt = JWT.decode(token);
        String algorithm = jwt.getAlgorithm();

        // 拒绝危险算法
        if (FORBIDDEN_ALGORITHMS.contains(algorithm.toLowerCase())) {
            throw new SecurityException("Forbidden algorithm: " + algorithm);
        }

        // 确保算法与预期一致
        if (!algorithm.equals(EXPECTED_ALGORITHM)) {
            throw new SecurityException("Unexpected algorithm: " + algorithm);
        }

        // 使用公钥验证(RS256 场景下不会使用对称密钥)
        return verifier.verify(jwt);
    }
}

方案二:使用白名单。只允许预定义的、安全的算法:

// 只允许 RSA 和 ECDSA 系列
private static final Set<String> ALLOWED_ALGORITHMS = Set.of(
    "RS256", "RS384", "RS512",
    "ES256", "ES384", "ES512",
    "PS256", "PS384", "PS512"
);

二、Token 存储与泄露

常见泄露场景

存储位置风险攻击方式
localStorageXSS 攻击攻击者注入脚本读取 localStorage
sessionStorageXSS 攻击同上(页面关闭后丢失,但攻击窗口仍在)
Cookie(无 HttpOnly)XSS 攻击JavaScript 可读取
Cookie(带 HttpOnly)CSRF 攻击跨站请求自动携带 Cookie

最佳存储方案

方案一:HttpOnly + Secure Cookie

TokenCookieConfig.java
import jakarta.servlet.http.Cookie;

public class TokenCookieConfig {

    public Cookie createAccessTokenCookie(String token) {
        Cookie cookie = new Cookie("access_token", token);
        cookie.setHttpOnly(true);    // 禁止 JavaScript 读取
        cookie.setSecure(true);       // 仅 HTTPS 传输
        cookie.setPath("/");
        cookie.setMaxAge(15 * 60);   // 15 分钟过期
        cookie.setSameSite("Strict"); // 防止 CSRF
        return cookie;
    }
}

方案二:内存存储 + 请求头

Token 存放在 JavaScript 内存中,每次请求通过 Authorization 头部发送。需要配合 XSS 防护(如 CSP):

<!-- Content Security Policy -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self';">
维度HttpOnly CookielocalStorage
XSS 防护有效无效
CSRF 防护需要 SameSite + CSRF Token无需考虑
移动端支持原生支持需要桥接
跨域请求自动携带需手动添加

三、Token 永不过期

Access Token 设置过长的过期时间(甚至没有过期时间),会导致「签发即永久有效」的问题。

风险场景

用户 A 登录后获得 Token,过了半年用户 A 离职,但 Token 仍然有效。攻击者获取到这个 Token,就可以一直以用户 A 的身份访问系统。

解决方案:Refresh Token 机制

sequenceDiagram
    participant User as 用户
    participant App as 应用
    participant Auth as 认证服务器

    User->>App: 登录
    App->>Auth: 验证凭证
    Auth->>App: Access Token(15分钟)+ Refresh Token
    App->>App: 存储 Refresh Token

    loop Access Token 有效
        App->>API: 携带 Access Token 请求
    end

    Note over App,Auth: Access Token 即将过期
    App->>Auth: 使用 Refresh Token 换取新 Access Token
    Auth->>App: 新 Access Token

    Note over User,Auth: 用户修改密码/账号冻结
    Auth->>Auth: 使 Refresh Token 失效
    App->>Auth: 刷新请求被拒绝
    App->>User: 要求重新登录

Refresh Token 存储策略

RefreshTokenManager.java
public class RefreshTokenManager {
    private final RedisTemplate<String, RefreshTokenData> redisTemplate;

    public void saveRefreshToken(String userId, String refreshToken, Duration ttl) {
        RefreshTokenData data = new RefreshTokenData();
        data.setUserId(userId);
        data.setCreatedAt(Instant.now());
        data.setTokenVersion(getCurrentVersion(userId)); // 用于密码修改后失效

        String key = "refresh_token:" + refreshToken;
        redisTemplate.opsForValue().set(key, data, ttl);
    }

    public void revokeAllUserTokens(String userId) {
        // 递增版本号,使所有旧 Refresh Token 失效
        Long newVersion = redisTemplate.opsForValue()
            .increment("user:token_version:" + userId);

        // 记录版本变更时间,用于验证时比对
        redisTemplate.opsForValue().set(
            "user:version_timestamp:" + userId,
            Instant.now().toString(),
            Duration.ofDays(30)
        );
    }
}

四、无撤销机制

JWT 的无状态性是优势,但也带来了无法单方面撤销的难题。

场景分析

用户在线看视频,突然发现自己账号被盗用。但由于 Token 还在有效期内,系统无法立即阻止攻击者。

解决方案对比

方案原理代价
Token 黑名单jti 存入 Redis损失部分无状态性
短过期时间Access Token 15 分钟内过期增加刷新频率
Token 版本号密码修改时递增版本号配合数据库查询
用户在线列表记录活跃用户 + Token 版本需要额外存储

生产级黑名单方案

JwtBlacklistService.java
@Service
public class JwtBlacklistService {
    private final RedisTemplate<String, String> redisTemplate;
    private static final String BLACKLIST_PREFIX = "jwt:blacklist:";

    public void blacklistToken(String jti, Instant exp) {
        long ttlSeconds = exp.getEpochSecond() - Instant.now().getEpochSecond();
        if (ttlSeconds > 0) {
            // 设置过期时间等于 Token 剩余有效期
            redisTemplate.opsForValue().set(
                BLACKLIST_PREFIX + jti,
                "revoked",
                Duration.ofSeconds(ttlSeconds)
            );
        }
    }

    public boolean isBlacklisted(String jti) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + jti));
    }
}

五、敏感信息泄露

JWT 的 Payload 只是 Base64URL 编码,不是加密。任何人都可以解码查看内容。

危险示例

{
  "sub": "user123",
  "email": "ceo@company.com",
  "salary": 500000,
  "creditCard": "4111111111111111"
}

将敏感信息放入 JWT,等于向所有人公开。

安全实践

  1. 不存储敏感信息:密码、信用卡号、完整身份证号等绝不能放入 JWT。
  2. 仅存储必要标识:使用用户 ID 而非邮箱、手机号。
  3. 使用 JWE:对必须传输的敏感数据使用加密 JWT。
// 错误做法
Map<String, Object> claims = new HashMap<>();
claims.put("password", user.getPassword()); // 绝不能这样做

// 正确做法
Map<String, Object> claims = new HashMap<>();
claims.put("sub", user.getId());
claims.put("email", user.getEmail()); // 邮箱可以,但密码不行

六、实战:JWT 安全检查清单

签发侧

  • 强制使用非对称算法(RS256/ES256)而非对称算法(HS256)
  • 禁止 none 算法
  • 设置合理的过期时间(Access Token <= 1 小时)
  • 设置 iss(签发者)和 aud(受众)声明
  • 使用 jti 作为唯一标识符
  • 不在 Payload 中存储敏感信息

验证侧

  • 强制要求特定算法,不接受动态算法
  • 验证 exp(过期时间)
  • 验证 nbf(生效时间)
  • 验证 aud(受众)是否匹配
  • 验证 iss(签发者)是否可信
  • 使用恒定时间比较签名
  • 记录验证失败的日志(用于安全审计)

存储侧

  • Access Token 使用 HttpOnly Cookie 或安全的前端存储
  • Refresh Token 使用加密存储
  • 所有传输使用 HTTPS

思考题

问题 1:某公司系统遭受到「Token 重放攻击」,攻击者截获了有效的 Access Token 后反复使用直到过期。如何在不改变 JWT 协议本身的情况下解决这个问题?

参考答案

有几种有效方案:

方案一:nonce + 时间窗口验证

在请求中加入一次性随机数(nonce)和时间戳,验证方记录已使用的 nonce 和时间窗口内的请求:

public class ReplayProtection {
    private final RedisTemplate<String, Long> redisTemplate;

    public boolean validateAndRecord(String jti, Instant iat) {
        // 检查是否在允许的时间窗口内(防止旧 Token 重放)
        long ageSeconds = Instant.now().getEpochSecond() - iat.getEpochSecond();
        if (ageSeconds > MAX_TOKEN_AGE_SECONDS) {
            return false;
        }

        // 检查 nonce 是否已使用(防止同一 Token 重放)
        String nonceKey = "used_nonce:" + jti;
        Boolean isNew = redisTemplate.opsForValue().setIfAbsent(
            nonceKey,
            1L,
            Duration.ofSeconds(MAX_TOKEN_AGE_SECONDS)
        );
        return Boolean.TRUE.equals(isNew);
    }
}

方案二:JWT 用量限制

结合设备指纹和用户 ID,记录每个 Token 的使用次数或频率,超过阈值则拒绝:

// 记录 Token 使用
String usageKey = "token_usage:" + jti;
Long count = redisTemplate.opsForValue().increment(usageKey);
if (count > MAX_USAGE_COUNT) {
    throw new SecurityException("Token usage limit exceeded");
}

最佳实践:组合使用 nonce + 短过期时间 + 频率监控,形成多层防护。

问题 2:为什么说「将 JWT 存放在 localStorage」比「存放在 Cookie 中」更容易受到攻击?在什么情况下 localStorage 反而是更好的选择?

参考答案

localStorage 更易受攻击的原因

  1. XSS 获取 localStorage 无需特殊条件。只要页面存在 XSS 漏洞,localStorage.getItem('token') 就可以直接获取 Token。

  2. Cookie 的 HttpOnly 屏障。设置 HttpOnly=true 后,JavaScript 无法读取 Cookie,XSS 漏洞无法直接获取 Token。

localStorage 反而更合适的场景

  1. API 开放平台。如果后端 API 需要被第三方 JavaScript 应用调用,Cookie 无法跨域传递,只能使用 Authorization 头部或 localStorage。

  2. 纯静态 SPA。后端不维护会话,希望完全由前端控制 Token 生命周期,且前端有完善的 CSP 防护 XSS。

  3. 多个子域共享。localStorage 可以通过 postMessage 安全地在同源页面间共享,而 Cookie 的跨域需要特殊配置。

总结:localStorage 本身不是问题,XSS 才是。只要做好 XSS 防护(输入校验、输出编码、CSP),两种方案都可以安全使用。