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...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
Bearerpor 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/refreshuma ú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 comAbortController). - Parsing robusto: lidar com 204, respostas não-JSON e JSON inválido.
- Cancelamento: suporte a
signalpara evitar atualizações tardias.