Race Conditions bei der Token-Erneuerung in Single-Page-Anwendungen verhindern
Nutzer werden ohne Erklärung abgemeldet. Das Sitzungstoken in localStorage scheint gültig, die letzte erfolgreiche Anfrage liegt nur Sekunden zurück, und das Muster ist frustrierend unregelmäßig – außer dass es gehäuft beim Laden von Seiten mit mehreren gleichzeitigen API-Aufrufen auftritt. Das gehört zu den verwirrendsten Fehlern im Produktivbetrieb von Single-Page-Anwendungen. Eine häufige Ursache ist eine Race Condition bei der Token-Erneuerung, also ein Fehler durch konkurrierende gleichzeitige Vorgänge.
Der Mechanismus ist einfach, sobald er erkannt ist. JWTs laufen ab. Läuft ein Token während einer Sitzung ab, sollte das Refresh-Token gegen ein neues Zugriffstoken getauscht und die ursprüngliche Anfrage unbemerkt für den Nutzer wiederholt werden. React-Anwendungen stellen jedoch selten nur eine Anfrage zugleich. Ein Dashboard lädt beim Einhängen seiner Komponenten möglicherweise Nutzerdaten, Kontoübersicht, Benachrichtigungszahlen und letzte Aktivitäten gleichzeitig. Läuft das Zugriffstoken dabei ab, erhält jede Anfrage eine 401-Antwort, erkennt das abgelaufene Token selbstständig und ruft den Endpunkt zur Erneuerung auf. Der erste Aufruf gelingt und verbraucht das alte Refresh-Token. Der zweite versucht es mit demselben, inzwischen ungültigen Token und scheitert. Der Nutzer wird abgemeldet.
Warum es in der Entwicklung selten auftaucht
Die Race Condition erfordert ein bestimmtes Zusammentreffen: mehrere tatsächlich gleichzeitige Anfragen und ein Token, das genau im falschen Moment abläuft. Entwicklungsumgebungen haben meist großzügige Token-Laufzeiten. Auf localhost ist die Latenz nahezu null, sodass Anfragen schnell enden und sich selten vollständig überschneiden. Außerdem testen die meisten Entwickler einzelne Funktionen statt datenintensiver Seiten. Deshalb bleibt der Fehler in der Entwicklung verborgen und tritt erst unter realen Nutzungsmustern im Produktivbetrieb auf – meist bei Nutzern, die lange genug aktiv sind, dass ihr ursprüngliches Token während der Sitzung abläuft.
Zur gezielten Reproduktion müssen Sie die Token-Laufzeit verkürzen und Seiten mit parallelen Datenabrufen aufrufen. Bei Supabase steuert jwt_expiry in der Projektkonfiguration die Laufzeit des Zugriffstokens. Setzen Sie den Wert auf sechzig Sekunden, öffnen Sie eine Dashboard-Seite mit mehreren Abfragen und warten Sie. Die Race Condition tritt typischerweise innerhalb der ersten Minuten aktiver Nutzung auf, wobei der genaue Zeitpunkt variiert.
Wie sich der Fehler bei Supabase Auth zeigt
Der Supabase-Client @supabase/supabase-js v2 übernimmt die Token-Erneuerung intern und enthält eine Serialisierungslogik: Er hält ein einziges Promise für die laufende Erneuerung vor und lässt gleichzeitige Aufrufer darauf warten. Für Anwendungen, die alle API-Aufrufe ausschließlich über den Supabase-Client ausführen, reicht das häufig aus. Die meisten Produktivanwendungen stellen jedoch irgendwann authentifizierte Anfragen außerhalb dieses Clients: an die eigene Backend-API, über fetch aufgerufene Edge Functions oder Drittanbieter-Integrationen, denen das Zugriffstoken als Header übergeben werden muss. Sobald Sie das Token aus der Sitzung entnehmen und direkt in einer separaten HTTP-Schicht verwenden, verlassen Sie den Schutz der internen Serialisierung.
Ein weiterer Fehler entsteht, wenn der Supabase-Client nicht korrekt als Singleton initialisiert wird. Erzeugt die Modulauswertung mehrere Client-Instanzen – etwa in bestimmten SSR-Kontexten oder in Testumgebungen mit wiederholter Modulauswertung –, teilen diese ihren internen Erneuerungszustand nicht. Zwei Instanzen versuchen unabhängig voneinander eine Erneuerung, sobald ihre jeweiligen Anfragen eine 401-Antwort erhalten. Die Race Condition tritt dann trotz interner Serialisierung erneut auf.
Das Singleton-Promise-Muster
Eine elegante Lösung hält die laufende Erneuerung als Promise fest und gibt dieses bis zu seinem Abschluss an alle Aufrufer zurück. Statt unabhängig neue Erneuerungen anzustoßen, warten alle auf denselben Vorgang. Die Implementierung ist kompakt:
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;
}
}
Entscheidend ist der finally-Block: Er löscht die gemeinsame Promise-Referenz sowohl nach Erfolg als auch nach Fehler, damit der nächste Erneuerungszyklus sauber beginnen kann. Ohne ihn würde ein abgelehntes Promise alle weiteren Erneuerungsversuche während der Lebensdauer des Moduls blockieren. Der Puffer von 30 Sekunden bei der Ablaufprüfung berücksichtigt einen subtilen Sonderfall: Ein Token besteht die Prüfung mit isTokenExpired, läuft aber wenige hundert Millisekunden später ab, bevor die Anfrage den Server erreicht. Das würde eine 401-Antwort und eine unnötige weitere Erneuerung auslösen. Zwei Einschränkungen der Hilfsfunktion: JWT-Segmente sind base64url-kodiert, während atob nur Standard-Base64 akzeptiert. Wandeln Sie deshalb vor dem Dekodieren - und _ in + und / um, sonst werden manche gültigen Tokens bei jedem Aufruf als abgelaufen behandelt. Außerdem ergibt der Vergleich bei einem Token ohne exp-Claim, dass es nicht abgelaufen ist. Weisen Sie Tokens mit fehlendem exp daher ausdrücklich zurück.
Der Ansatz mit Interceptor-Warteschlange
Bei Anwendungen mit Axios wird dieselbe Serialisierungslogik üblicherweise in einem Response-Interceptor umgesetzt. Dieser Ansatz wiederholt Anfragen automatisch: Fehlgeschlagene Anfragen kommen in eine Warteschlange, die Erneuerung wird abgeschlossen, und anschließend erhält jede wartende Anfragekonfiguration das neue Token, bevor die Anfragen erneut ausgeführt werden:
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;
}
}
);
Das Flag _retry an der Anfrage, die die Erneuerung ausgelöst hat, verhindert Endlosschleifen. Liefert auch die wiederholte Anfrage eine 401-Antwort, etwa weil das neue Token selbst ungültig ist, reicht der Interceptor den Fehler weiter, statt erneut zu aktualisieren. Anfragen, die während einer laufenden Erneuerung in die Warteschlange gestellt werden, erhalten dieses Flag im Beispiel nicht. Setzen Sie auch im Warteschlangen-Zweig original._retry = true, wenn eine erneut fehlgeschlagene Anfrage keine weitere Erneuerung auslösen soll. Der ausdrückliche Aufruf von signOut() nach einer fehlgeschlagenen Erneuerung löscht den lokalen Supabase-Sitzungszustand. So erreicht die Anwendung einen klaren Zustand ohne Anmeldung, statt in einem Zwischenzustand zu bleiben, in dem die Oberfläche den Nutzer für angemeldet hält, aber jede Anfrage scheitert.
React-Context synchron halten
Beide Muster verwalten das Token auf HTTP-Ebene. React-Anwendungen benötigen die aktuelle Sitzung jedoch häufig auch im Context – für bedingte Darstellung, für Nutzerdaten in Kindkomponenten oder für Aufrufe außerhalb von Axios, die das Token direkt lesen. Wird das Token im React-State gespeichert und der Interceptor damit synchronisiert, entstehen zwei maßgebliche Datenquellen, die bei erneuten Render-Vorgängen auseinanderlaufen können.
Ein sauberer Ansatz hält das maßgebliche Token in einer ref, auf die der Interceptor synchron zugreifen kann. Die React-Subscription verwaltet den Oberflächenzustand separat:
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>;
}
Mit einer Ref statt State liest der Interceptor das aktuelle Token synchron, ohne erneute Render-Vorgänge auszulösen. Zudem vermeidet er veraltete Closures, bei denen Callbacks einen bestimmten State-Wert zum Zeitpunkt ihrer Erstellung festhalten. Die Subscription über onAuthStateChange hält Ref und React-State bei Sitzungsänderungen konsistent. Der zweite Effect hat im gezeigten Beispiel keine Bereinigungsfunktion. Lassen Sie setupInterceptors eine Funktion zurückgeben, die den Interceptor entfernt (bei Axios mit interceptors.response.eject(id)), und geben Sie diese aus dem Effect zurück. Andernfalls wird der Interceptor im React Strict Mode und beim erneuten Mounten doppelt registriert.
Was Supabase übernimmt – und was nicht
Der Schutzumfang der internen Serialisierung des Supabase-Clients sollte klar benannt werden. Er umfasst Aufrufe über die eigenen Client-Methoden: supabase.from(), supabase.rpc(), Speicheroperationen, Realtime-Subscriptions und die Auth-Methoden selbst. Intern hält der Client ein einziges Promise für die Erneuerung vor und lässt gleichzeitige Aufrufer darauf warten. Wird supabase.auth.getSession() parallel an mehreren Stellen aufgerufen, erhalten alle die erneuerte Sitzung, sobald der einzige laufende Erneuerungsvorgang abgeschlossen ist.
Außerhalb dieses Schutzes liegen manuell erstellte authentifizierte fetch-Aufrufe mit dem access_token der Sitzung, unabhängig konfigurierte HTTP-Clients und mehrere Supabase-Client-Instanzen. Entnimmt Ihre Architektur das Zugriffstoken, um es an eine separate API-Schicht zu übergeben, endet der Schutz vor Race Conditions an dieser Stelle. Hier werden die oben beschriebenen Muster notwendig.
Tabübergreifendes Verhalten
Eine Variante der Race Condition tritt zwischen Browser-Tabs auf. Hat ein Nutzer die Anwendung in zwei Tabs geöffnet und erkennen beide gleichzeitig den Token-Ablauf, versucht die Sitzungsverwaltung jedes Tabs unabhängig eine Erneuerung. Die meisten Authentifizierungsimplementierungen behandeln das über localStorage-Ereignisse: Ein Tab schreibt die neuen Tokens in den Speicher, der andere erkennt das storage-Ereignis und aktualisiert seinen lokalen Sitzungszustand, statt eine konkurrierende Erneuerung auszulösen. Im Browser überträgt supabase-js v2 Authentifizierungsereignisse an andere Tabs mit demselben Speicherschlüssel (bei persistierten Sitzungen über BroadcastChannel), sodass onAuthStateChange auch dort ausgelöst wird. Das ist unabhängig von detectSessionInUrl, das nur steuert, ob der Client OAuth-Autorisierungen aus der Seiten-URL liest.
Das funktioniert allerdings nur, wenn jeder aktive Tab den Supabase-Client korrekt initialisiert hat und Änderungen des Authentifizierungszustands abonniert. Anwendungen, die den Client erst bei Bedarf initialisieren oder die Sitzung beim Einhängen zwischenspeichern, ohne laufende Änderungen über onAuthStateChange zu verfolgen, verpassen die tabübergreifenden Aktualisierungen. Der zweite Tab versucht dann eine Erneuerung mit dem bereits verbrauchten Refresh-Token und meldet den Nutzer dort ab. Die Lösung bleibt dieselbe: Jeder Tab muss bei der Initialisierung onAuthStateChange abonnieren und darf das Sitzungstoken nicht in einem Modulzustand zwischenspeichern, der über Routenwechsel hinweg bestehen bleibt.
Den Fehler im Produktivbetrieb erkennen
Race Conditions bei der Token-Erneuerung sind nicht sofort erkennbar. Im Fehlermonitoring erscheinen sie als vereinzelte 401-Antworten an Endpunkten, die authentifiziert sein sollten, in der Datenanalyse als unerwartete Sitzungsabbrüche oder in Nutzermeldungen als scheinbar zufällige Abmeldungen. Jeder einzelne Fehler wirkt wie ein isoliertes Authentifizierungsproblem statt wie ein systematisches Nebenläufigkeitsproblem. Deshalb untersuchen Teams häufig erst Token-Laufzeiten, Cookie-Einstellungen oder Netzwerkbedingungen, bevor sie die eigentliche Ursache finden.
Die Voraussetzung ist klar: parallele Anfragen in Verbindung mit einem Token-Ablauf. Jede Seite, die Daten gleichzeitig aus mehreren Quellen lädt, kommt infrage. Dashboards, Feed-Ansichten und Komponentenbäume, in denen mehrere Kindkomponenten unabhängig über useEffect Daten abrufen, erzeugen dieses Muster. Bei React Query oder SWR entstehen dieselben Bedingungen, wenn mehrere Abfragen gleichzeitig eingehängt werden. Sobald Sie wissen, wonach Sie suchen müssen, ist die Lösung einfach – Sie müssen das Problem jedoch als Nebenläufigkeitsfehler behandeln, nicht als fehlerhafte Authentifizierungskonfiguration. Sowohl die Interceptor-Warteschlange als auch das Singleton-Promise-Muster sind etabliert. Beide funktionieren. Die entscheidende Invariante lautet: Es darf höchstens eine Erneuerung gleichzeitig laufen, und alle Aufrufer warten auf ihr Ergebnis, statt konkurrierende Erneuerungen anzustoßen.
Weiterführende Beiträge
- JWT-Struktur und Ablauf – Zugriffstokens und Refresh-Tokens haben unterschiedliche Aufgaben. Zugriffstokens sind kurzlebig und zustandslos; Refresh-Tokens sind langlebig, im Rahmen einer Rotationsrichtlinie typischerweise einmal verwendbar und werden serverseitig gespeichert. Supabase erlaubt beispielsweise die Wiederverwendung nur innerhalb eines kurzen Zeitfensters (standardmäßig 10 Sekunden) und widerruft die Sitzung, wenn eine Wiederverwendung außerhalb dieses Zeitfensters erkannt wird. Dieser Unterschied bestimmt mit, wie kurz Ablaufzeiten gewählt werden können und was bei einer fehlgeschlagenen Erneuerung passiert.
- Supabase Row Level Security und das Zugriffstoken – RLS-Richtlinien prüfen die JWT-Claims des anfragenden Nutzers. Supabase weist ein abgelaufenes Token mit einer 401-Antwort zurück. Führt eine Race Condition jedoch dazu, dass eine Anfrage ohne Token ausgeführt wird, läuft sie unter der Rolle
anon. Dabei liefert RLS typischerweise leere Ergebnisse oder einen Berechtigungsfehler statt einer 401-Antwort. Das kann die Ursachenanalyse erschweren. - React Query und parallele Datenabrufe – Die
useQuery-Hooks von React Query aus mehreren Komponenten derselben Seite werden standardmäßig parallel ausgeführt. Ein gemeinsamer Query-Client mit einer eigenenqueryFn, diegetValidAccessToken()aufruft, leitet alle Abfragen durch dieselbe Serialisierungsschicht für die Erneuerung. - Service Worker und Token-Caching – Anwendungen, die API-Antworten mit Service Workern zwischenspeichern, haben eine dritte Stelle, an der der Authentifizierungszustand konsistent bleiben muss. Ein Service Worker, der Anfragen mit veralteten Autorisierungsheadern zwischenspeichert, kann nach einer Token-Erneuerung veraltete Antworten liefern. Die daraus entstehenden subtilen Dateninkonsistenzen lassen sich nur schwer auf die Authentifizierungsverwaltung zurückführen.