tva
← Artigos

Evitando condições de corrida na renovação de tokens em aplicações de página única

Os usuários são desconectados sem explicação. O token de sessão parece válido no localStorage, a última requisição bem-sucedida aconteceu segundos atrás, e o padrão é irritantemente inconsistente — exceto que tende a se agrupar em torno de carregamentos de página que disparam múltiplas chamadas de API concorrentes. Este é um dos bugs de produção mais confusos em aplicações de página única, e uma causa raiz comum é uma condição de corrida na renovação de token.

O mecanismo é simples depois que você o identifica. JWTs expiram. Quando um token expira no meio de uma sessão, o comportamento correto é trocar o token de renovação por um novo token de acesso e repetir a requisição original de forma transparente. Mas, na prática, aplicações React raramente fazem uma requisição por vez. Uma página de painel pode buscar simultaneamente dados do usuário, resumo de conta, contagens de notificações e atividade recente — tudo na montagem do componente, tudo em paralelo. Se o token de acesso expirar durante esse pico de requisições, cada uma dessas requisições recebe um 401. Cada uma delas detecta independentemente o token expirado. Cada uma delas tenta chamar o endpoint de renovação. A primeira chamada tem sucesso e o antigo token de renovação é consumido. A segunda chamada tenta o mesmo token de renovação — agora invalidado — e falha. O usuário é desconectado.

Por que raramente aparece em desenvolvimento

A condição de corrida requer uma coincidência específica: múltiplas requisições verdadeiramente concorrentes, combinadas com uma expiração de token acontecendo exatamente no momento errado. Ambientes de desenvolvimento normalmente têm tempos de vida de token generosos, a latência do localhost é quase zero, então as requisições são concluídas rapidamente e raramente se sobrepõem completamente, e a maioria dos desenvolvedores testa um recurso por vez em vez de carregar páginas com muitos dados. O resultado é que esse bug se esconde confortavelmente em desenvolvimento e aparece em produção sob padrões de uso reais — afetando com mais frequência usuários que estiveram ativos tempo suficiente para seu token inicial expirar no meio da sessão.

Reproduzi-lo deliberadamente requer reduzir os tempos de vida dos tokens e navegar para páginas com busca de dados paralela. Com o Supabase, a configuração jwt_expiry na configuração do seu projeto controla o tempo de vida do token de acesso. Defina-o para sessenta segundos, abra uma página de painel com múltiplas consultas e aguarde. A condição de corrida costuma aparecer nos primeiros minutos de uso ativo, embora o tempo varie.

Como isso se manifesta na autenticação do Supabase

O cliente @supabase/supabase-js v2 do Supabase renova tokens internamente e inclui lógica de serialização — mantém uma única promise de renovação em andamento e coloca os chamadores concorrentes em espera pelo resultado dela. Para aplicações que usam exclusivamente o cliente Supabase em todas as chamadas de API, esse tratamento integrado costuma bastar. Na prática, porém, a maioria das aplicações em produção chega a um ponto em que faz requisições autenticadas fora do cliente Supabase: chamadas à própria API de backend, funções de borda acessadas por fetch ou integrações de terceiros que precisam receber o token de acesso em um cabeçalho. Ao extrair o token da sessão e usá-lo diretamente em uma camada HTTP separada, você sai da proteção da serialização interna do cliente.

Um segundo tipo de falha aparece quando o cliente Supabase não é inicializado corretamente como uma instância única (singleton). Se a avaliação dos módulos cria várias instâncias do cliente — o que pode ocorrer em certos contextos de SSR ou em testes que reavaliam módulos —, essas instâncias não compartilham o estado interno de renovação. Duas instâncias tentarão renovar o token de forma independente quando suas respectivas requisições retornarem 401, e a condição de corrida reaparecerá apesar da serialização integrada.

O padrão de promise única

A solução mais elegante guarda a renovação em andamento em uma promise e devolve essa mesma promise a todos os chamadores até sua resolução. Em vez de cada chamador iniciar uma renovação independente, todos aguardam uma única operação compartilhada. A implementação é compacta:

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

O detalhe essencial é o bloco finally: ele limpa a referência à promise compartilhada após sua resolução ou rejeição, permitindo que o próximo ciclo de renovação comece sem estado residual. Sem ele, uma promise rejeitada bloquearia permanentemente as futuras tentativas de renovação enquanto o módulo permanecesse carregado. A margem de 30 segundos na verificação de expiração trata um caso sutil: um token que passa pela verificação isTokenExpired, mas expira nas poucas centenas de milissegundos antes de a requisição chegar ao servidor, retornará 401 e acionará desnecessariamente outro ciclo de renovação. Há duas ressalvas nessa função auxiliar: os segmentos JWT são codificados em base64url e atob aceita apenas base64 padrão, então converta - e _ em + e / antes de decodificar; caso contrário, alguns tokens válidos serão tratados como expirados em todas as chamadas. Além disso, um token sem a declaração exp é considerado não expirado na comparação, portanto rejeite explicitamente os tokens sem exp.

A abordagem de fila no interceptor

Em aplicações que usam Axios, o padrão usual implementa a mesma lógica de serialização em um interceptor de resposta. Essa abordagem repete as requisições automaticamente: as que falham entram em uma fila; a renovação termina; e a fila é processada, aplicando o novo token à configuração de cada requisição pendente antes de repeti-la:

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

O sinalizador _retry na requisição que iniciou a renovação evita loops infinitos: se a requisição repetida também retornar 401 — por exemplo, porque o novo token está inválido por algum motivo —, o interceptor repassa o erro em vez de iniciar outra renovação. Neste exemplo, as requisições enfileiradas enquanto uma renovação está em andamento não recebem o sinalizador. Defina original._retry = true também no ramo de enfileiramento se uma requisição repetida que falha novamente não deve iniciar outra renovação. A chamada explícita a signOut() quando a renovação falha limpa o estado local de sessão do Supabase. Assim, a aplicação fica em um estado não autenticado consistente, em vez de um estado indefinido em que a interface considera o usuário conectado, mas todas as requisições falham.

Mantendo o contexto do React sincronizado

Os dois padrões acima gerenciam o token na camada HTTP, mas aplicações React frequentemente precisam da sessão atual disponível no contexto: para renderização condicional, para passar dados do usuário a componentes filhos ou para chamadas sem Axios que leem o token diretamente. O desafio é que armazenar o token no estado do React e manter o interceptor sincronizado cria duas fontes de referência que podem divergir durante novas renderizações.

Uma abordagem mais simples mantém o token de referência em um ref acessível ao interceptor de forma síncrona, enquanto a assinatura do React gerencia separadamente o estado da interface:

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

Usar uma referência em vez do estado permite que o interceptor leia o token atual de forma síncrona, sem provocar novas renderizações e sem o problema de closures desatualizadas, que ocorre quando callbacks capturam um valor específico do estado no momento de sua criação. A assinatura de onAuthStateChange mantém a referência e o estado do React consistentes conforme a sessão muda. O segundo efeito não tem limpeza no exemplo; faça setupInterceptors retornar uma função que remova o interceptor (no Axios, interceptors.response.eject(id)) e retorne essa função no efeito, ou o React Strict Mode e as remontagens o registrarão duas vezes.

O que o Supabase trata — e o que fica fora da proteção

Vale explicitar o limite de proteção da serialização interna do cliente Supabase. Ela cobre chamadas feitas pelos próprios métodos do cliente: supabase.from(), supabase.rpc(), operações de armazenamento, assinaturas em tempo real e os métodos de autenticação. O cliente mantém internamente uma única promise de renovação e coloca os chamadores concorrentes em espera. Por isso, chamadas simultâneas a supabase.auth.getSession() feitas de vários pontos recebem corretamente a sessão renovada quando a única renovação em andamento termina.

Ficam fora dessa proteção: chamadas autenticadas de fetch montadas manualmente com o access_token da sessão, qualquer cliente HTTP configurado independentemente do cliente Supabase e, como mencionado, várias instâncias do cliente Supabase. Se sua arquitetura extrai o token de acesso para passá-lo a uma camada de API separada, a proteção contra condições de corrida termina nesse ponto. É aí que os padrões acima se tornam necessários.

Comportamento entre abas

A condição de corrida tem uma variante entre abas do navegador. Se o usuário abre a aplicação em duas abas e ambas detectam simultaneamente a expiração do token, o gerenciamento de sessão de cada aba tentará renová-lo de forma independente. A maioria das implementações de autenticação trata isso com eventos de localStorage: uma aba grava os novos tokens, a outra detecta o evento storage e atualiza o estado local da sessão em vez de iniciar uma renovação concorrente. No navegador, o supabase-js v2 transmite eventos de autenticação para outras abas que compartilham a mesma chave de armazenamento (via BroadcastChannel quando as sessões são persistidas), de modo que onAuthStateChange também é acionado nelas. Isso não tem relação com detectSessionInUrl, que controla apenas se o cliente lê as concessões OAuth na URL da página.

Na prática, isso só funciona se todas as abas ativas tiverem o cliente Supabase corretamente inicializado e estiverem acompanhando as mudanças no estado de autenticação. Aplicações que inicializam o cliente sob demanda ou mantêm a sessão em cache na montagem sem acompanhar as mudanças contínuas por onAuthStateChange perderão as atualizações entre abas. A segunda aba tentará renovar com o token de renovação já consumido, e o usuário será desconectado nela. A correção é a mesma: garantir que cada aba assine onAuthStateChange na inicialização e não mantenha o token da sessão em um estado de módulo que persista entre navegações de rotas.

Identificando o problema em produção

Condições de corrida na renovação de tokens não se revelam claramente. Aparecem no monitoramento de erros como respostas 401 esporádicas em endpoints que deveriam estar autenticados, como encerramentos inesperados de sessão nos dados de análise ou como relatos de usuários desconectados sem motivo aparente. Cada falha parece um problema isolado de autenticação, e não um problema sistêmico de concorrência. Por isso, as equipes frequentemente investigam configuração de expiração, cookies ou condições de rede antes de chegar à causa real.

A condição necessária é específica: requisições paralelas combinadas com a expiração do token. Qualquer página que carrega dados de várias fontes simultaneamente é candidata. Painéis, visualizações de feeds ou árvores de componentes em que vários filhos chamam useEffect de forma independente para buscar seus dados criam esse padrão. Em aplicações com React Query ou SWR, qualquer página que monte várias consultas ao mesmo tempo produz as mesmas condições. Depois de identificar o padrão, a correção é direta, mas exige tratar o problema como concorrência, e não como configuração de autenticação. A fila no interceptor e o padrão de promise única são bem estabelecidos. Ambos funcionam. A condição essencial é que haja no máximo uma renovação em andamento por vez e que todos os chamadores aguardem seu resultado, em vez de iniciar renovações concorrentes.

Artigos relacionados

  • Estrutura e expiração de JWTs — Tokens de acesso e de renovação têm funções diferentes. Tokens de acesso têm vida curta e não mantêm estado; tokens de renovação têm vida longa, normalmente são de uso único sob uma política de rotação e ficam no servidor. O Supabase, por exemplo, permite a reutilização apenas dentro de um intervalo curto (10 segundos por padrão) e revoga a sessão ao detectar uma reutilização fora desse intervalo. Entender essa distinção orienta a definição dos prazos de expiração e o comportamento quando a renovação falha.
  • Row Level Security do Supabase e o token de acesso — As políticas de RLS avaliam as declarações (claims) do JWT do usuário que faz a requisição. O Supabase rejeita um token expirado com 401, mas uma condição de corrida que permite uma requisição sem token faz com que ela seja executada com o papel anon, para o qual a RLS normalmente retorna resultados vazios ou um erro de permissão em vez de 401, o que pode dificultar a identificação da causa.
  • React Query e busca paralela de dados — Hooks useQuery do React Query chamados por vários componentes na mesma página são executados concorrentemente por padrão. Configurar um cliente compartilhado de consultas com uma queryFn personalizada que chama getValidAccessToken() garante que todas as consultas passem pela mesma camada de serialização da renovação.
  • Service workers e cache de tokens — Aplicações que usam service workers para armazenar respostas de API em cache introduzem um terceiro local em que o estado de autenticação deve permanecer consistente. Um service worker que armazena requisições com cabeçalhos de autorização desatualizados pode servir respostas antigas após uma renovação, criando inconsistências sutis nos dados, difíceis de atribuir ao tratamento da autenticação.

Leitura complementar