tva
← 洞察

在单页应用中防止令牌刷新竞态条件

用户莫名其妙地退出登录。localStorage 中的会话令牌看起来有效,几秒前请求还成功过,问题出现得毫无规律,令人头疼——但往往集中在页面加载时同时触发多个 API 调用的场景。这是单页应用中较难理解的一类生产故障,而一个常见的根本原因是令牌刷新竞态条件。

一旦识别出问题,机制就很清楚。JWT 会过期。令牌在会话中途过期时,正确的行为是用刷新令牌换取新的访问令牌,再对用户透明地重试原请求。但实际上,React 应用很少一次只发一个请求。仪表盘页面可能在组件挂载时同时获取用户数据、账户概况、通知数量和近期活动,所有请求并行发出。如果访问令牌恰好在这批请求期间过期,每个请求都会收到 401。它们各自检测到令牌过期,各自尝试调用刷新端点。第一个调用成功,旧刷新令牌随之被消耗。第二个调用继续使用同一个、此时已失效的刷新令牌,因而失败。用户于是退出登录。

为什么开发环境中很少出现

这种竞态条件需要特定条件同时发生:多个真正并发的请求,恰好遇上令牌在不合适的时刻过期。开发环境中的令牌有效期通常较长,localhost 延迟接近零,请求很快完成,很少完全重叠,而且大多数开发人员会逐项测试功能,而不是加载数据密集型页面。因此,这个问题在开发环境中不易暴露,却会在生产环境的真实使用模式下出现——最常受影响的是使用时间足够长、初始令牌在会话中途过期的用户。

要刻意复现它,需要缩短令牌有效期,并访问并行获取数据的页面。在 Supabase 中,项目配置里的 jwt_expiry 设置控制访问令牌的有效期。将其设为六十秒,打开有多个查询的仪表盘页面,然后等待。这种竞态条件通常会在持续使用的最初几分钟内出现,但具体时间会有所不同。

它在 Supabase 认证中如何表现

Supabase 的 @supabase/supabase-js v2 客户端会在内部处理令牌刷新,并包含串行化逻辑:维护一个正在执行的刷新 Promise,让并发调用者排队等待它。对于所有 API 调用都只通过 Supabase 客户端发出的应用,这种内置处理通常已经足够。但实际上,多数生产应用发展到一定阶段后,都会在 Supabase 客户端之外发出已认证请求,例如调用自己的后端 API、通过 fetch 访问边缘函数,或调用需要在请求头中传入访问令牌的第三方集成。一旦从会话中提取访问令牌,并直接用于独立的 HTTP 层,就超出了客户端内部串行化机制的保护范围。

另一种故障出现在 Supabase 客户端未正确以单例方式初始化时。如果模块求值创建了多个客户端实例——某些 SSR 场景或模块被重新求值的测试环境中可能发生——这些实例就不会共享内部刷新状态。两个客户端实例在各自的请求返回 401 时,会分别尝试刷新,即使有内置串行化机制,竞态条件也会再次出现。

单例 Promise 模式

一种简洁的解决方案,是将正在执行的刷新操作保存为 Promise,并在它完成之前,向所有调用者返回同一个 Promise。这样,调用者不会各自发起新刷新,而是共同等待同一次操作。实现很精简:

let refreshPromise: Promise<string> | null = null;

async function getValidAccessToken(): Promise<string> {
  const { data } = await supabase.auth.getSession();
  const token = data.session?.access_token;

  if (token && !isTokenExpired(token)) {
    return token;
  }

  if (!refreshPromise) {
    refreshPromise = supabase.auth
      .refreshSession()
      .then(({ data, error }) => {
        if (error || !data.session) {
          throw error ?? new Error("Token refresh failed");
        }
        return data.session.access_token;
      })
      .finally(() => {
        refreshPromise = null;
      });
  }

  return refreshPromise;
}

function isTokenExpired(token: string): boolean {
  try {
    const payload = JSON.parse(atob(token.split(".")[1]));
    // 30-second buffer avoids tokens that are valid now but expire before the request completes
    return payload.exp * 1000 < Date.now() + 30_000;
  } catch {
    return true;
  }
}

finally 块是关键:无论 Promise 成功还是失败,它都会清空共享引用,让下一轮刷新能够重新开始。没有这一步,一个被拒绝的刷新 Promise 就会在模块的整个生命周期内阻塞后续刷新。过期检查中的 30 秒缓冲,用于处理一个细微边界情况:令牌通过 isTokenExpired 检查后,却在请求到达服务器前的几百毫秒内过期,就会返回 401,并不必要地触发另一轮刷新。这个辅助函数有两点需要注意:JWT 各段使用 base64url 编码,而 atob 仅接受标准 base64,因此解码前应将 - 和 _ 分别转换为 + 和 /,否则某些有效令牌会在每次调用时都被视为已过期;此外,缺少 exp 声明的令牌在比较中会被判为未过期,因此应明确拒绝缺少 exp 的令牌。

拦截器队列方案

对于使用 Axios 的应用,常见模式是在响应拦截器中封装相同的串行化逻辑。这种方式会自动处理重试:失败请求先排队,刷新完成后再处理队列,将新令牌应用到每个待处理请求的配置,然后重试:

import axios, { type AxiosError, type AxiosRequestConfig } from "axios";

let isRefreshing = false;
let pendingQueue: Array<{
  resolve: (token: string) => void;
  reject: (err: unknown) => void;
}> = [];

function drainQueue(error: unknown, token: string | null): void {
  pendingQueue.forEach(({ resolve, reject }) => {
    if (error) reject(error);
    else resolve(token!);
  });
  pendingQueue = [];
}

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
});

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const original = error.config as AxiosRequestConfig & { _retry?: boolean };

    if (error.response?.status !== 401 || original._retry) {
      return Promise.reject(error);
    }

    if (isRefreshing) {
      // Queue this request — it will be retried once the in-flight refresh completes
      return new Promise<string>((resolve, reject) => {
        pendingQueue.push({ resolve, reject });
      }).then((token) => {
        original.headers = { ...original.headers, Authorization: `Bearer ${token}` };
        return api(original);
      });
    }

    original._retry = true;
    isRefreshing = true;

    try {
      const { data, error: refreshError } = await supabase.auth.refreshSession();
      if (refreshError || !data.session) throw refreshError ?? new Error("Refresh failed");

      const newToken = data.session.access_token;
      drainQueue(null, newToken);

      original.headers = { ...original.headers, Authorization: `Bearer ${newToken}` };
      return api(original);
    } catch (err) {
      drainQueue(err, null);
      // Clear the local Supabase session so the app reaches a clean unauthenticated state
      await supabase.auth.signOut();
      return Promise.reject(err);
    } finally {
      isRefreshing = false;
    }
  }
);

触发刷新的请求上的 _retry 标志可防止无限循环:如果重试后的请求仍返回 401,例如新令牌本身因某种原因无效,拦截器会直接传递错误,而不是再触发刷新。在此示例中,刷新进行期间排队的请求不会设置该标志。如果不希望重试后再次失败的请求启动新一轮刷新,也应在排队分支中设置 original._retry = true。刷新失败时显式调用 signOut(),可以确保清空 Supabase 的本地会话状态,使应用进入明确的未认证状态,避免界面认为用户已登录、但所有请求都失败的尴尬局面。

保持 React Context 同步

上述两种模式都在 HTTP 层管理令牌,但 React 应用通常还需要在上下文中提供当前会话,用于条件渲染、向子组件传递用户信息,或供非 Axios 调用直接读取令牌。难点在于,将令牌保存在 React 状态中,再让拦截器与之同步,会形成两个事实来源,它们可能在重新渲染时偏离。

更清晰的做法是将作为唯一依据的令牌保存在 ref 中,让拦截器能够同步访问,同时由 React 订阅单独处理界面状态:

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const tokenRef = useRef<string | null>(null);

  useEffect(() => {
    supabase.auth.getSession().then(({ data }) => {
      setSession(data.session);
      tokenRef.current = data.session?.access_token ?? null;
    });

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, newSession) => {
        setSession(newSession);
        tokenRef.current = newSession?.access_token ?? null;
      }
    );

    return () => subscription.unsubscribe();
  }, []);

  // Pass tokenRef to your interceptor setup so it reads the current token
  // synchronously rather than going async through state
  useEffect(() => {
    setupInterceptors(tokenRef);
  }, []);

  return <AuthContext.Provider value={{ session }}>{children}</AuthContext.Provider>;
}

使用 ref 而非 state,意味着拦截器可以同步读取当前令牌,不会触发重新渲染,也不会遇到陈旧闭包问题——回调在创建时捕获了某个特定状态值,却在之后继续使用旧值。随着会话变化,onAuthStateChange 订阅会保持 ref 和 React 状态一致。示例中的第二个 effect 没有清理逻辑;应让 setupInterceptors 返回一个移除拦截器的函数(Axios 中使用 interceptors.response.eject(id)),并在 effect 中返回该函数,否则 React Strict Mode 和重新挂载会导致重复注册。

Supabase 会处理什么,哪些需要自行处理

有必要明确 Supabase 客户端内部串行化机制的保护范围。它覆盖通过客户端自身方法发出的调用:supabase.from()、supabase.rpc()、存储操作、实时订阅,以及认证方法本身。客户端在内部维护一个刷新 Promise,让并发调用者排队,因此从多个位置并发调用 supabase.auth.getSession() 时,所有调用都能在这次刷新完成后正确获得刷新后的会话。

不在保护范围内的情况包括:手动使用会话中的 access_token 构造的已认证 fetch 调用、任何独立于 Supabase 客户端配置的 HTTP 客户端,以及前文提到的多个 Supabase 客户端实例。如果架构需要提取访问令牌并传给独立 API 层,那么竞态条件保护就到提取点为止。此时就需要使用上述模式。

跨标签页行为

这种竞态条件还有一个发生在浏览器标签页之间的变体。如果用户在两个标签页中打开应用,且两个标签页同时检测到令牌过期,各自的会话管理都会独立尝试刷新。大多数认证实现通过 localStorage 事件处理这种情况:一个标签页将新令牌写入存储,另一个标签页检测到 storage 事件后更新本地会话状态,而不是发起竞争性刷新。在浏览器中,supabase-js v2 会向共享同一存储键的其他标签页广播认证事件(持久化会话时通过 BroadcastChannel),因此 onAuthStateChange 也会在那些标签页中触发。这与 detectSessionInUrl 无关,后者仅控制客户端是否从页面 URL 中读取 OAuth 授权信息。

但实际上,只有每个活动标签页都正确初始化 Supabase 客户端并订阅认证状态变化,这种方式才有效。如果应用延迟初始化客户端,或只在挂载时缓存会话,却不通过 onAuthStateChange 订阅后续变化,就会错过跨标签页更新。第二个标签页会使用已被消耗的刷新令牌自行尝试刷新,导致用户在该标签页退出登录。解决方法相同:确保每个标签页都在初始化时订阅 onAuthStateChange,并且不要将会话令牌缓存在跨路由导航长期保留的模块级状态中。

如何在生产环境中识别它

令牌刷新竞态条件不会明确表明自己的存在。它可能表现为错误监控中本应已认证的端点偶尔返回 401、分析数据中会话意外终止,或用户报告随机退出登录。每次失败单独看都像是一次偶发认证问题,而非系统性的并发问题。因此,团队常常会先花时间排查令牌过期配置、cookie 设置或网络状况,之后才找到真正原因。

它的前置条件很明确:并行请求与令牌过期同时发生。任何同时从多个来源加载数据的页面,都可能触发它。仪表盘、动态流视图,或多个子组件各自通过 useEffect 获取数据的组件树,都会产生这种并行请求模式。在 React Query 或 SWR 应用中,同时挂载多个查询的页面也会形成相同条件。知道该查什么后,修复就很直接——但必须把它视为并发问题,而不是认证配置问题。拦截器队列和单例 Promise 都是成熟的模式,两者都可行。关键的不变量是:任何时刻最多只能有一个正在执行的刷新操作,所有调用者都等待其结果,而不是发起竞争性刷新。

相关洞察

  • JWT 结构与过期机制——访问令牌和刷新令牌用途不同。访问令牌有效期短且无状态;刷新令牌有效期长,在轮换策略下通常只能使用一次,并保存在服务端。例如,Supabase 仅允许在一个短暂的时间窗口内重复使用(默认为 10 秒),若检测到窗口之外的重复使用,就会撤销会话。理解这种区别,有助于决定过期时间窗口应设得多短,以及刷新失败后应如何处理。
  • Supabase 行级安全与访问令牌——RLS 策略会评估请求用户的 JWT 声明。Supabase 会以 401 拒绝过期令牌,但如果竞态条件导致请求完全没有令牌,请求就会以 anon 角色运行,此时 RLS 通常会返回空结果或权限错误,而不是 401,这会使根本原因更难识别。
  • React Query 与并行获取数据——同一页面中多个组件调用的 React Query useQuery Hook,默认会并发执行。配置共享查询客户端,并让自定义 queryFn 调用 getValidAccessToken(),可以确保所有查询都经过同一令牌刷新串行化层。
  • Service Worker 与令牌缓存——使用 Service Worker 缓存 API 响应的应用,会多出第三处需要保持一致的认证状态。如果 Service Worker 缓存了带有陈旧授权头的请求,令牌刷新后仍可能返回过时响应,造成细微的数据不一致,而且很难将原因归结到认证处理上。

延伸阅读