Capa do Ebook gratuito Roteamento e Autenticação em React: Construindo SPAs Seguras com React Router e JWT

Roteamento e Autenticação em React: Construindo SPAs Seguras com React Router e JWT

Novo curso

20 páginas

Interceptação de requisições para anexar JWT e padronizar erros com fetch e axios

Capítulo 12

Tempo estimado de leitura: 12 minutos

+ Exercício
Audio Icon

Ouça em áudio

0:00 / 0:00

Em uma SPA, quase toda interação relevante passa por requisições HTTP: buscar dados, salvar formulários, atualizar preferências, etc. Quando a aplicação usa JWT para autenticação, surge um problema recorrente: como garantir que todas as requisições que precisam de autenticação enviem o token correto, e como tratar erros (principalmente 401/403) de maneira consistente, sem duplicar código em cada chamada?

Este capítulo foca em interceptação de requisições e respostas para: (1) anexar automaticamente o JWT no header Authorization; (2) padronizar o tratamento de erros e a forma como eles chegam à UI; (3) lidar com casos especiais como expiração do token, falhas de rede, timeouts e cancelamento de requisições. Vamos cobrir duas abordagens: usando axios (que possui interceptors nativos) e usando fetch (onde criamos um “wrapper” para simular interceptação).

O que significa “interceptar” requisições e respostas

Interceptar é inserir uma camada entre o código que faz a chamada HTTP e o transporte de rede. Essa camada consegue:

  • Antes de enviar: adicionar headers, ajustar baseURL, serializar body, anexar query params, adicionar correlation id, registrar logs, aplicar timeout, etc.
  • Depois de receber: normalizar o formato de sucesso/erro, transformar payload, lidar com códigos HTTP específicos (401, 403, 422, 500), disparar ações globais (ex.: invalidar sessão), e reexecutar requisições em cenários controlados (ex.: refresh token).

O objetivo é reduzir repetição e evitar divergências: se cada tela tratar 401 de um jeito, você terá bugs difíceis de rastrear e uma UX inconsistente.

Regras práticas para anexar JWT com segurança no front-end

Antes do passo a passo, defina regras claras para evitar efeitos colaterais:

Continue em nosso aplicativo

Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.

ou continue lendo abaixo...
Download App

Baixar o aplicativo

  • Não anexe token em endpoints públicos: login, registro, reset de senha, healthcheck. Isso evita confusão no back-end e reduz exposição desnecessária.
  • Não anexe token para domínios externos: se a app chama APIs de terceiros, não envie seu JWT para fora do seu domínio de API.
  • Evite “pegar token” diretamente do localStorage em todo lugar: centralize o acesso ao token em um serviço/hook, para facilitar mudanças (ex.: migração para cookie httpOnly).
  • Padronize erros: a UI deve receber um formato previsível (ex.: { type, status, message, details }), independentemente de ser axios/fetch, erro de rede ou erro HTTP.

Modelo de erro padronizado

Um padrão simples e útil é criar uma classe/estrutura de erro que carregue informações essenciais:

export type ApiErrorType = 'HTTP' | 'NETWORK' | 'TIMEOUT' | 'ABORTED' | 'UNKNOWN';

export class ApiError extends Error {
  type: ApiErrorType;
  status?: number;
  details?: unknown;

  constructor(params: { type: ApiErrorType; message: string; status?: number; details?: unknown }) {
    super(params.message);
    this.name = 'ApiError';
    this.type = params.type;
    this.status = params.status;
    this.details = params.details;
  }
}

export function isApiError(err: unknown): err is ApiError {
  return err instanceof ApiError;
}

Com isso, componentes e hooks conseguem tratar erros sem depender do formato específico do axios (AxiosError) ou do fetch (Response + exceptions).

Interceptação com axios: interceptors de request e response

O axios oferece interceptors nativos, ideais para anexar JWT e normalizar erros. A estratégia recomendada é criar uma instância dedicada (apiClient) e nunca usar axios diretamente nas features.

Passo a passo: criando o client e anexando JWT

1) Crie uma instância com configurações padrão (baseURL, timeout, headers):

import axios from 'axios';

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json'
  }
});

2) Centralize como obter o token. Exemplo simples com um “token provider” (pode ser integrado ao seu estado de autenticação):

let tokenGetter: (() => string | null) | null = null;

export function setTokenGetter(getter: () => string | null) {
  tokenGetter = getter;
}

function getAccessToken() {
  return tokenGetter ? tokenGetter() : null;
}

3) Adicione um interceptor de request para anexar o header Authorization apenas quando fizer sentido:

const AUTH_WHITELIST = ['/auth/login', '/auth/refresh', '/public'];

function isWhitelisted(url?: string) {
  if (!url) return false;
  return AUTH_WHITELIST.some((p) => url.startsWith(p));
}

apiClient.interceptors.request.use((config) => {
  // Evita anexar token em endpoints públicos
  if (isWhitelisted(config.url)) return config;

  const token = getAccessToken();
  if (token) {
    config.headers = config.headers ?? {};
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

Observações importantes:

  • Use Bearer por padrão, a menos que seu back-end exija outro esquema.
  • Se você usa múltiplas APIs (múltiplos domínios), crie instâncias separadas para evitar vazamento de token.

Passo a passo: interceptor de response para padronizar erros

O axios rejeita a Promise em respostas fora do range 2xx. Isso é útil, mas o formato do erro é específico. Vamos transformar em ApiError.

import type { AxiosError } from 'axios';
import { ApiError } from './ApiError';

apiClient.interceptors.response.use(
  (response) => response,
  (error: AxiosError) => {
    // Timeout
    if (error.code === 'ECONNABORTED') {
      return Promise.reject(new ApiError({
        type: 'TIMEOUT',
        message: 'Tempo de resposta excedido.',
        details: { original: error.message }
      }));
    }

    // Erro de rede (sem response)
    if (!error.response) {
      return Promise.reject(new ApiError({
        type: 'NETWORK',
        message: 'Falha de rede. Verifique sua conexão.',
        details: { original: error.message }
      }));
    }

    const status = error.response.status;
    const data = error.response.data as any;

    // Mensagem vinda do back-end (ajuste conforme seu padrão)
    const message =
      data?.message ||
      data?.error ||
      (status === 401 ? 'Sessão expirada ou não autorizada.' : 'Erro ao processar a requisição.');

    return Promise.reject(new ApiError({
      type: 'HTTP',
      status,
      message,
      details: data
    }));
  }
);

Agora, qualquer chamada apiClient.get/post rejeita com ApiError padronizado, e a UI pode tratar com previsibilidade.

Tratamento global de 401/403 sem duplicação

Um caso comum: ao receber 401, você quer invalidar a sessão e redirecionar para login (ou disparar um fluxo de refresh token). Mesmo sem repetir capítulos anteriores, aqui a preocupação é: onde colocar a ação global?

Uma abordagem é registrar um “handler” global para eventos de autenticação, semelhante ao tokenGetter:

let onUnauthorized: (() => void) | null = null;

export function setOnUnauthorized(handler: () => void) {
  onUnauthorized = handler;
}

E no interceptor de response:

apiClient.interceptors.response.use(
  (r) => r,
  (err) => {
    // ...conversão para ApiError como acima
    // Supondo que você já obteve status
    const status = (err as any)?.response?.status;
    if (status === 401 && onUnauthorized) {
      onUnauthorized();
    }
    return Promise.reject(err);
  }
);

O ponto-chave é: o interceptor não deve importar diretamente o roteador ou o contexto, para não criar dependências circulares. Em vez disso, ele chama um callback registrado pela camada de aplicação.

Evitando loop de requisições e múltiplos disparos de logout

Se várias requisições falharem com 401 ao mesmo tempo, você pode disparar múltiplas ações globais. Uma proteção simples é usar um “flag”:

let unauthorizedTriggered = false;

export function resetUnauthorizedFlag() {
  unauthorizedTriggered = false;
}

// no interceptor
if (status === 401 && onUnauthorized && !unauthorizedTriggered) {
  unauthorizedTriggered = true;
  onUnauthorized();
}

Depois que o app concluir o fluxo (ex.: limpar estado e navegar), você pode resetar a flag quando apropriado.

Interceptação com fetch: criando um wrapper (fetch client)

O fetch nativo não tem interceptors. A solução é criar uma função que encapsula: (1) montagem de headers; (2) timeout; (3) parsing de JSON; (4) normalização de erros; (5) callbacks globais (401).

Passo a passo: criando um fetchClient com baseURL e timeout

1) Crie utilitários para timeout e abort:

export function withTimeout(ms: number) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), ms);
  return {
    signal: controller.signal,
    clear: () => clearTimeout(id)
  };
}

2) Crie o wrapper principal:

import { ApiError } from './ApiError';

type FetchClientOptions = {
  baseURL: string;
  timeoutMs?: number;
  getToken?: () => string | null;
  onUnauthorized?: () => void;
  isPublicEndpoint?: (path: string) => boolean;
};

export function createFetchClient(opts: FetchClientOptions) {
  const timeoutMs = opts.timeoutMs ?? 15000;

  async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
    const url = `${opts.baseURL}${path}`;

    const { signal, clear } = withTimeout(timeoutMs);

    const headers = new Headers(init.headers);
    headers.set('Accept', 'application/json');

    const isPublic = opts.isPublicEndpoint ? opts.isPublicEndpoint(path) : false;
    const token = opts.getToken ? opts.getToken() : null;
    if (!isPublic && token) {
      headers.set('Authorization', `Bearer ${token}`);
    }

    // Se body for objeto, serialize
    let body = init.body;
    if (body && typeof body === 'object' && !(body instanceof FormData)) {
      headers.set('Content-Type', 'application/json');
      body = JSON.stringify(body);
    }

    try {
      const res = await fetch(url, {
        ...init,
        headers,
        body,
        signal: init.signal ?? signal
      });

      // 204 No Content
      if (res.status === 204) {
        return undefined as T;
      }

      const contentType = res.headers.get('content-type') || '';
      const isJson = contentType.includes('application/json');
      const data = isJson ? await res.json().catch(() => null) : await res.text().catch(() => null);

      if (!res.ok) {
        if (res.status === 401 && opts.onUnauthorized) {
          opts.onUnauthorized();
        }

        const message =
          (data && (data.message || data.error)) ||
          (res.status === 401 ? 'Sessão expirada ou não autorizada.' : 'Erro ao processar a requisição.');

        throw new ApiError({
          type: 'HTTP',
          status: res.status,
          message,
          details: data
        });
      }

      return data as T;
    } catch (err: any) {
      if (err?.name === 'AbortError') {
        throw new ApiError({ type: 'ABORTED', message: 'Requisição cancelada ou expirou o tempo limite.' });
      }

      if (err instanceof ApiError) {
        throw err;
      }

      throw new ApiError({
        type: 'NETWORK',
        message: 'Falha de rede. Verifique sua conexão.',
        details: { original: String(err?.message ?? err) }
      });
    } finally {
      clear();
    }
  }

  return {
    request,
    get: <T>(path: string, init?: RequestInit) => request<T>(path, { ...init, method: 'GET' }),
    post: <T>(path: string, body?: any, init?: RequestInit) => request<T>(path, { ...init, method: 'POST', body }),
    put: <T>(path: string, body?: any, init?: RequestInit) => request<T>(path, { ...init, method: 'PUT', body }),
    patch: <T>(path: string, body?: any, init?: RequestInit) => request<T>(path, { ...init, method: 'PATCH', body }),
    delete: <T>(path: string, init?: RequestInit) => request<T>(path, { ...init, method: 'DELETE' })
  };
}

3) Configure o client em um único lugar:

import { createFetchClient } from './fetchClient';

const PUBLIC_ENDPOINTS = ['/auth/login', '/auth/refresh', '/public'];

export const fetchApi = createFetchClient({
  baseURL: import.meta.env.VITE_API_URL,
  timeoutMs: 15000,
  getToken: () => localStorage.getItem('access_token'),
  onUnauthorized: () => {
    // Aqui você chama sua ação global (ex.: limpar sessão e navegar)
  },
  isPublicEndpoint: (path) => PUBLIC_ENDPOINTS.some((p) => path.startsWith(p))
});

Mesmo que você mude a forma de armazenamento do token no futuro, você altera apenas getToken.

Padronizando a camada de API para a aplicação inteira

Interceptar é só parte do problema. Para a UI ficar simples, é útil padronizar também a assinatura das funções de API. Em vez de espalhar URLs e métodos pela aplicação, crie módulos por domínio (ex.: usersApi, ordersApi) que dependem do client (axios ou fetch) e retornam dados tipados.

Exemplo com axios:

type UserDTO = { id: string; name: string; email: string };

type UpdateUserInput = { name: string };

export const usersApi = {
  me: async () => {
    const res = await apiClient.get<UserDTO>('/users/me');
    return res.data;
  },
  updateMe: async (input: UpdateUserInput) => {
    const res = await apiClient.put<UserDTO>('/users/me', input);
    return res.data;
  }
};

Exemplo com fetch:

export const usersApi = {
  me: () => fetchApi.get<UserDTO>('/users/me'),
  updateMe: (input: UpdateUserInput) => fetchApi.put<UserDTO>('/users/me', input)
};

Note como a UI não precisa saber se é axios ou fetch, e não precisa se preocupar com headers ou parsing.

Tratando erros na UI de forma consistente

Com ApiError, qualquer camada (hooks, componentes, services) pode decidir como exibir mensagens. Um padrão prático é mapear por tipo/status:

  • NETWORK/TIMEOUT: mostrar aviso de conectividade e permitir “tentar novamente”.
  • HTTP 401: fluxo global (sessão inválida). A tela em si geralmente não precisa exibir nada além de um fallback.
  • HTTP 403: mostrar “sem permissão” (ou redirecionar para uma rota de acesso negado).
  • HTTP 422: erros de validação; exibir campos e mensagens (usando details).
  • HTTP 500+: erro inesperado; exibir mensagem genérica e registrar detalhes.

Exemplo de função utilitária para extrair mensagem amigável:

import { ApiError, isApiError } from './ApiError';

export function getFriendlyErrorMessage(err: unknown) {
  if (isApiError(err)) {
    if (err.type === 'NETWORK') return 'Sem conexão com o servidor. Tente novamente.';
    if (err.type === 'TIMEOUT') return 'O servidor demorou para responder. Tente novamente.';
    if (err.type === 'ABORTED') return 'A requisição foi cancelada.';
    if (err.type === 'HTTP') return err.message;
  }
  return 'Ocorreu um erro inesperado.';
}

Cancelamento de requisições e concorrência (evitando estados inconsistentes)

Em SPAs, é comum o usuário navegar rapidamente entre telas. Se uma requisição antiga termina depois, ela pode sobrescrever estado com dados “fora de contexto”. Duas práticas ajudam:

  • AbortController no fetch (e também no axios moderno via signal): cancele requisições ao desmontar componentes ou ao iniciar uma nova busca.
  • Controle de concorrência: mantenha um identificador da última requisição e ignore respostas antigas.

Exemplo com fetchApi usando signal:

const controller = new AbortController();

usersApi.me({ signal: controller.signal });

// ao desmontar
controller.abort();

Se você quiser suportar signal no seu wrapper, basta repassar init.signal (como já fizemos) e não sobrescrever quando ele existir.

Refresh token e reexecução de requisições (visão prática do interceptor)

Um uso avançado de interceptors é renovar o token automaticamente quando receber 401 por expiração. Mesmo que o fluxo completo dependa do seu back-end, o padrão técnico é:

  • Ao receber 401 em uma requisição autenticada, tentar /auth/refresh uma única vez.
  • Se refresh der certo, atualizar o token armazenado e reexecutar a requisição original.
  • Se refresh falhar, disparar onUnauthorized.

No axios, isso costuma ser implementado no interceptor de response, com uma fila para evitar múltiplos refresh simultâneos. O cuidado principal é evitar loop infinito: marque a requisição original com um flag (ex.: config._retry = true) e não tente refresh novamente se já tentou.

let refreshing: Promise<string> | null = null;

async function refreshToken(): Promise<string> {
  // chamada ao endpoint de refresh (sem anexar Authorization se não for necessário)
  const res = await apiClient.post<{ accessToken: string }>('/auth/refresh');
  return res.data.accessToken;
}

apiClient.interceptors.response.use(
  (r) => r,
  async (error) => {
    const status = error?.response?.status;
    const original = error.config;

    if (status === 401 && original && !original._retry) {
      original._retry = true;

      try {
        refreshing = refreshing ?? refreshToken();
        const newToken = await refreshing;
        refreshing = null;

        // Atualize onde você guarda o token e reexecute
        original.headers = original.headers ?? {};
        original.headers.Authorization = `Bearer ${newToken}`;
        return apiClient.request(original);
      } catch (e) {
        refreshing = null;
        // fallback: handler global
        // onUnauthorized?.();
      }
    }

    return Promise.reject(error);
  }
);

Esse exemplo mostra o mecanismo. Na prática, você deve integrar a atualização do token ao seu armazenamento central e garantir que o endpoint de refresh não caia na mesma lógica de anexar token/retentar (use whitelist).

Checklist de implementação (para evitar armadilhas comuns)

  • Uma única instância de client (axios) ou um único wrapper (fetch) por API.
  • Whitelist/blacklist de endpoints para não anexar JWT onde não deve.
  • Não vazar token para domínios externos: instâncias separadas ou validação de URL.
  • Normalização de erro em um tipo único (ex.: ApiError), incluindo rede/timeout/abort.
  • Handler global de 401 desacoplado (callback registrado), com proteção contra múltiplos disparos.
  • Timeout consistente (axios timeout; fetch com AbortController).
  • Parsing robusto: lidar com 204, respostas não-JSON e JSON inválido.
  • Cancelamento: suporte a signal para evitar atualizações tardias.

Agora responda o exercício sobre o conteúdo:

Qual abordagem ajuda a evitar dependências circulares ao tratar 401 de forma global em uma aplicação que intercepta requisições HTTP?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Para evitar dependências circulares, o interceptor não deve importar o roteador ou contextos. Em vez disso, ele chama um callback global (ex.: onUnauthorized) registrado pela aplicação quando detectar status 401.

Próximo capitúlo

Tratamento de expiração de token e renovação de sessão com estratégia de refresh token

Arrow Right Icon
Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.