Ao autenticar uma SPA com JWT (ou qualquer token de acesso), uma decisão prática aparece rapidamente: onde armazenar o token no front-end. Essa escolha afeta segurança, experiência do usuário, comportamento em múltiplas abas, persistência após fechar o navegador e até o desenho do fluxo de renovação (refresh). Não existe uma opção “perfeita”; existem trade-offs que precisam ser entendidos para escolher conscientemente.
Neste capítulo, você vai comparar localStorage, sessionStorage e cookies (incluindo cookies HttpOnly) sob a ótica de SPAs com React e APIs com JWT, com exemplos práticos e um passo a passo de implementação para cada abordagem.
O que exatamente estamos armazenando?
Em autenticação baseada em tokens, normalmente existem dois “tipos” de token:
- Access token: curto (minutos), enviado em cada requisição para autorizar acesso a recursos.
- Refresh token: mais longo (dias/semanas), usado para obter um novo access token quando ele expira.
Um padrão comum e mais seguro é: access token em memória (não persistente) e refresh token em cookie HttpOnly (persistente e inacessível ao JavaScript).
Mas há cenários em que você pode optar por persistir o access token também, ou não usar refresh token e exigir login novamente.
Independentemente do padrão, o ponto central é: qual superfície de ataque você aceita (XSS, CSRF, roubo por extensões, vazamento por logs) e qual UX você deseja (lembrar login, manter sessão entre abas, etc.).
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
Critérios para decidir: segurança e comportamento
Ameaças principais
- XSS (Cross-Site Scripting): se um atacante conseguir executar JavaScript na sua página, ele pode ler dados acessíveis via JS (localStorage, sessionStorage, cookies não-HttpOnly) e exfiltrar tokens.
- CSRF (Cross-Site Request Forgery): se sua autenticação depende de cookies enviados automaticamente pelo navegador, um site malicioso pode disparar requisições para sua API usando o cookie da vítima (a menos que você mitigue com SameSite/CSRF token).
- Persistência indevida: tokens que ficam no dispositivo podem ser usados por outra pessoa (computador compartilhado) ou por malware/extensões.
- Exposição acidental: tokens em storage podem parar em prints, logs, backups do navegador, ferramentas de debug, etc.

Comportamento esperado
- Persistir após fechar o navegador? localStorage e cookies persistem; sessionStorage não.
- Compartilhar sessão entre abas? localStorage é compartilhado entre abas do mesmo origin; sessionStorage é isolado por aba; cookies valem para todas as abas.
- Sincronizar logout entre abas? localStorage facilita via evento
storage; cookies exigem outra estratégia (ex.: BroadcastChannel, polling, ou resposta 401 global). - Enviar token automaticamente? cookies são enviados automaticamente (com regras); localStorage/sessionStorage exigem adicionar header manualmente.
localStorage: simples e persistente, mas exposto a XSS
localStorage é um armazenamento chave-valor persistente por origin. Ele é fácil de usar, sobrevive a reload e fechamento do navegador, e é compartilhado entre abas. O grande problema: qualquer JavaScript rodando na página pode ler. Em caso de XSS, o token pode ser roubado.
Quando faz sentido
- Aplicações internas/ambientes controlados com baixo risco e forte mitigação de XSS.
- Protótipos e MVPs (com consciência do risco).
- Quando você precisa de persistência simples e não quer lidar com cookies/CSRF.
Trade-offs
- Pró: simples; persistente; fácil de depurar; não depende de configuração de cookie/CORS.
- Contra: vulnerável a XSS; token fica acessível a extensões; exige adicionar header manualmente; revogação é difícil se o token for longo.
Passo a passo: armazenar e usar access token no localStorage
1) Defina funções utilitárias para set/get/remove:
const TOKEN_KEY = "access_token";export function setAccessToken(token) { localStorage.setItem(TOKEN_KEY, token);}export function getAccessToken() { return localStorage.getItem(TOKEN_KEY);}export function clearAccessToken() { localStorage.removeItem(TOKEN_KEY);}2) Configure um cliente HTTP (ex.: fetch wrapper) para anexar o token:
export async function apiFetch(url, options = {}) { const token = getAccessToken(); const headers = new Headers(options.headers || {}); if (token) headers.set("Authorization", `Bearer ${token}`); const res = await fetch(url, { ...options, headers }); if (res.status === 401) { // opcional: limpar token e sinalizar logout clearAccessToken(); } return res;}3) No login, após receber o token, persista:
async function login(email, password) { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) }); if (!res.ok) throw new Error("Falha no login"); const data = await res.json(); setAccessToken(data.accessToken); return data;}4) No logout, remova:
function logout() { clearAccessToken();}Mitigações mínimas se você usar localStorage
- Evite token de longa duração (curto + refresh em canal mais seguro).
- Reduza risco de XSS: sanitize de HTML, evite
dangerouslySetInnerHTML, valide entradas, use CSP (Content Security Policy). - Não armazene dados sensíveis extras junto do token (perfil completo, permissões detalhadas, etc.).
sessionStorage: reduz persistência, mantém exposição a XSS
sessionStorage é parecido com localStorage, mas com duas diferenças importantes: ele é isolado por aba e é apagado ao fechar a aba (ou o navegador, dependendo do comportamento). Isso diminui o risco em dispositivos compartilhados e reduz o tempo de exposição do token em repouso, mas não resolve XSS, porque ainda é acessível via JavaScript.
Quando faz sentido
- Você quer que o usuário precise logar novamente ao fechar a aba.
- Você quer evitar que uma aba “herde” a sessão de outra (isolamento por aba).
- Você quer persistência apenas durante a sessão atual, mas ainda precisa sobreviver a reload.
Trade-offs
- Pró: menos persistente; reduz impacto em máquinas compartilhadas; simples.
- Contra: ainda vulnerável a XSS; não compartilha sessão entre abas; UX pode ser pior (login mais frequente).
Passo a passo: trocar localStorage por sessionStorage
1) Troque as funções utilitárias:
const TOKEN_KEY = "access_token";export function setAccessToken(token) { sessionStorage.setItem(TOKEN_KEY, token);}export function getAccessToken() { return sessionStorage.getItem(TOKEN_KEY);}export function clearAccessToken() { sessionStorage.removeItem(TOKEN_KEY);}2) O resto do fluxo (anexar no header Authorization, limpar em 401, etc.) permanece igual.
3) Se você precisa sincronizar logout entre abas, sessionStorage não ajuda (cada aba tem o seu). Uma alternativa é: ao receber 401, forçar logout local e redirecionar; outra é usar BroadcastChannel para comunicar eventos entre abas:
const authChannel = new BroadcastChannel("auth");export function broadcastLogout() { authChannel.postMessage({ type: "LOGOUT" });}export function onAuthMessage(handler) { authChannel.onmessage = (event) => handler(event.data);}Ao fazer logout em uma aba, você envia a mensagem e as outras abas limpam seu sessionStorage e atualizam a UI.
Cookies: convenientes, mas exigem atenção a CSRF e atributos
Cookies podem armazenar tokens e são enviados automaticamente pelo navegador em requisições para o domínio/rota correspondente. Isso pode simplificar o cliente (você não precisa anexar Authorization manualmente), mas muda o modelo de ameaça: CSRF passa a ser uma preocupação central.
Além disso, cookies têm atributos que impactam diretamente segurança e comportamento:
- HttpOnly: impede leitura via JavaScript. Ajuda muito contra exfiltração por XSS (o atacante não consegue “ler” o cookie), embora XSS ainda possa fazer ações em nome do usuário se a sessão estiver ativa.
- Secure: cookie só é enviado via HTTPS.
- SameSite: controla envio em navegação cross-site. Valores comuns:
Lax,Strict,None(este exige Secure). É uma mitigação importante contra CSRF. - Path e Domain: restringem onde o cookie é enviado.
Cookie não-HttpOnly (token acessível via JS)
Armazenar token em cookie sem HttpOnly é, na prática, parecido com localStorage do ponto de vista de XSS (o JS consegue ler). A diferença é que o cookie pode ser enviado automaticamente, o que pode aumentar risco de CSRF se você também depender dele para autenticar.
Em geral, se você vai usar cookie para token, prefira HttpOnly e desenhe o fluxo para não precisar ler o token no JS.
Cookie HttpOnly (recomendado para refresh token)
O padrão mais usado em SPAs seguras é:
- Refresh token em cookie HttpOnly + Secure + SameSite.
- Access token em memória (variável/estado) e renovado via endpoint de refresh.
Assim, mesmo que ocorra XSS, o atacante não consegue extrair o refresh token do cookie. Ainda assim, XSS é grave (pode disparar requisições enquanto a sessão existir), mas você reduz a chance de “roubo reutilizável” do token.
Trade-offs de cookies
- Pró: com HttpOnly, token não fica acessível ao JS; envio automático; boa compatibilidade com SSR e APIs tradicionais.
- Contra: exige tratar CSRF; CORS e credenciais precisam estar corretos; SameSite pode impactar cenários cross-site; depuração pode ser menos direta.
Passo a passo prático: autenticação com refresh token em cookie HttpOnly
O objetivo aqui é: o front-end não armazena refresh token em lugar nenhum acessível ao JS. O servidor define o cookie no login e o navegador o envia automaticamente no refresh.
1) Backend: endpoint de login setando cookie HttpOnly
Exemplo conceitual (Node/Express) do que o servidor precisa fazer:
// POST /api/auth/login// valida credenciais, gera accessToken e refreshTokenres.cookie("refresh_token", refreshToken, { httpOnly: true, secure: true, // em produção sameSite: "lax", // ou "strict" dependendo do fluxo path: "/api/auth/refresh"});res.json({ accessToken });Pontos importantes:
- path restrito ao endpoint de refresh reduz exposição do cookie.
- sameSite: se seu front e API estão no mesmo site (mesmo “site” para o navegador),
Laxcostuma funcionar bem. Se você precisa de cross-site (domínios diferentes em contexto de terceiro), pode precisar deSameSite=None; Secure, o que aumenta exigência de mitigação de CSRF.
2) Front-end: guardar access token em memória
Você pode manter o access token em uma variável de módulo ou em estado (ex.: Context). O ponto é: não persistir. Exemplo simples com variável de módulo:
let inMemoryAccessToken = null;export function setInMemoryToken(token) { inMemoryAccessToken = token;}export function getInMemoryToken() { return inMemoryAccessToken;}export function clearInMemoryToken() { inMemoryAccessToken = null;}3) Front-end: login recebe access token e não persiste em storage
export async function login(email, password) { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ email, password }) }); if (!res.ok) throw new Error("Falha no login"); const data = await res.json(); setInMemoryToken(data.accessToken); return data;}Note o credentials: "include": ele é necessário para o navegador aceitar cookies em cenários com CORS e para enviar cookies em requisições subsequentes quando apropriado.
4) Front-end: endpoint de refresh para obter novo access token
export async function refreshAccessToken() { const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" }); if (!res.ok) throw new Error("Refresh falhou"); const data = await res.json(); setInMemoryToken(data.accessToken); return data.accessToken;}5) Front-end: wrapper de fetch com retry após 401
Quando uma requisição falhar com 401 por expiração do access token, você tenta refresh e repete a requisição uma vez:
export async function apiFetch(url, options = {}) { const headers = new Headers(options.headers || {}); const token = getInMemoryToken(); if (token) headers.set("Authorization", `Bearer ${token}`); let res = await fetch(url, { ...options, headers, credentials: "include" }); if (res.status !== 401) return res; // tenta renovar e repetir try { const newToken = await refreshAccessToken(); const retryHeaders = new Headers(options.headers || {}); retryHeaders.set("Authorization", `Bearer ${newToken}`); res = await fetch(url, { ...options, headers: retryHeaders, credentials: "include" }); return res; } catch { clearInMemoryToken(); return res; }}Esse padrão evita que o usuário seja desconectado imediatamente quando o access token expira, sem precisar persistir tokens em storage.
6) Backend: endpoint de logout limpando cookie
// POST /api/auth/logoutres.clearCookie("refresh_token", { path: "/api/auth/refresh"});res.status(204).send();E no front-end:
export async function logout() { await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); clearInMemoryToken();}CSRF em autenticação por cookie: como pensar e mitigar
Se a sua API autentica o usuário com base em cookie (especialmente se você não usa Authorization header), você precisa mitigar CSRF. Mesmo usando Authorization header para recursos, o endpoint de refresh e logout normalmente depende do cookie.
Camadas comuns de mitigação:
- SameSite:
LaxouStrictjá reduz bastante CSRF em muitos cenários.Noneé mais permissivo e exige mais cuidado. - CSRF token: o servidor emite um token anti-CSRF (não HttpOnly) e o front envia em header (ex.:
X-CSRF-Token) em requisições sensíveis. O servidor valida se o token confere com o esperado para aquela sessão/cookie. - Verificação de Origin/Referer: para requisições state-changing, validar
Origine/ouReferercontra domínios permitidos. - Evitar efeitos colaterais em GET: endpoints que alteram estado devem ser POST/PUT/PATCH/DELETE.
Exemplo de envio de CSRF token (conceitual):
// suponha que o backend disponibilize um endpoint que retorna csrfTokenexport async function getCsrfToken() { const res = await fetch("/api/auth/csrf", { credentials: "include" }); const data = await res.json(); return data.csrfToken;}export async function apiFetchWithCsrf(url, options = {}) { const csrf = await getCsrfToken(); const headers = new Headers(options.headers || {}); headers.set("X-CSRF-Token", csrf); return fetch(url, { ...options, headers, credentials: "include" });}Comparativo direto: qual escolher?
Se você prioriza segurança contra roubo de token
- Preferência: refresh token em cookie HttpOnly + access token em memória.
- Motivo: reduz exfiltração por XSS (não elimina XSS, mas diminui impacto de roubo persistente).
Se você prioriza simplicidade de implementação no front-end
- Preferência: localStorage (ou sessionStorage) com Authorization header.
- Cuidado: invista em mitigação de XSS e mantenha tokens curtos.
Se você quer “lembrar login” sem complexidade de refresh
- Preferência: localStorage com token de duração moderada.
- Risco: maior janela de exposição se o token for roubado.
Se você quer sessão que morre ao fechar a aba
- Preferência: sessionStorage (ou apenas memória).
- Observação: memória é ainda mais restritiva (perde no reload), sessionStorage sobrevive a reload.
Armadilhas comuns e boas práticas
1) Guardar token e dados sensíveis juntos
Evite armazenar no storage objetos completos de usuário com permissões detalhadas, e-mails, etc. Guarde o mínimo necessário. Se precisar de dados do usuário, busque do backend e mantenha em memória.
2) Token longo demais
Quanto maior a validade, maior o impacto de vazamento. Prefira access tokens curtos e, se necessário, refresh com rotação no servidor.
3) Falta de rotação/revogação
Se você usa refresh token, considere rotação (refresh token muda a cada uso) e invalidação no servidor em logout/comprometimento. Isso reduz reutilização em caso de vazamento.
4) CORS e credenciais mal configurados (cookies)
Para cookies funcionarem em chamadas cross-origin, o backend precisa responder com headers adequados e o front precisa usar credentials: "include". Além disso, Access-Control-Allow-Origin não pode ser * quando Allow-Credentials está habilitado.
5) Confundir “token inacessível ao JS” com “app imune a XSS”
Cookie HttpOnly impede leitura do token via JS, mas XSS ainda pode disparar ações autenticadas (porque o navegador envia cookies automaticamente). Por isso, XSS deve ser tratado como prioridade independentemente do mecanismo de armazenamento.
6) Sincronização entre abas
Se você usa localStorage, pode sincronizar logout com o evento storage:
window.addEventListener("storage", (e) => { if (e.key === "access_token" && e.newValue === null) { // outra aba fez logout: atualize a UI local }});Com cookies HttpOnly, você tende a depender mais de: resposta 401 global, BroadcastChannel, ou um endpoint de “me”/sessão para revalidar periodicamente.