不能只用refresh_token调用/refresh接口,因并发请求易触发401:A请求持旧token未响应时,B已更新token并使旧token失效,导致A被拒;须采用双Token+时间差+静默续期方案,确保原子性替换、jti黑名单防重放、Redis锁防竞态,并区分两类401错误。

为什么不能只用 refresh_token 调用 /refresh 接口就完事
因为单纯换新 access_token 会导致并发请求出现 401 —— 前端发了两个请求,A 拿着旧 token 正在跑,B 已经拿到新 token 并更新了本地存储,但 A 还没收到响应,服务端却已废弃旧 token(比如设置了单次使用或时间窗口校验),结果 A 直接被拒。
真正可行的方案是「双 Token + 时间差 + 静默续期」:让 access_token 短期有效(如 15 分钟),refresh_token 长期有效(如 7 天),且刷新时服务端必须验证 refresh_token 的签名、过期时间、绑定设备/指纹(可选),同时返回一对新 token,前端原子性替换本地两个值。
-
refresh_token必须存储在HttpOnlyCookie 中(防 XSS 泄露),而access_token可放内存或 localStorage(仅用于请求头) - 刷新接口必须校验
refresh_token的jti是否在黑名单(防止重复使用),建议配合 Redis 存储已用过的jti,TTL 设为略长于access_token有效期 - 不要在每次请求都无条件刷新 —— 只有当
access_token剩余有效期
PHP 中如何安全生成和验证 JWT 双 Token
别直接手撸 base64 + hash,用 firebase/php-jwt 且必须指定算法白名单。JWT 默认支持多种算法,攻击者若篡改 alg 字段为 none,就能绕过签名验证 —— 所以 JWT::decode() 第三个参数必须传入严格限定的数组,例如 ['HS256']。
生成示例关键点:
立即学习“PHP免费学习笔记(深入)”;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$payload = [
'user_id' => 123,
'exp' => time() + 900, // access_token 15 分钟
'iat' => time(),
'jti' => bin2hex(random_bytes(16)), // 防重放,存入 Redis 校验
];
$access_token = JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256');
$refresh_payload = [
'user_id' => 123,
'exp' => time() + 604800, // refresh_token 7 天
'jti' => bin2hex(random_bytes(16)), // 单独生成,和 access_token 不共用
'device_hash' => hash('sha256', $_SERVER['HTTP_USER_AGENT'] ?? '' . $_SERVER['REMOTE_ADDR']),
];
$refresh_token = JWT::encode($refresh_payload, $_ENV['REFRESH_SECRET'], 'HS256');
-
access_token和refresh_token必须用不同密钥(JWT_SECRETvsREFRESH_SECRET),降低密钥泄露影响面 -
jti字段必须唯一且不可预测,刷新成功后立即把旧jti写入 Redis,设置 TTL 为access_token有效期 + 30 秒,用于后续access_token解析时拦截已刷新的旧 token - 不要把用户敏感字段(如密码哈希、手机号)塞进 payload,只放必要标识
刷新接口如何避免 race condition 导致 token 被覆盖或失效
前端多个标签页或请求并发调用 /api/auth/refresh 时,PHP 后端若不加锁,可能产生「先查后写」竞争:两个请求同时读到同一个未过期 refresh_token,都生成新 token 对,但只有最后一个写入 Redis 的 jti 黑名单生效,前一个的旧 jti 就漏掉了,导致它对应的 access_token 还能继续用。
解决方式不是靠 PHP 层 sleep 或 file_get_contents 锁文件,而是利用 Redis 的原子操作:
- 用
SET key value EX 300 NX(即setnx+ 过期)抢占刷新资格,key 可为refresh_lock:{user_id}:{jti_prefix},value 任意,TTL 设为 5 分钟足够覆盖整个刷新流程 - 抢锁失败则直接返回
429 Too Many Requests,前端应退避重试(如 500ms 后) - 抢锁成功后,再校验
refresh_token是否已被使用(查 Redisjti:used:{jti})、是否过期、是否匹配device_hash - 全部通过才生成新 token 对,并把旧
jti加入黑名单,最后清空锁 key
前端无感刷新的真实落地难点在哪
不是逻辑写不出来,而是 HTTP 请求生命周期和错误捕获时机容易错位。比如 Axios 拦截器中,对 401 响应发起刷新请求,但这个刷新请求本身又 401(refresh_token 过期),此时若没分层处理,就会无限递归或卡死。
- 必须区分两种 401:一种是
access_token失效(走刷新流程),另一种是refresh_token失效(强制登出),后者响应体里要有明确字段如{"error": "refresh_expired"} - 刷新请求要用独立的 Axios 实例(不走同一套拦截器),否则会再次触发 401 拦截,形成循环
- 所有原始请求需挂起(Promise 缓存),等刷新完成统一 resolve/reject,不能每个请求都各自发刷新 —— 否则 10 个并发请求会发 10 次刷新
- localStorage 里存
access_token时,要监听 storage 事件,在其他标签页刷新后同步更新本页内存中的 token,否则仍会发旧 token
最常被忽略的是:刷新成功后,原请求的 headers 里的 Authorization 已固定为旧值,必须手动 clone request config 并注入新 token,再用新 config 重发 —— 不是简单地 return axios(config) 就完事。



















