Cookie de sessão com HttpOnly, Secure e SameSite: o JavaScript não precisa ler
Configure o cookie de sessão com HttpOnly, Secure, SameSite e prefixo __Host-, entenda o que cada flag protege e o que continua sendo trabalho seu contra XSS e CSRF.
Um comentário com script passou pela validação e foi exibido no painel. O script leu document.cookie, mandou o identificador de sessão para um servidor externo, e alguém entrou na conta do administrador sem saber a senha. O cookie de sessão nunca precisou ser lido pelo JavaScript da página. Estava visível porque ninguém marcou o contrário. A resposta curta: cookie de sessão sai do servidor com HttpOnly, Secure, SameSite=Lax e, de preferência, com o prefixo __Host-. Isso não elimina XSS nem CSRF, mas reduz muito o que um ataque consegue levar.
O ajuste é uma linha de configuração na maioria dos frameworks. O que dá trabalho é entender o que cada flag faz, para não achar que ela resolve mais do que resolve.
O que cada atributo do cookie faz
O navegador guarda o cookie e o devolve ao servidor em cada pedido para o mesmo site. Os atributos dizem quando devolver e quem mais pode ver.
HttpOnly tira o cookie do alcance do JavaScript. Ele continua indo em todo pedido, mas document.cookie não o mostra. Um script injetado não consegue copiar o valor e levar para outro lugar.
Secure faz o navegador enviar o cookie só por HTTPS. Sem ele, um link http:// qualquer para o seu domínio, numa rede Wi-Fi aberta, entrega a sessão em texto puro para quem estiver ouvindo.
SameSite controla o envio em pedidos que vêm de outro site. Com Strict, o cookie nunca vai em pedido iniciado por outro site, nem quando a pessoa clica num link para você, e ela chega parecendo deslogada. Com Lax, o cookie vai quando a pessoa navega para o seu site clicando num link (navegação de nível superior com GET), mas não vai em POST vindo de outro site, em iframe ou em fetch cruzado. Com None, vai sempre, e o navegador exige Secure junto. Alguns navegadores tratam a ausência do atributo como Lax, mas não todos. Declare explicitamente.
O prefixo __Host- no nome do cookie é uma trava do navegador: ele só aceita o cookie se tiver Secure, Path=/ e nenhum Domain. Isso impede que um subdomínio comprometido, como um blog antigo em blog.exemplo.com.br, sobrescreva a sessão do domínio principal.
Como configurar o cookie de sessão
- Defina o nome com prefixo
__Host-, por exemplo__Host-sessao. - Marque
HttpOnlyeSecure. Não há motivo para o identificador de sessão ser legível por script. - Use
SameSite=Laxcomo padrão. ConsidereStrictpara áreas administrativas onde chegar deslogado por link externo não incomoda. - Defina
Path=/e não definaDomain, a menos que precise compartilhar entre subdomínios, e aí abra mão do prefixo conscientemente. - Defina um prazo com
Max-Agecoerente com o risco: horas para painel administrativo, dias para loja, se fizer sentido. - Gere um identificador de sessão novo no momento do login, para que um identificador plantado antes não continue valendo.
- Se a aplicação roda atrás de proxy que termina o TLS, informe isso ao framework, senão ele acha que a conexão é HTTP e não envia o cookie com
Secure.
O cabeçalho que o navegador deve receber:
Set-Cookie: __Host-sessao=k3J9xQ2vT8pL...; Path=/; Max-Age=28800; Secure; HttpOnly; SameSite=Lax
Em Express com express-session:
const express = require('express');
const session = require('express-session');
const app = express();
app.set('trust proxy', 1);
app.use(session({
name: '__Host-sessao',
secret: process.env.SESSAO_SEGREDO,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 8 * 60 * 60 * 1000
}
}));
app.post('/login', express.urlencoded({ extended: false }), async (req, res, next) => {
const usuario = await autenticar(req.body.email, req.body.senha);
if (!usuario) return res.status(401).send('Não foi possível entrar.');
req.session.regenerate((erro) => {
if (erro) return next(erro);
req.session.usuarioId = usuario.id;
res.redirect(303, '/painel');
});
});
O trust proxy faz o Express confiar no cabeçalho X-Forwarded-Proto do proxy. Sem ele, atrás de Nginx com HTTPS, o express-session simplesmente não envia o cookie com secure: true. A função autenticar é a sua, com senha guardada como hash, como em login com senha protegida. O regenerate troca o identificador no login.
O que HttpOnly e SameSite não resolvem
HttpOnly impede que o script leia o cookie. Não impede que o script use o cookie. Um script injetado na sua página pode chamar fetch('/api/conta', { method: 'POST', ... }), e o navegador anexa o cookie normalmente, porque o pedido sai do seu próprio site. O atacante não leva a sessão para casa, mas faz o que quiser enquanto a página estiver aberta. A defesa contra XSS continua sendo escapar saída, validar entrada e ter uma Content-Security-Policy restritiva.
SameSite=Lax bloqueia o POST de formulário vindo de outro site, que é o CSRF clássico. Mas o conceito de site considera o domínio registrável: app.exemplo.com.br e blog.exemplo.com.br são o mesmo site. Um subdomínio vulnerável consegue enviar pedidos com o cookie. E Lax deixa passar GET de navegação, então rota GET que altera estado continua explorável. Para ações sensíveis, mantenha token anti-CSRF no formulário.
Por fim, nada disso faz sentido se o token de sessão também for copiado para localStorage "para facilitar o front". Aí o script lê de lá, e o HttpOnly vira enfeite. O mesmo raciocínio vale para tokens JWT, discutido em JWT que expira.
Erros comuns
- Cookie de sessão sem HttpOnly. Qualquer XSS leva a sessão para fora e o ataque continua mesmo depois que a página fecha.
- Sem Secure em site com HTTPS. Um único acesso por HTTP em rede aberta expõe a sessão.
- SameSite=None sem necessidade. O cookie volta a ir em todo pedido cruzado, e o CSRF clássico volta a funcionar.
- Rota GET que altera estado. Lax permite a navegação, então um link ou imagem dispara a ação com a sessão da vítima.
- Não trocar o identificador no login. Quem plantou um identificador antes do login herda a sessão autenticada.
- Esquecer o proxy. O framework não manda o cookie com Secure, alguém desliga o Secure para funcionar e a proteção some.
Checklist
- No console do navegador,
document.cookienão mostra o cookie de sessão. - Na aba Application do DevTools, o cookie aparece com HttpOnly e Secure marcados.
- O atributo SameSite está declarado explicitamente.
- O nome usa o prefixo
__Host-, ou há um motivo documentado para não usar. - O identificador muda depois do login.
- Nenhuma rota GET altera dados.
- Formulários sensíveis têm token anti-CSRF.
- O token de sessão não é copiado para
localStorage.
Abra o seu sistema logado, aperte F12 e digite document.cookie no console. Se o identificador de sessão aparecer na resposta, qualquer script que entrar na página também o vê. E a pergunta seguinte: qual rota GET do seu sistema muda alguma coisa no banco?
Referência: https://developer.mozilla.org/docs/Web/HTTP/Cookies