Sessões e cookies
Deixe sua mochila em um armário de academia e você recebe uma chave numerada. A chave não diz o que tem dentro, não vale nada sozinha, e não serve para nada a quem estiver em qualquer lugar que não seja aquele vestiário.
Isso é um session cookie. A parte valiosa fica com o servidor; o navegador carrega apenas um número.
O que cada lado guarda
O servidor mantém uma session: um pequeno conjunto de dados representando uma pessoa logada.
{
sessionId: 'a3f9c2e7b418',
userId: 4821,
role: 'teacher',
expires: '2026-09-05T14:30:00Z',
}Isso é o armário. Ele nunca sai do servidor.
O navegador recebe um cookie carregando o id da sessão e nada mais:
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })Essa é a chave. O navegador a armazena sem que ninguém peça e a envia automaticamente em toda requisição para aquele domínio, então continuar logado não exige nenhum código da sua parte.
O que é conveniente, e também é o motivo de um cookie enviado ao lugar errado ser um problema tão sério. Ele vai para todo lugar para onde aquele domínio vai.
express-session cria a sessão, a armazena, define o cookie e te entrega req.session para ler e escrever. E é exatamente por isso que os erros abaixo ainda acontecem. O framework decide onde as sessões ficam e como os cookies são configurados; você decide o que vai dentro deles e por quanto tempo duram.
crypto.randomBytes atende a esse requisito, Math.random não, e um id que qualquer pessoa consiga prever é uma conta que qualquer pessoa consegue ocupar sem senha. O cookie também precisa de três atributos que o framework não vai escolher por você. HttpOnly impede que o JavaScript o leia, de modo que um payload de XSS não consiga roubar a sessão.
Secure mantém o cookie fora do HTTP puro. SameSite controla se ele acompanha requisições entre sites, o que é a defesa contra cross-site request forgery: outro site fazendo uma requisição à qual o seu navegador anexa o cookie.
Não presuma um padrão para esse último atributo. O Chromium trata um SameSite não definido como Lax; o Firefox não faz isso, e o bug 1617609 da Mozilla foi resolvido como WONTFIX com a justificativa "quando nenhum atributo SameSite é definido, usamos None por padrão". A proteção do Safari vem do bloqueio de cookies de terceiros em vez disso. Defina esse atributo explicitamente, e lembre-se de que SameSite=None exige Secure.
O ciclo completo do login
Seis passos, e apenas os dois primeiros envolvem uma senha:
- O usuário envia um nome de usuário e uma senha.
- O servidor os confere com um usuário armazenado.
- Em caso de sucesso, o servidor cria uma sessão contendo o id do usuário e o que mais for necessário, como um papel (role).
- O servidor devolve um cookie contendo o id da sessão.
- O navegador o armazena e passa a anexá-lo a toda requisição futura para aquele domínio.
- O servidor lê o id, procura a sessão correspondente e sabe quem está fazendo a requisição.
O passo seis se repete durante toda a vida da sessão. Essa consulta é o que caracteriza esse modelo por completo: o servidor é consultado a cada vez, então ele pode mudar de ideia a qualquer momento.
Tudo que vem depois é o id da sessão fazendo o trabalho, então proteger esse id importa tanto quanto proteger a senha importava antes.
Isso é aceitável para um papel que raramente muda; não é aceitável para um que pode ser revogado durante um incidente. Essa escolha é sua, não do framework.
Isso é session fixation, e a correção é regenerar o id no momento em que o privilégio muda: no login, e novamente em qualquer elevação, como para admin. No Express isso é req.session.regenerate(), e a sessão antiga precisa ser destruída, não simplesmente abandonada.
Duas formas de arruinar um cookie
Colocar dados reais nele. A versão tentadora parece útil:
res.cookie('session', {
sessionId: 'a3f9c2e7b418',
userId: 4821,
role: 'admin',
email: '[email protected]',
password: 'hunter2',
ip: '203.0.113.42',
})Tudo além de sessionId é um risco. Cookies são interceptados, gravados em logs, sincronizados entre dispositivos e lidos por qualquer JavaScript rodando na página. Detalhes privados agora viajam para todo lugar que o usuário vai, e um papel (role) ou id vazado já é o suficiente para ajudar alguém a se passar por ele.
Na STRIDE isso é information disclosure e spoofing; em termos da OWASP, broken access control.
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })Deixá-lo viver para sempre. Um cookie sem maxAge ou expiração é mantido indefinidamente por muitos navegadores:
res.cookie('sid', 'a3f9c2e7b418')Um cookie roubado então continua útil pelo tempo que o navegador o mantiver, e alguém cujo acesso deveria ter terminado ainda tem uma chave funcionando. Isso é elevation of privilege, e uma falha de autenticação em termos da OWASP. Trinta minutos é um ponto de partida razoável:
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })A parte difícil é perceber o problema, porque um cookie cheio de dados úteis e um cookie que nunca expira funcionam perfeitamente até o dia em que param de funcionar.
maxAge do Express é em milissegundos, enquanto o atributo Max-Age no cabeçalho HTTP é em segundos, então 1800 no lugar errado vira trinta minutos ou menos de dois segundos, dependendo do que você pretendia. Em uma revisão, a pergunta para qualquer cookie é o que ele guarda e quando ele morre. Duas coisas para checar, e o resto é detalhe.
A combinação que vale conhecer é um timeout de inatividade somado a um limite absoluto: trinta minutos de inatividade, e um teto rígido de oito ou doze horas independentemente disso. Sem o limite absoluto, um cookie roubado que é mantido "aquecido" pelo próprio tráfego do atacante nunca expira.
Duas formas de arruinar uma sessão
Sobrecarregá-la. Uma sessão deveria servir para identificar alguém, mas ela vai acumulando coisas:
{
sessionId: 'a3f9c2e7b418',
userId: 4821,
role: 'teacher',
cart: [ /* 14 items */ ],
lastFivePages: [ /* ... */ ],
theme: 'dark',
cachedPosts: [ /* ... */ ],
}Sessões vivem na memória do servidor, então isso custa memória por usuário logado. Pior, os dados ficam desatualizados: a aplicação passa a tomar decisões a partir de uma cópia de dados que o banco de dados já alterou desde então. A STRIDE chama isso de tampering, não porque alguém editou algo, mas porque a decisão se apoia em algo que deixou de ser verdade.
{
sessionId: 'a3f9c2e7b418',
userId: 4821,
role: 'teacher',
expires: '2026-09-05T14:30:00Z',
}Deixá-la viver para sempre. O mesmo erro do cookie, um nível abaixo e ainda pior, porque agora é o próprio servidor que está confiando em dados desatualizados. Uma sessão sem expiração significa que um cookie roubado funciona indefinidamente, que uma permissão revogada nunca chega a ter efeito, e que deslogar alguém de todos os lugares se torna impossível.
A diferença é que um problema de cookie é visível no navegador, enquanto um problema de sessão fica no servidor, onde ninguém olha.
Um carrinho de compras pertence ao banco de dados, onde sobrevive a um reinício do servidor e acompanha o usuário para outro dispositivo. Colocá-lo na sessão geralmente significa perdê-lo bem na pior hora.
A consequência prática é que um deploy desloga todo mundo, e por trás de um load balancer metade dos seus usuários fica deslogada de forma aleatória. O Redis costuma ser a resposta padrão. Fique atento à versão: o connect-redis v8 exporta RedisStore como uma exportação nomeada, diferente das versões v6 e v7, e isso costuma pegar quem está fazendo upgrade.
Os frameworks lidam bem com a mecânica. Express, Django, Rails e Laravel gerenciam o armazenamento e definem padrões sensatos para os cookies. Nenhum deles decide o que você coloca na sessão ou por quanto tempo ela dura, e é exatamente aí que vivem esses quatro erros.
Tente você mesmo
Quatro cenários. Identifique os problemas e corrija cada um.
// 1. Cookie definido após o login
res.cookie('session', {
sessionId: 'a3f9c2e7b418',
userId: 4821,
password: 'hunter2',
ip: '203.0.113.42',
}, { maxAge: 1000000000000 })
// 2. Cookie definido após o login
res.cookie('sid', 'a3f9c2e7b418')
// 3. Uma sessão armazenada
{ sessionId: 'b7d1', userId: 91, role: 'admin', cart: [], theme: 'dark',
ip: '203.0.113.42', expires: '3000-01-01T00:00:00Z' }
// 4. Um cookie e sua sessão
res.cookie('sid', { userId: 91, role: 'admin' }, { maxAge: 2000 })
{ sessionId: 'c4e2', userId: 91, role: 'admin', expires: '2099-01-01T00:00:00Z' }Compare suas respostas
| # | Problemas | Correção |
|---|---|---|
| 1 | Dados sensíveis no cookie, e um maxAge de cerca de 32 anos | Manter apenas sessionId, maxAge: 1800000 |
| 2 | Nenhuma expiração, então muitos navegadores o mantêm indefinidamente | Adicionar maxAge |
| 3 | Sobrecarregada, e expira no ano 3000 | Manter sessionId, userId, role, expiração em 30 minutos |
| 4 | O cookie não tem id de sessão e carrega userId e role em vez disso; seu maxAge de 2 segundos é inutilizável; a sessão expira em 2099 | Colocar o id da sessão no cookie, descartar o resto, maxAge: 1800000, expiração em 30 minutos |
O cenário 4 é o mais interessante, porque falha nas duas direções ao mesmo tempo. O cookie é ao mesmo tempo curto demais para ser útil e carrega dados que nunca deveriam sair do servidor, enquanto a sessão por trás dele dura quase um século.
Se suas correções diferirem nos detalhes, tudo bem. O que importa é chegar ao raciocínio.
Para onde isso vai
A identidade stateful é robusta porque o servidor continua no comando. Cada um desses quatro erros corrói isso: dados escapando do servidor, ou uma decisão que sobrevive além dos fatos que a sustentavam.
Tokens opacos (bearer tokens) começa o arco oposto, no qual o servidor deixa de manter qualquer sessão e o cliente passa a carregar sua própria prova.

