tva
← Approfondimenti

Prevenire le condizioni di competizione nel rinnovo dei token nelle applicazioni a pagina singola

Gli utenti vengono disconnessi senza spiegazione. Il token di sessione sembra valido in localStorage, l’ultima richiesta riuscita risale a pochi secondi prima e il problema si presenta in modo esasperantemente irregolare, salvo concentrarsi sui caricamenti di pagina che avviano più chiamate API concorrenti. È uno dei bug di produzione più difficili da interpretare nelle applicazioni a pagina singola, e una causa comune è una condizione di competizione (race condition) nel rinnovo del token.

Il meccanismo è semplice una volta individuato. I JWT scadono. Quando un token scade durante la sessione, il comportamento corretto è scambiare il token di rinnovo con un nuovo token di accesso e ripetere la richiesta originale in modo trasparente. Le applicazioni React, però, raramente eseguono una richiesta alla volta. Una dashboard può recuperare contemporaneamente dati utente, riepilogo dell’account, conteggio delle notifiche e attività recenti, tutti al montaggio del componente e tutti in parallelo. Se il token di accesso scade durante questa sequenza, ogni richiesta riceve un 401. Ciascuna rileva autonomamente la scadenza del token e tenta di chiamare l’endpoint di rinnovo. La prima chiamata riesce e consuma il vecchio token di rinnovo. La seconda tenta di usare lo stesso token, ora invalidato, e fallisce. L’utente viene disconnesso.

Perché raramente emerge in sviluppo

La condizione di competizione richiede una coincidenza specifica: richieste realmente concorrenti e scadenza del token proprio nel momento sbagliato. Negli ambienti di sviluppo i token durano generalmente a lungo; la latenza su localhost è quasi nulla, quindi le richieste terminano rapidamente e raramente si sovrappongono completamente; inoltre, gli sviluppatori tendono a testare una funzionalità alla volta, invece di caricare pagine ricche di dati. Il bug resta così nascosto in sviluppo ed emerge in produzione con l’uso reale, soprattutto per gli utenti attivi abbastanza a lungo da far scadere il token iniziale durante la sessione.

Per riprodurlo deliberatamente occorre ridurre la durata dei token e aprire pagine che recuperano dati in parallelo. In Supabase, l’impostazione jwt_expiry nella configurazione del progetto controlla la durata del token di accesso. Impostala a sessanta secondi, apri una dashboard con più query e attendi. La condizione di competizione si presenta in genere nei primi minuti di utilizzo attivo, anche se i tempi variano.

Come si manifesta con l’autenticazione Supabase

Il client @supabase/supabase-js v2 gestisce internamente il rinnovo dei token e include una logica di serializzazione: mantiene una sola promise di rinnovo in corso e accoda i chiamanti concorrenti in attesa del suo risultato. Per le applicazioni che lo usano per tutte le chiamate API, questa gestione integrata spesso basta. In produzione, però, si arriva frequentemente a eseguire richieste autenticate al di fuori del client: chiamate al proprio backend, funzioni edge tramite fetch o integrazioni di terze parti che richiedono il token di accesso in un’intestazione. Estrarre il token dalla sessione e usarlo direttamente in un livello HTTP separato significa uscire dalla protezione della serializzazione interna del client.

Un secondo problema si presenta se il client Supabase non è inizializzato correttamente come istanza unica. Se la valutazione dei moduli crea più istanze del client, come può accadere in alcuni contesti SSR o nei test che rivalutano i moduli, queste istanze non condividono lo stato interno del rinnovo. Due istanze tentano ciascuna autonomamente un rinnovo quando le rispettive richieste ricevono un 401, e la condizione di competizione si ripresenta nonostante la serializzazione integrata.

Il modello della promise condivisa

La soluzione più elegante conserva l’operazione di rinnovo in corso in una promise e restituisce la stessa promise a tutti i chiamanti fino al completamento. Invece di avviare ciascuno un rinnovo, tutti attendono una sola operazione condivisa. L’implementazione è compatta:

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

Il dettaglio decisivo è il blocco finally: azzera il riferimento alla promise condivisa sia in caso di successo sia in caso di errore, così il ciclo di rinnovo successivo può ripartire. Senza questo passaggio, una promise rifiutata bloccherebbe tutti i rinnovi futuri per l’intera durata del modulo. Il margine di 30 secondi nella verifica della scadenza gestisce un caso limite: un token che supera il controllo isTokenExpired, ma scade nei successivi pochi centinaia di millisecondi prima dell’arrivo della richiesta al server, produce un 401 e avvia inutilmente un altro ciclo di rinnovo. La funzione di supporto presenta due aspetti da considerare: i segmenti JWT sono codificati in base64url e atob accetta solo base64 standard, quindi converti - e _ in + e / prima della decodifica, altrimenti alcuni token validi verranno considerati scaduti a ogni chiamata. Inoltre, un token senza la dichiarazione exp risulta non scaduto nel confronto: rifiuta quindi esplicitamente i token privi di exp.

L’approccio con coda nell’interceptor

Per le applicazioni che usano Axios, il modello standard inserisce la stessa logica di serializzazione in un interceptor delle risposte. Questo approccio gestisce automaticamente i nuovi tentativi: le richieste fallite vengono accodate, il rinnovo termina e la coda viene svuotata applicando il nuovo token alla configurazione di ogni richiesta in attesa prima di ripeterla:

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

Il flag _retry sulla richiesta che ha avviato il rinnovo impedisce cicli infiniti: se anche la richiesta ripetuta riceve un 401, per esempio perché il nuovo token non è valido, l’interceptor propaga l’errore anziché avviare un altro rinnovo. In questo esempio, le richieste messe in coda durante un rinnovo in corso non ricevono il flag. Imposta original._retry = true anche nel ramo della coda se una richiesta ripetuta che fallisce di nuovo non deve avviare un altro rinnovo. La chiamata esplicita a signOut() in caso di rinnovo fallito elimina la sessione locale Supabase e porta l’applicazione a uno stato chiaramente non autenticato, evitando che l’interfaccia consideri ancora connesso l’utente mentre ogni richiesta fallisce.

Mantenere sincronizzato il contesto React

Entrambi i modelli gestiscono il token al livello HTTP, ma le applicazioni React spesso richiedono la sessione corrente nel contesto: per il rendering condizionale, per passare dati utente ai componenti figli o per le chiamate non Axios che leggono direttamente il token. Il problema è che conservare il token nello stato React e sincronizzarlo con l’interceptor crea due fonti di verità che possono divergere durante i nuovi rendering.

Un approccio più semplice conserva il token di riferimento in una ref leggibile sincronicamente dall’interceptor, mentre la sottoscrizione React gestisce separatamente lo stato dell’interfaccia:

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

Usare una ref anziché lo stato consente all’interceptor di leggere il token corrente sincronicamente, senza provocare nuovi rendering e senza usare un valore obsoleto catturato da una closure quando il callback è stato creato. La sottoscrizione onAuthStateChange mantiene coerenti la ref e lo stato React man mano che la sessione cambia. Il secondo effetto, così com’è, non prevede alcuna pulizia: fai in modo che setupInterceptors restituisca una funzione che rimuove l’interceptor (in Axios, interceptors.response.eject(id)) e restituiscila dall’effetto, altrimenti React Strict Mode e i rimontaggi lo registreranno due volte.

Cosa gestisce Supabase e cosa resta escluso

È utile chiarire il perimetro di protezione della serializzazione interna del client Supabase. Copre le chiamate ai metodi del client: supabase.from(), supabase.rpc(), operazioni di archiviazione, sottoscrizioni realtime e metodi di autenticazione. Il client mantiene internamente una sola promise di rinnovo e accoda i chiamanti concorrenti: più chiamate simultanee a supabase.auth.getSession() ricevono quindi tutte la sessione aggiornata dopo il completamento dell’unico rinnovo in corso.

Restano escluse le chiamate fetch autenticate costruite manualmente con l’access_token della sessione, qualsiasi client HTTP configurato indipendentemente dal client Supabase e, come già descritto, le istanze multiple del client Supabase. Se l’architettura estrae il token di accesso per passarlo a un livello API separato, la protezione dalle condizioni di competizione termina in quel punto. È qui che i modelli descritti diventano necessari.

Comportamento fra schede del browser

La condizione di competizione ha una variante che si presenta fra schede del browser. Se un utente apre l’applicazione in due schede ed entrambe rilevano contemporaneamente la scadenza del token, ciascuna tenta autonomamente un rinnovo. Molte implementazioni dell’autenticazione gestiscono questo caso tramite gli eventi di localStorage: una scheda scrive i nuovi token nell’archiviazione, l’altra rileva l’evento storage e aggiorna la sessione locale anziché avviare un rinnovo concorrente. Nel browser, supabase-js v2 trasmette gli eventi di autenticazione alle altre schede che condividono la stessa chiave di archiviazione (tramite BroadcastChannel quando le sessioni vengono salvate in modo persistente), quindi onAuthStateChange si attiva anche in quelle schede. Questo non dipende da detectSessionInUrl, che controlla solo se il client legge le autorizzazioni OAuth dall’URL della pagina.

In pratica, però, questo funziona solo se ogni scheda attiva ha il client Supabase correttamente inizializzato e una sottoscrizione agli aggiornamenti dell’autenticazione. Le applicazioni che inizializzano il client solo all’occorrenza, o che conservano la sessione al montaggio senza sottoscrivere gli aggiornamenti tramite onAuthStateChange, perdono gli aggiornamenti fra schede. La seconda scheda tenta un rinnovo con il token già consumato e disconnette l’utente. La soluzione resta la stessa: assicurarsi che ogni scheda sottoscriva onAuthStateChange all’inizializzazione ed evitare di conservare il token di sessione in uno stato a livello di modulo che persista fra navigazioni.

Riconoscere il problema in produzione

Le condizioni di competizione nel rinnovo dei token non si manifestano chiaramente. Compaiono nel monitoraggio degli errori come 401 sporadici su endpoint che dovrebbero essere autenticati, come terminazioni inattese delle sessioni nei dati di analisi o come segnalazioni di disconnessioni casuali. Ogni singolo errore sembra un problema isolato di autenticazione, anziché un difetto sistematico di concorrenza: per questo i team spesso indagano configurazione della scadenza dei token, cookie e rete prima di individuare la vera causa.

Il presupposto è specifico: richieste parallele insieme alla scadenza di un token. Qualsiasi pagina che carica dati da più fonti contemporaneamente è candidata. Dashboard, viste dei feed o alberi di componenti in cui diversi figli usano autonomamente useEffect per recuperare i propri dati creano questo schema di richieste parallele. Nelle applicazioni React Query o SWR, più query montate insieme creano le stesse condizioni. Una volta individuato cosa cercare, la soluzione è semplice, ma richiede di affrontare il problema come un difetto di concorrenza anziché di configurazione dell’autenticazione. La coda nell’interceptor e la promise condivisa sono entrambe soluzioni consolidate. Entrambe funzionano. L’invariante essenziale è che sia in corso al massimo una sola operazione di rinnovo e che ogni chiamante ne attenda il risultato anziché avviare un rinnovo concorrente.

Approfondimenti correlati

  • Struttura e scadenza dei JWT — I token di accesso e quelli di rinnovo hanno scopi diversi. I token di accesso sono di breve durata e senza stato; quelli di rinnovo durano più a lungo, sono generalmente monouso nell’ambito di una politica di rotazione e vengono conservati sul server. Supabase, per esempio, ne consente il riutilizzo solo entro un breve intervallo (10 secondi per impostazione predefinita) e revoca la sessione quando rileva un riutilizzo al di fuori di tale intervallo. Comprendere la distinzione aiuta a stabilire quanto ridurre le finestre di validità e cosa accade quando il rinnovo fallisce.
  • Row Level Security di Supabase e token di accesso — Le policy RLS valutano le dichiarazioni JWT dell’utente che effettua la richiesta. Supabase rifiuta un token scaduto con un 401, ma una condizione di competizione che fa proseguire una richiesta senza token la esegue con il ruolo anon, per il quale RLS restituisce in genere risultati vuoti o un errore di autorizzazione anziché un 401, rendendo meno evidente la causa.
  • React Query e recupero parallelo dei dati — Gli hook useQuery di React Query chiamati da più componenti della stessa pagina vengono tutti eseguiti in parallelo per impostazione predefinita. Configurare un client query condiviso con una queryFn personalizzata che chiama getValidAccessToken() assicura che tutte le query passino attraverso lo stesso livello di serializzazione dei rinnovi.
  • Service worker e memorizzazione dei token nella cache — Le applicazioni che usano service worker per conservare risposte API nella cache introducono un terzo punto in cui mantenere coerente lo stato dell’autenticazione. Un service worker che memorizza richieste con intestazioni di autorizzazione obsolete può restituire risposte non aggiornate dopo il rinnovo del token, causando incoerenze dei dati difficili da ricondurre alla gestione dell’autenticazione.

Per approfondire