単一のユーザーが開いた5つのタブが、すべて同じミリ秒に期限切れのJWTをリフレッシュしようとした際、トークントラップ(token trap)が本番サイトで発生しました。これにより、バックエンドに重複したリフレッシュリクエストが殺到し、セッションが即座に無効化されました。すべてのタブでユーザーがログアウトしてしまい、トークン更新における単一タブ向けの解決策ではもはや不十分であることが証明されました。
なぜトークントラップが問題なのか
現代のシングルページアプリケーション(SPA)は、有効期限の短いアクセストークンと、有効期限の長いリフレッシュトークンを使用します。アクセストークンが期限切れになると、クライアントはリフレッシュリクエストを送信して新しいトークンペアを受け取り、元の呼び出しを再試行します。ほとんどの開発者は、このフローをメモリ内のフラグ(例:isRefreshing = true)やリクエストキューで保護し、単一のタブでテストを行います。しかし、現実の世界では、ユーザーは設定ページ、分析ダッシュボード、いくつかのデータビューなど、複数のタブを開いたままにしています。アクセストークンが期限切れになると、各タブが独立して401エラーを検出し、それぞれがリフレッシュリクエストを送信します。すると、バックエンド(特にリフレッシュトークンのローテーションを強制している場合)は、2番目のリクエストをリプレイとみなし、セッション全体を無効にします。
JavaScriptの分離が問題を引き起こす
各ブラウザタブは、独自のJavaScriptコンテキストで動作します。変数、タイマー、メモリ内のフラグは、たとえ同じオリジンを共有していても、他のタブからは見えません。「リフレッシュが既に進行中である」ことを示すフラグは、それを設定したタブの中にしか存在しません。他のタブは、別の場所でトークンがリフレッシュされていることを知る術がないため、すべてが独自のネットワークコールを開始してしまいます。この問題はインターセプターのバグではなく、クライアントサイドの状態分離(state isolation)における根本的な制限なのです。
Web Locks APIによる解決策
あるタブがロックを保持しているとき、同じロックを要求する他のタブは、そのロックが解放されるまで待機しなければなりません。
トークンリフレッシュにおける仕組み
- 401を検知 – 認証エラーのレスポンスを受け取ったタブは、
navigator.locks.request('auth_token_refresh_lock', async lock => { … })を呼び出します。 - ロックを取得 – 他のタブがロックを保持していなければ、現在のタブが処理を続行します。保持されている場合は、ロックが解放されるまで一時停止します。
- 一度だけリフレッシュ – ロックを保持しているタブがリフレッシュリクエストを送信し、新しいアクセストークンとタイムスタンプを
localStorageに保存します。コールバックが終了すると、ロックは自動的に解放されます。 - 重複作業をスキップ – 待機していたタブがようやくロックを取得した際、
localStorageからタイムスタンプを読み取ります。もしトークンが設定された時間枠内(例:直近数秒間)にリフレッシュされていた場合、そのタブはネットワークコールをスキップし、localStorageからメモリ内のトークンを更新します。 - クラッシュへの対応 – ロックを保持している間にタブがクラッシュしたり閉じられたりした場合、ブラウザがロックを解放するため、別のタブがリフレッシュを再試行できます。
メリットのまとめ
- 冗長なネットワークコールをゼロに – 最初のタブだけがバックエンドと通信します。
- セッションの破壊を防ぐ – リフレッシュトークンのローテーションにおいて使用が1回のみとなるため、セッションが維持されます。
- スムーズな復旧 – ブラウザが管理するロック解放により、タブが消失した場合でもデッドロックを防げます。
実装チェックリスト
- Axios(または fetch)のインターセプター内のリフレッシュロジックを、ロックリクエストでラップします。
- リフレッシュされたトークンとミリ秒単位のタイムスタンプを
localStorageに保存します(セッションごとのデータを好む場合はsessionStorageを使用してください)。 - ロックが付与されたら、保存されたタイムスタンプを
Date.now()と比較します。差分がしきい値未満であれば、バックエンドを呼び出す代わりにストレージからトークンを読み取ります。 - インターセプターが元のAPI呼び出しを再試行する前に、ストレージから取得したトークンを使用してリクエストヘッダーを更新するようにしてください。
- 複数のタブを使用してフローをテストし、ネットワーク遅延をシミュレートして、サーバーに到達するリフレッシュリクエストが常に1つだけであることを確認します。
起こりうる問題
次に注意すべきこと
まとめ
各ブラウザタブを、小さな分散システムにおける一つのノードとして扱ってください。Web Locks APIを使用してJWTのリフレッシュをシリアル化することで、重複呼び出しを排除し、リフレッシュトークンのローテーションを保護し、ユーザーがすべての開いているタブでログイン状態を維持できるようにします。
