12k
All articles

Token-Refresh-Races mit der Web Locks API beheben

Beheben Sie Token-Refresh-Rennen zwischen Tabs mit der Web Locks API, navigator.locks.request und einer Token-Prüfung.

OpenReplay Team
OpenReplay Team
Token-Refresh-Races mit der Web Locks API beheben

Wenn mehrere Tabs sich ein rotierendes Refresh-Token teilen, kann der Refresh-Aufruf des ersten Tabs genau jenes Token invalidieren, das die anderen Tabs gerade abschicken wollen. Deren Refreshes schlagen dann fehl, und der Nutzer wird am Ende in allen Tabs gleichzeitig ausgeloggt. Die Lösung besteht darin, den Refresh in navigator.locks.request zu kapseln, sodass genau ein Tab ihn ausführt, während die übrigen warten und anschließend das dort gespeicherte Token weiterverwenden.

Wenn Sie einem Bug-Report nachgehen, in dem es heißt „Ich wurde ohne mein Zutun ausgeloggt”, und sich das Verhalten auf Ihrem Rechner nie reproduzieren lässt, fragen Sie nach, wie viele Tabs der Melder geöffnet hatte. Eine Single-Tab-Refresh-Queue in Ihrem Interceptor ist an sich korrekt, aber sie kann diesen Race nicht erkennen.

Dieser Artikel geht die Abfolge durch, die zum Logout führt, erklärt, warum ein localStorage-Flag hier keine verlässliche Lösung ist, und stellt die drei Bausteine der Lösung vor: das Lock, die erneute Prüfung innerhalb des Locks und die Verdrahtung im Interceptor.

Die wichtigsten Erkenntnisse

  • Wenn mehrere Tabs gleichzeitig ein rotiertes Token erneuern, ist nur der erste Aufruf erfolgreich; der Server invalidiert das Token, das jeder andere Tab gerade abschicken will, und loggt den Nutzer damit überall aus.
  • navigator.locks.request stellt allen Tabs, iframes und Workern derselben Origin einen gemeinsamen Mutex bereit und ist Baseline Widely available – Fallback-Code ist also nicht erforderlich.
  • Ein localStorage-Flag ist kein Lock: Es gibt kein atomares Compare-and-Set, und ein Flag, das ein abgestürzter Tab geschrieben hat, bleibt dauerhaft hängen, während ein Web Lock automatisch freigegeben wird.
  • Wartende Tabs müssen das gespeicherte Token innerhalb des Lock-Callbacks erneut prüfen und sich dabei an der Gültigkeitsdauer des Tokens selbst orientieren, nicht an einem Zeitstempel im Sinne von „kürzlich erneuert”.
  • Richtig umgesetzt führt eine beliebige Anzahl von Tabs, die auf ein abgelaufenes Token stoßen, zu genau einem Netzwerk-Refresh.

Warum schlägt ein Refresh-Token über mehrere Tabs hinweg fehl?

Für den Race braucht es vier Zutaten: kurzlebige Access-Tokens, einen Refresh-Endpoint, Refresh-Token-Rotation und mehr als einen Tab. Alle vier sind gängige Praxis. RFC 9700, die OAuth 2.0 Security Best Current Practice, lässt Public Clients bei Refresh-Tokens zwei Optionen: jedes Token an den Client zu binden, dem es ausgestellt wurde, oder bei jeder Verwendung ein neues auszugeben. Damit ist Rotation die Standardhaltung für SPAs.

Die Abfolge: Der Nutzer hat drei Tabs offen, und das Access-Token läuft ab. Der nächste Request jedes Tabs erhält ein 401, und der Interceptor jedes Tabs ruft unabhängig den Refresh-Endpoint auf. Der Aufruf von Tab A trifft zuerst ein und ist erfolgreich; wenn der Server rotiert und das vorherige Refresh-Token bei Verwendung invalidiert, senden Tab B und C nun eine tote Credential. Ihre Refreshes scheitern, ihre Error-Handler behandeln einen fehlgeschlagenen Refresh als endgültigen Auth-Fehler, und der Nutzer wird mitten in seiner Aufgabe zum Login umgeleitet.

In einem Session Replay hat dieser Bug eine markante Signatur: ein Redirect zum Login-Screen ohne vorausgehende Nutzerinteraktion, und zwar in jedem einzelnen der offenen Tabs des Nutzers innerhalb derselben Sekunde. Diese Signatur – nicht irgendetwas in der Konsole – ist das Erkennungsmerkmal.

Warum lösen Per-Tab-State oder ein localStorage-Flag das Problem nicht?

Das isRefreshing-Flag und die Promise-Queue Ihres Interceptors liegen im JavaScript-Speicher eines einzigen Tabs. Tabs teilen keinen Speicher, also sieht Tab B das Flag von Tab A nie. Tab-übergreifende Koordination braucht ein Primitiv auf Browser-Ebene.

Der klassische Workaround, ein „Refresh in progress”-Flag im localStorage, ist kein Lock. Die Web Storage API bietet getItem und setItem, aber kein atomares Compare-and-Set. Zwei Tabs können also beide lesen, dass kein Refresh läuft, und beide einen starten, bevor einer der Schreibvorgänge greift. Das Flag versagt auch in der umgekehrten Richtung: Stürzt der Tab, der es gesetzt hat, mitten im Refresh ab oder wird geschlossen, bleibt das Flag für immer gesetzt, und jeder überlebende Tab wartet auf einen Refresh, der niemals abgeschlossen wird. Ein Web Lock wird vom Browser in dem Moment freigegeben, in dem das Dokument seines Inhabers verschwindet – also genau in dem Fehlerfall, der ein selbstgebautes Flag blockiert.

Wie kapselt man den Refresh in navigator.locks.request?

Die Web Locks API stellt jedem Tab, iframe und Worker einer Origin einen gemeinsamen Mutex bereit. Mit navigator.locks.request(name, callback) führt immer nur ein Inhaber eines bestimmten Namens seinen Callback aus, und der Browser gibt das Lock frei, sobald das vom Callback zurückgegebene Promise settled – ob es resolved oder rejected. Es gibt keinen Unlock-Aufruf, den man vergessen könnte, und kein geleaktes Lock bei einem fehlgeschlagenen Fetch. Alle großen Engines liefern die API aus, seit Safari 15.4 sie im März 2022 ergänzt hat; deshalb bewertet MDN sie als Baseline Widely available, und Feature Detection oder ein Fallback-Zweig sind nicht nötig.

async function refreshTokenAcrossTabs() {
  return navigator.locks.request("token-refresh", async () => {
    const existing = getUsableToken();
    if (existing) return existing; // another tab already refreshed

    const res = await fetch("/auth/refresh", {
      method: "POST",
      credentials: "include",
    });
    if (!res.ok) throw new Error("refresh_failed");

    const { accessToken, expiresAt } = await res.json();
    writeToken(accessToken, expiresAt);
    return accessToken;
  });
}

readToken und writeToken sind bewusst abstrakt gehalten: Wo Tokens gespeichert werden, ist eine separate Sicherheitsentscheidung, die dieser Artikel nicht trifft – und das Lock funktioniert unabhängig davon gleich.

Die erneute Prüfung: das Token testen, nicht die Uhr

Der Schritt, den die meisten Implementierungen überspringen, ist die erneute Prüfung innerhalb des Lock-Callbacks: Ein Tab, der auf das Lock gewartet hat, sollte zuerst testen, ob das gespeicherte Token nun gültig ist, und es in diesem Fall ohne einen zweiten Netzwerkaufruf zurückgeben. Drei Tabs stellen sich am Lock an; der erste macht den Round Trip, der zweite und dritte erhalten es danach, finden ein brauchbares Token und geben es sofort zurück. Ein Netzwerk-Refresh, unabhängig von der Anzahl der Tabs.

Machen Sie diese Prüfung davon abhängig, ob das gespeicherte Token tatsächlich brauchbar ist – nicht davon, wie kürzlich ein Zeitstempel geschrieben wurde. Eine Wall-Clock-Prüfung im Sinne von „in den letzten 5 Sekunden erneuert” bricht auf zwei Wegen: Ein Refresh, der langsamer ist als das Zeitfenster, führt dazu, dass wartende Tabs fälschlich schließen, es sei nichts passiert, und Duplikate abfeuern; und eine verstellte Client-Uhr macht den Vergleich in beide Richtungen ungültig. Die Gültigkeitsdauer des Tokens selbst kann über sich nicht lügen.

const SKEW_MS = 30_000; // tolerate modest clock drift

function getUsableToken() {
  const stored = readToken(); // { token, expiresAt } or null
  if (!stored) return null;
  return stored.expiresAt - SKEW_MS > Date.now() ? stored.token : null;
}

Das Lock in einen 401-Interceptor einbinden

Die Aufgabe des Interceptors bleibt unverändert: das 401 abfangen, ein frisches Token beschaffen, den ursprünglichen Request einmal erneut senden. Der einzige Unterschied ist, dass der Refresh-Aufruf jetzt refreshTokenAcrossTabs() lautet – die Serialisierung erstreckt sich damit über alle Tabs.

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;

    if (error.response?.status !== 401 || original._retry) {
      return Promise.reject(error);
    }
    if (original.url?.includes("/auth/refresh")) {
      await logout(); // the refresh itself failed: session is gone
      return Promise.reject(error);
    }

    original._retry = true;
    try {
      const token = await refreshTokenAcrossTabs();
      original.headers.Authorization = `Bearer ${token}`;
      return api(original);
    } catch (refreshError) {
      await logout();
      return Promise.reject(refreshError);
    }
  }
);

Der _retry-Guard verhindert Schleifen, und ein 401 vom Refresh-Endpoint selbst bedeutet Logout, nicht Retry. Aus Sicht eines wartenden Tabs lautet der vollständige Pfad: 401, am Lock anstellen, Lock erhalten, gültiges Token finden, ohne Netzwerkaufruf zurückgeben, ursprünglichen Request erneut senden.

Wissenswerte Randfälle

  • Web Locks erfordert einen Secure Context; http://localhost gilt als potentially trustworthy origin.
  • Die Termination-Regeln der Spezifikation geben die Locks eines Dokuments beim Unload frei, sodass ein Lock einen Reload oder eine Navigation nie überlebt und niemals persistenter State ist.
  • Halten Sie den Critical Section auf den Refresh allein beschränkt; alles andere, was Sie darin awaiten, blockiert jeden Tab.
  • Fordern Sie dasselbe Lock niemals innerhalb seines eigenen Callbacks an: Der innere Request stellt sich hinter den äußeren Halter an und hängt stillschweigend für immer.
  • Shared Mode, ifAvailable-Leader-Election und steal existieren für andere Aufgaben; der Token-Refresh braucht keines davon.
  • BroadcastChannel teilt anderen Tabs mit, dass etwas passiert ist; ein Lock verhindert, dass sie es alle gleichzeitig tun.

Fazit

Der Bug mit den zufälligen Logouts ist ein Distributed-Systems-Race, das auf dem Rechner des Nutzers abläuft – und der Browser liefert den Mutex mit, der ihn beendet. Kapseln Sie Ihren Refresh in navigator.locks.request, machen Sie eine Token-Gültigkeitsprüfung zur ersten Zeile des Callbacks, und leiten Sie Ihren 401-Handler darüber. Reproduzieren Sie den Bug zunächst mit mehreren Tabs und einer kurzen Token-Lebensdauer, wenden Sie dann das Lock an und beobachten Sie, wie N Refresh-Aufrufe auf einen zusammenfallen.

FAQs

Kann BroadcastChannel die Web Locks API für tab-übergreifenden Token-Refresh ersetzen?

Nein. BroadcastChannel ist ein Messaging-Transport, kein Mutex: Es kann ankündigen, dass ein Refresh stattgefunden hat, aber nichts hindert zwei Tabs daran, beide einen zu starten, bevor eine der Nachrichten ankommt – derselbe Read-then-Act-Race, den auch ein localStorage-Flag hat. Verwenden Sie navigator.locks.request, um den Refresh zu serialisieren, und ergänzen Sie BroadcastChannel erst danach, wenn Sie das neue Token an lauschende Tabs pushen möchten.

Wie füge ich einem navigator.locks.request-Aufruf ein Timeout hinzu?

Übergeben Sie ein AbortSignal über die Option signal. Brechen Sie es ab, während der Request noch in der Queue steht, wird das Promise mit einem AbortError rejected – AbortSignal.timeout gibt Ihnen damit eine Deadline für das Warten. Nachdem das Lock erteilt wurde, hat das Signal keine Wirkung mehr, ein Timeout kann also einen bereits laufenden Callback nicht abkürzen. Die Kombination von signal mit steal oder ifAvailable führt zu einem NotSupportedError – wählen Sie also pro Request eine Strategie.

Funktioniert die Web Locks API in Web Workern und Service Workern?

Ja. Die Spezifikation stellt LockManager sowohl in Window- als auch in Worker-Kontexten bereit, sodass Dedicated Worker, Shared Worker und Service Worker navigator.locks.request aufrufen können. Alle Kontexte derselben Origin teilen einen Lock Manager – ein Worker, der das Lock 'token-refresh' anfordert, stellt sich also gemeinsam mit Tabs an, die denselben Namen anfordern. Das Muster bleibt daher korrekt, selbst wenn Teile Ihrer Auth-Logik außerhalb des Main Threads laufen.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.