Um endpoint de login devolve um JWT, e você tem três lugares para colocá-lo: um cookie, localStorage ou sessionStorage. Escolha errado, e ou um bug de XSS leva o token embora, ou o token nunca chega à sua API. Os três guardam strings no navegador. Eles diferem em quem pode lê-los, quanto tempo duram, e se o servidor chega a vê-los.
Como localStorage, sessionStorage e cookies se diferenciam
localStorage e sessionStorage são a mesma API, a interface Storage: setItem, getItem, removeItem, clear, chaves e valores em string. A diferença é o tempo de vida. localStorage sobrevive ao fechar o navegador e reiniciar a máquina, e permanece até algo limpá-lo. O Safari é a exceção: com a prevenção de rastreamento ativada, ele apaga o storage escrito por script de um site com o qual o usuário não interage há sete dias. sessionStorage pertence a uma aba e morre quando essa aba fecha. Digite a mesma URL em uma segunda aba e você recebe um sessionStorage novo e vazio; só “Duplicar aba” copia o conteúdo.
Cookies são mais antigos e funcionam de forma diferente. O servidor define um com o header de resposta Set-Cookie, e a partir daí o navegador o anexa automaticamente a toda requisição correspondente, no header Cookie. Isso inclui requisições de imagem e stylesheet e chamadas fetch same-origin que você nunca configurou para enviar nada. localStorage e sessionStorage nunca saem do navegador, a menos que seu JavaScript os leia e os envie para algum lugar.
Enviado automaticamente versus nunca enviado: essa única propriedade resolve a maior parte da pergunta “qual usar”.
Limites de armazenamento e persistência comparados
| Cookies | localStorage | sessionStorage | |
|---|---|---|---|
| Enviado ao servidor | Sim, em toda requisição correspondente | Não | Não |
| Capacidade | ~4KB por cookie | ~5-10MB por origin (varia com o navegador) | ~5-10MB por origin |
| Persistência | Definida por Expires/Max-Age; cookie de sessão se omitido | Até ser explicitamente limpo | Até a aba fechar |
| Escopo | Domínio + path (configurável) | Origin (esquema + host + porta) | Origin, e só a aba atual |
| Legível via JS | Sim, a menos que HttpOnly | Sempre | Sempre |
| API | Header de string, ou document.cookie | Storage (síncrona) | Storage (síncrona) |
A diferença de capacidade é o motivo de carrinhos e blobs de configuração não irem em cookies. 4KB se esgota rápido quando você guarda JSON, e cada byte é reenviado em toda requisição, quer o endpoint precise dele ou não. Uma página que carrega 40 assets estáticos do mesmo domínio com um cookie de 3KB anexado envia esse cookie 40 vezes.
Como ler e escrever cada um em JavaScript
localStorage e sessionStorage usam as mesmas chamadas:
| |
Ambos guardam apenas strings. setItem("user", {name: "Dan"}) guarda a string literal "[object Object]". Serialize antes:
| |
Em JavaScript, um cookie passa por document.cookie:
| |
Parece uma atribuição que sobrescreve tudo. Não é. Cada atribuição define ou atualiza um cookie, e ler document.cookie de volta devolve todos os cookies da página como uma única string unida por ; . Você mesmo precisa dividir por ; e depois pelo primeiro =, ou usar uma biblioteca pequena que já faz isso.
A maioria dos cookies é definida pelo servidor, nos headers de resposta. Essa é também a única forma de obter as proteções que importam para qualquer coisa sensível:
| |
Essa chamada produz um header de resposta como:
| |
HttpOnly, Secure e SameSite: o que torna um cookie seguro
HttpOnlyremove o cookie dedocument.cookiepor completo. O JavaScript da página não pode lê-lo, e isso inclui um script injetado por um atacante. Para um token de sessão, esse é o motivo principal para usar um cookie em vez delocalStorage: um bug de XSS que executa JS arbitrário ainda não consegue exfiltrar um cookieHttpOnly.Secureimpede que o navegador envie o cookie por HTTP puro; ele só sai por HTTPS. Ative em tudo em produção. Veja o que o TLS faz por baixo do HTTPS para entender o valor dessa proteção.SameSitecontrola se o cookie sai em requisições cross-site.Strictsó o envia em requisições same-site.Laxainda o envia em navegação GET de nível superior, então seguir um link de um email ainda te loga, mas retém o cookie em POSTs e chamadasfetchcross-site. Isso bloqueia a maioria dos ataques de cross-site request forgery.Noneenvia em todo lugar e exigeSecure, ou o navegador descarta o cookie de imediato. Chrome e Edge tratam um cookie semSameSitecomoLax; Firefox e Safari não, então defina explicitamente.
localStorage e sessionStorage não têm equivalente a nada disso. O que você colocar ali é legível por todo script que roda na sua origin: seu próprio código, uma dependência npm comprometida, o payload de um atacante depois de um buraco de XSS. Cookies não são mais seguros por natureza. Eles têm um modo opt-in que o JavaScript não consegue tocar, e localStorage não tem. Esse é todo o argumento contra manter um token de autenticação em localStorage.
Por que cookies não são enviados em requisições fetch cross-origin
Uma página em app.example.com chama api.example.com com fetch. O cookie definido por api.example.com fica em casa, mesmo com o domínio certo no cookie, porque fetch só envia credenciais para a mesma origin por padrão. Você precisa habilitar isso explicitamente:
| |
O servidor também precisa cooperar. Uma vez que credenciais entram em jogo, ele não pode responder com o wildcard Access-Control-Allow-Origin: *. Ele precisa ecoar a origin específica e adicionar Access-Control-Allow-Credentials: true. Essa é a negociação de CORS coberta em O Que É CORS e Como Corrigir Erros CORS, e é onde moram a maioria dos bugs de “meu cookie não aparece”.
Como sincronizar localStorage entre abas com o evento storage
Quando localStorage muda, o navegador dispara um evento storage em toda outra aba da mesma origin, não na aba que fez a mudança. Isso permite deslogar um usuário em todas as abas no momento em que uma delas chama localStorage.removeItem("token"):
| |
sessionStorage nunca dispara esse evento entre abas. Nenhuma outra aba consegue vê-lo.
Quando não usar cookies, localStorage ou sessionStorage
Cookies são errados para qualquer coisa acima de alguns kilobytes e para dados que o servidor nunca lê, como um tema de UI ou um comentário pela metade. Você estaria reenviando isso em toda requisição sem necessidade nenhuma.
localStorage é errado para qualquer coisa que precise sobreviver a um bug de XSS: tokens de sessão, chaves de API, qualquer coisa que você odiaria ver em um relatório de incidente dizendo “o atacante rodou console.log(localStorage)”. Também é errado para dados sobre os quais o servidor age. Ele nunca sai do navegador a menos que seu código o envie, e uma vez que você escreve esse código, você reconstruiu um cookie sem HttpOnly, Secure ou SameSite.
sessionStorage é errado para qualquer coisa compartilhada entre abas ou que se espera encontrar depois que o usuário fecha e reabre o navegador. Ele se encaixa em um formato específico de problema: estado que pertence a esta aba, nesta visita. O passo atual de um checkout multi-página é o caso clássico, já que não deveria vazar para uma segunda aba que o usuário abriu para comparar preços.
Acima de alguns megabytes, ou quando você precisa de consultas em vez de busca por chave, nenhum dos três serve. Use IndexedDB antes de tentar enfiar alguns milhares de registros JSON no localStorage e bater na cota.
Qual usar para tokens, carrinhos e preferências
| Dado | Mecanismo | Por quê |
|---|---|---|
| Token de sessão, estado de autenticação | Cookie HttpOnly + Secure | O servidor precisa dele; o JS não pode lê-lo |
| Token CSRF | Cookie (double-submit) ou sessão no servidor | Precisa ir e voltar com a requisição que protege |
| Tema, idioma, preferência de UI | localStorage | Pequeno, não sensível, deve persistir |
| Rascunho de formulário multi-etapa, estado de wizard | sessionStorage | Por aba, descartável, sem necessidade de ida e volta ao servidor |
| Carrinho de compras (não autenticado) | localStorage, ou no servidor ligado a um cookie de sessão | Depende de precisar sobreviver a um navegador limpo |
| Dados estruturados grandes, cache offline | IndexedDB | Precisa de capacidade e consultas que nenhum dos três oferece |
Se o seu servidor precisa confiar nele, coloque em um cookie HttpOnly, Secure, com SameSite explícito. Deixe o localStorage para dados que você entregaria sem problema a qualquer script da sua página.