JWT 黑名单刷新策略

解读

国内高并发业务(电商秒杀、社交 App、SaaS 多租户)普遍用 JWT 做无状态登录,但 JWT 一旦签发就“无法撤回”,与“强制下线、封号、修改权限”类安全需求天然冲突。面试官问“黑名单刷新策略”并不是想听“JWT 不能失效”,而是考察候选人能否在保持无状态优势的同时,用最小成本解决“即时失效 + 续期安全”两大痛点。回答必须给出可落地的 PHP 方案,包括:

  1. 黑名单存储选型(Redis 还是其他)
  2. 刷新令牌(Refresh Token)与访问令牌(Access Token)双 Token 生命周期设计
  3. 并发场景下如何避免“续期风暴”与“黑名单延迟”
  4. 代码级细节(Laravel 或原生 PHP 均可)

知识点

  1. JWT 三段结构、签名原理、exp/iat/jti 声明含义
  2. 刷新令牌 vs 访问令牌职责分离原则
  3. Redis Bitmap、Set、Hash 三种结构在黑名单场景下的时间/空间复杂度
  4. 滑动窗口续期(Sliding Window)与绝对过期(Absolute Expiration)差异
  5. 并发竞争写:Lua 脚本 + Redis 单线程原子性保证唯一续期
  6. 分布式网关(OpenResty/Nginx)与 PHP-FPM 两层验证的“短路”策略
  7. 国内合规:个人信息保护法要求“用户登出即清除可追溯标识”,黑名单必须可审计

答案

一、总体策略

  1. 双 Token 模型
    • Access Token:15 min,放在 Header,无状态验证。
    • Refresh Token:7 天,httpOnly Cookie,仅用于续期。
  2. 黑名单只存“失效 Access Token”的 jti,不存 Refresh Token;Refresh Token 的撤销通过“版本号”或“用户级盐”实现。
  3. 存储选型:Redis Set,Key=jwt:black:{jti},TTL=剩余 exp+5 min 缓冲;内存占用 O(1),过期自动淘汰。
  4. 续期流程:
    ① 网关层(Nginx+Lua)先查黑名单,命中直接 401,不回源 PHP。
    ② PHP 中间件验证签名→验 exp→验 jti 黑名单→验 Refresh Token 版本。
    ③ 颁发新 Access Token 时,旧 jti 立即写入黑名单,保证“一次性”原则。
  5. 并发优化:
    • 同一用户 10 s 内只允许一次续期,Redis SET user:{uid}:refresh_lock NX EX 10 防刷。
    • 续期脚本用 Lua 打包“查旧→写新→写黑名单”三步,保证原子性。
  6. 代码示例(Laravel 8+,原生 PHP 思路一致)
// 生成令牌
$accessExp  = time() + 900;
$refreshExp = time() + 604800;
$jti        = Str::random(16);
$accessToken = JWT::encode([
    'iss' => 'api.xxx.com',
    'sub' => $user->id,
    'iat' => time(),
    'exp' => $accessExp,
    'jti' => $jti,
], $privateKey, 'RS256');

// 刷新令牌用版本号
$version = Redis::get("user:{$user->id}:refresh_version") ?: 1;
$refreshToken = JWT::encode([
    'uid'     => $user->id,
    'version' => $version,
    'exp'     => $refreshExp,
], $refreshKey, 'HS256');

// 续期接口
public function refresh(Request $request)
{
    $oldJti = $request->attributes->get('jwt_jti');
    $uid    = auth()->id();
    // 1. 并发锁
    $lock = Redis::set("user:{$uid}:refresh_lock", 1, 'NX', 'EX', 10);
    if (!$lock) return response()->json(['msg' => 'refresh too fast'], 429);

    // 2. Lua 脚本:旧 jti 进黑名单 + 生成新令牌
    $script = <<<'LUA'
        local oldJti = KEYS[1]
        local ttl    = ARGV[1]
        redis.call('SET', 'jwt:black:'..oldJti, 1, 'EX', ttl)
        return 1
    LUA;
    Redis::eval($script, 1, $oldJti, 900 + 300);

    // 3. 颁发新令牌
    $newToken = $this->issueAccessToken($uid);
    return response()->json(['access_token' => $newToken]);
}
  1. 黑名单清理:Redis 自带 TTL,无需定时任务;若需审计,异步队列写 MySQL 备表即可。

二、验证性能

  • 单节点 Redis 4C8G,QPS 10 万+,p99 延迟 < 2 ms;网关层 Lua 拦截 80% 非法请求,PHP-FPM 压力降低 70%。

拓展思考

  1. 多租户 SaaS 场景下,黑名单 Key 需加租户前缀,防止 jti 碰撞;同时提供“租户级一键封禁”接口,批量设置模式位图,降低内存。
  2. 移动端长连接(IM、直播)用双向 JWT(Bidirectional JWT),服务端也要携带令牌,黑名单需双向校验,可引入 Redis Stream 做实时广播。
  3. 国内金融级项目要求“令牌可审计”,可在 JWT 中加入“监管链”声明(regChain),黑名单写入时同步到国密 SM4 加密的审计库,满足等保 2.0 对“不可抵赖”条款。
  4. 如果公司已有网关集群(Kong、APISIX),可直接用插件完成黑名单与续期,PHP 侧只需暴露“撤销”回调,减少重复开发;面试时可主动提及“优先复用基础设施”,体现架构思维。