tva
← コラム

シングルページアプリケーションでトークン更新の競合を防ぐ

理由も分からずログアウトされます。localStorage内のセッショントークンは有効に見え、数秒前のリクエストも成功しているのに、再現パターンは非常に不規則です。ただし、複数のAPI呼び出しを同時に行うページの読み込み時に集中する傾向があります。これはシングルページアプリケーションの本番環境で特に分かりにくいバグの一つで、よくある根本原因の一つがトークン更新の競合状態です。

原因が分かれば仕組みは単純です。JWTには有効期限があります。セッション中に期限が切れたら、リフレッシュトークンを新しいアクセストークンと交換し、利用者に意識させず元のリクエストを再試行するのが正しい動作です。しかしReactアプリケーションが一度に一つだけリクエストすることはまれです。ダッシュボードでは、コンポーネントのマウント時に、ユーザーデータ、アカウント概要、通知件数、最近のアクティビティを並列に取得することがあります。その間にアクセストークンが切れると、すべてが401を受け取り、それぞれが期限切れを検知して更新エンドポイントを呼び出します。最初は成功し、古いリフレッシュトークンが消費されます。次の呼び出しは、すでに無効になった同じトークンを使うため失敗し、ユーザーがログアウトされます。

開発中に表面化しにくい理由

競合には、実際に同時実行される複数のリクエストと、間の悪いタイミングでの期限切れが重なる必要があります。開発環境では通常、有効期間が長く設定されています。localhostの遅延もほぼゼロなので、リクエストはすぐ完了し、完全に重なることは少なくなります。開発者も、データの多いページ全体ではなく、一つの機能ずつ試すことが多いものです。そのため開発中は隠れ、本番の実際の利用で表面化します。特に、初期トークンがセッション中に切れるほど長く利用しているユーザーが影響を受けます。

意図的に再現するには、有効期間を短くして、データを並列取得するページを開きます。Supabaseでは、プロジェクト設定のjwt_expiryがアクセストークンの有効期間を制御します。60秒に設定し、複数クエリを持つダッシュボードを開いて待ちます。操作を続けると、通常は最初の数分以内に競合が発生しますが、発生までの時間は状況によって異なります。

Supabase認証での現れ方

Supabaseの@supabase/supabase-js v2クライアントは内部でトークンを更新し、更新処理を直列化します。処理中のPromiseを一つだけ保持し、同時に呼び出す処理を待機させます。すべてのAPI呼び出しをSupabaseクライアントだけで行うアプリなら、この機能で十分なことが多いものです。しかし多くの本番アプリは、自社バックエンドAPI、fetchで呼ぶエッジ関数、アクセストークンをヘッダーで渡す外部連携など、Supabaseクライアントの外でも認証付きリクエストを行います。セッションからトークンを取り出して別の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のローカルセッションを消去します。これにより、UIはログイン済みと表示するのに全リクエストが失敗する中途半端な状態ではなく、明確に未認証の状態になります。

React Contextを同期する

上記の方法はHTTP層でトークンを管理しますが、Reactアプリでは、条件付き表示、子コンポーネントへのユーザー情報の受け渡し、トークンを直接読むAxios以外の呼び出しのため、現在のセッションをContextでも使うことがよくあります。トークンをReactのstateに保存し、インターセプターと同期させると、正本が二つになり、再レンダー時に食い違う可能性があります。

より明快な方法は、正本となるトークンをインターセプターが同期的にアクセスできるrefに保持し、Reactの購読処理でUIの状態を別に管理することです。

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>;
}

stateではなくrefを使うと、再レンダーを発生させず、現在のトークンを同期的に読めます。コールバックが作成時のstate値を保持する、古いクロージャーの問題も避けられます。onAuthStateChangeの購読により、セッションの変化に合わせてrefとReactのstateの両方を一致させます。この例の二つ目のEffectにはクリーンアップがありません。setupInterceptorsからインターセプターを解除する関数(Axiosではinterceptors.response.eject(id)を使用)を返し、その関数をEffectから返してください。そうしないと、React Strict Modeや再マウントによって二重登録が起こります。

Supabaseが保護する範囲

Supabaseクライアント内部の直列化がどこまで保護するかは、明確にしておく必要があります。対象はクライアント自身のメソッドによる呼び出しで、supabase.from()、supabase.rpc()、ストレージ操作、リアルタイム購読、認証メソッドが含まれます。内部で更新用Promiseを一つだけ維持し、同時呼び出しを待機させるため、複数箇所で同時にsupabase.auth.getSession()を呼んでも、一つの更新処理が完了すれば、すべてが更新済みセッションを正しく受け取ります。

保護の対象外なのは、手動で組み立てた認証付きfetch呼び出しにセッションのaccess_tokenを使う場合、Supabaseから独立して設定したHTTPクライアント、そして前述の複数クライアントインスタンスです。別のAPI層に渡すためアクセストークンを取り出す設計では、そこで競合防止の保護範囲が終わります。上記のパターンはこの境界で必要になります。

タブ間の動作

ブラウザーのタブ間でも競合が起こります。アプリを2タブで開き、両方が同時に期限切れを検知すると、各タブのセッション管理が独立して更新を試みます。多くの認証実装はlocalStorageのイベントで対応します。一方が新しいトークンを保存すると、もう一方はstorageイベントを検知し、競合する更新を始めずローカルセッションを更新します。ブラウザーでは、supabase-js v2は同じストレージキーを共有する他のタブに認証イベントを配信します(セッションを永続化する場合はBroadcastChannelを使用)。そのため、他のタブでもonAuthStateChangeが発火します。これはdetectSessionInUrlとは無関係で、この設定はクライアントがページのURLからOAuthの認可情報を読み取るかどうかだけを制御します。

ただし、すべての開いているタブでクライアントを正しく初期化し、認証状態の変化を購読していることが前提です。遅延初期化するアプリや、マウント時のセッションをキャッシュするだけで、onAuthStateChangeによる継続的な変更を購読しないアプリは、タブ間の更新を見逃します。2つ目のタブは消費済みのリフレッシュトークンで更新を試み、そのタブのユーザーがログアウトされます。対策は、すべてのタブで初期化時にonAuthStateChangeを購読し、ルート移動後も残るモジュール単位の状態にセッショントークンをキャッシュしないことです。

本番環境で見分ける方法

トークン更新の競合は、明確な形では現れません。認証されるはずのエンドポイントで散発的に401が出る、利用状況の分析で予期しないセッション終了が見える、利用者から突然ログアウトされたと報告される、といった症状になります。個々の失敗は、システム全体の並行処理の問題ではなく、単発の認証問題に見えます。そのためチームは、本当の原因にたどり着く前に、有効期限の設定、Cookie設定、ネットワークを調べてしまいがちです。

発生条件は、並列リクエストと期限切れの重なりです。複数のデータソースから同時に読み込むページはすべて候補になります。ダッシュボード、フィード画面、複数の子コンポーネントが独立してuseEffectでデータを取得する構成では、並列リクエストが発生します。React QueryやSWRでも、複数クエリを同時にマウントするページで同じ条件が生じます。見るべき点が分かれば対策は単純ですが、認証設定ではなく並行処理の問題として捉える必要があります。インターセプターのキューとPromiseの共有は、どちらも確立された方法です。重要なのは、同時に進行する更新処理を最大一つにし、すべての呼び出し元が別の更新を始めず、その結果を待つという不変条件です。

関連記事

  • JWTの構造と有効期限 —アクセストークンとリフレッシュトークンの役割は異なります。前者は短命でステートレス、後者は長命で、通常はローテーションポリシーの下で1回限りの使用を想定し、サーバー側に保存します。たとえばSupabaseは、短い期間内(既定では10秒)のみ再利用を許可し、その期間外での再利用を検知するとセッションを無効にします。この違いを理解することで、有効期間をどれだけ短くするか、更新失敗時にどうするかを判断できます。
  • SupabaseのRow Level Securityとアクセストークン — RLSポリシーは、要求するユーザーのJWTクレームを評価します。Supabaseは期限切れトークンを401で拒否します。一方、競合によってトークンなしで送信されたリクエストはanonロールで実行され、RLSは通常、401ではなく空の結果や権限エラーを返すため、根本原因を見つけにくくなります。
  • React Queryによる並列取得 —同じページの複数コンポーネントから呼ぶReact QueryのuseQueryフックは、既定で同時に実行されます。共有クエリクライアントにカスタムのqueryFnを設定し、その中でgetValidAccessToken()を呼ぶことで、すべてのクエリが同じ更新直列化層を通るようにできます。
  • サービスワーカーとトークンのキャッシュ — APIレスポンスをキャッシュするサービスワーカーを使うアプリでは、認証状態の一致を保つ場所がもう一つ増えます。古い認証ヘッダー付きのリクエストをキャッシュすると、トークン更新後も古いレスポンスが返り、認証処理が原因とは分かりにくい、微妙なデータの不整合が生じます。

関連記事