HTTP é um protocolo de texto — um pedido é uma mensagem estruturada que o browser
envia ao servidor, e uma resposta é a mensagem estruturada que o servidor devolve.
Perceber a anatomia exacta de cada mensagem — métodos, headers, status codes,
e o mecanismo de cookies — é perceber onde o JWT viaja, como o browser o envia
automaticamente em cada pedido, e porque o HttpOnly é a defesa
correcta contra XSS.
Um pedido HTTP tem três partes: a linha de início com o método e o caminho, os headers com metadados, e o body com dados opcionais. Uma linha vazia separa os headers do body.
// Pedido HTTP completo — formato em texto (dentro do canal TLS)
POST /api/auth/login HTTP/1.1 ← linha de início: método + path + versão
Host: app.randomt.pt ← header obrigatório: identifica o servidor virtual
Content-Type: application/json ← formato do body
Content-Length: 52 ← tamanho do body em bytes
Accept: application/json ← formatos aceites na resposta
Origin: https://app.randomt.pt ← origem do pedido (usado pelo CORS)
← linha vazia obrigatória — separa headers do body
{"email":"ana@example.com","password":"secreta123"} ← body (JSON)
// Pedido GET — sem body (GETs não têm body por convenção)
GET /api/accounts/42 HTTP/1.1
Host: app.randomt.pt
Accept: application/json
Cookie: access_token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbmFAZXhhbXBsZS5jb20ifQ.abc123
↑
o JWT viaja aqui — enviado automaticamente pelo browser em cada pedido
o JavaScript não tem acesso a este valor — está em HttpOnly cookie
O método define a intenção do pedido — o que o cliente quer fazer com o recurso identificado pelo URL. REST usa os métodos como verbos semânticos sobre substantivos (os recursos).
| Método | Semântica | Body | Idempotente | Exemplo |
|---|---|---|---|---|
GET |
Ler um recurso | ❌ | ✅ | GET /api/accounts/42 |
POST |
Criar um recurso novo | ✅ | ❌ | POST /api/accounts |
PUT |
Substituir um recurso completo | ✅ | ✅ | PUT /api/accounts/42 |
PATCH |
Modificar parcialmente um recurso | ✅ | ❌ | PATCH /api/accounts/42 |
DELETE |
Eliminar um recurso | ❌ | ✅ | DELETE /api/accounts/42 |
OPTIONS |
Pré-voo CORS — "posso fazer este pedido?" | ❌ | ✅ | Enviado automaticamente pelo browser antes de pedidos cross-origin |
DELETE /accounts/42
executado dez vezes tem o mesmo efeito que executado uma vez — a conta
está eliminada. POST /accounts executado dez vezes cria dez contas.
Esta propriedade é crítica para retry automático em falhas de rede:
só é seguro repetir pedidos idempotentes.
Os headers transportam metadados sobre o pedido ou a resposta — formato
do conteúdo, autenticação, cache, origem, cookies. São pares
Nome: Valor, um por linha, case-insensitive no nome.
Alguns são definidos pelo protocolo HTTP; outros são convenções da aplicação.
// Headers de pedido mais relevantes para desenvolvimento Spring Boot:
// Identificação do conteúdo
Content-Type: application/json // formato do body enviado
Accept: application/json // formatos aceites na resposta
Content-Length: 248 // tamanho do body em bytes
// Autenticação (as duas abordagens — apenas uma é correcta)
Authorization: Bearer eyJhbGci... // ❌ JWT no header — exposto ao JavaScript
Cookie: access_token=eyJhbGci... // ✅ JWT em HttpOnly cookie — JS não acede
// Contexto do browser
Origin: https://app.randomt.pt // origem do pedido — usado pelo CORS
Referer: https://app.randomt.pt/login // página de onde veio o pedido
User-Agent: Mozilla/5.0 ... // identificação do browser
// Cache
Cache-Control: no-cache // não usar cache — sempre pedir ao servidor
If-None-Match: "abc123" // condicional: só responder se o recurso mudou
// Headers de resposta mais relevantes:
// Tipo de conteúdo
Content-Type: application/json; charset=utf-8
// Cookies — o servidor instrui o browser a guardar um cookie
Set-Cookie: access_token=eyJhbGci...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
// Segurança
Strict-Transport-Security: max-age=31536000; includeSubDomains // HSTS
X-Content-Type-Options: nosniff // impede browser de "adivinhar" o Content-Type
X-Frame-Options: DENY // impede embedding em iframe (clickjacking)
Content-Security-Policy: default-src 'self' // restringe origens de recursos
// CORS
Access-Control-Allow-Origin: https://app.randomt.pt
Access-Control-Allow-Credentials: true // necessário para cookies em pedidos cross-origin
Um cookie é um par chave-valor que o servidor instrui o browser a guardar
via o header Set-Cookie. A partir desse momento, o browser
envia o cookie automaticamente em todos os pedidos para o mesmo domínio —
sem que o JavaScript precise de fazer nada. Este comportamento automático
é exactamente o que torna os cookies HttpOnly a escolha correcta para
transportar o JWT.
// 1. LOGIN — o servidor autentica e define o cookie
// Pedido do cliente:
POST /api/auth/login HTTP/1.1
Content-Type: application/json
{"email":"ana@example.com","password":"secreta123"}
// Resposta do servidor após autenticação bem-sucedida:
HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbmFAZXhhbXBsZS5jb20iLCJpZCI6OTl9.xyz;
HttpOnly; ← JavaScript não consegue ler document.cookie para este cookie
Secure; ← só enviado sobre HTTPS — nunca sobre HTTP
SameSite=Strict;← só enviado em pedidos da mesma origem (protecção CSRF)
Path=/; ← válido para todos os paths do domínio
Max-Age=900 ← expira em 900 segundos (15 minutos)
{"message": "Login successful"}
// O browser guarda o cookie internamente.
// O JavaScript vê apenas {"message": "Login successful"} — não vê o token.
// 2. PEDIDO SUBSEQUENTE — o browser envia o cookie automaticamente
// O utilizador navega para /dashboard — o browser faz um pedido à API:
GET /api/accounts HTTP/1.1
Host: app.randomt.pt
Accept: application/json
Cookie: access_token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbmFAZXhhbXBsZS5jb20iLCJpZCI6OTl9.xyz
↑
o browser anexou o cookie automaticamente
o JavaScript não escreveu esta linha — o browser fê-lo por si próprio
o JavaScript não consegue ler este valor — HttpOnly impede document.cookie
// O JwtAuthFilter no Spring Boot lê o cookie:
String jwt = Arrays.stream(request.getCookies())
.filter(c -> "access_token".equals(c.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
// extrai o userId=99 do JWT e coloca no SecurityContext
// 3. LOGOUT — o servidor instrui o browser a apagar o cookie
// O servidor responde ao pedido de logout com um cookie de expiração imediata:
HTTP/1.1 200 OK
Set-Cookie: access_token=;
HttpOnly; Secure; SameSite=Strict; Path=/;
Max-Age=0 ← Max-Age=0 instrui o browser a apagar o cookie imediatamente
// O browser apaga o cookie.
// O JavaScript não precisa de fazer nada — não tinha acesso ao cookie de qualquer forma.
HttpOnly impede o JavaScript de ler o cookie via
document.cookie — mas o cookie ainda viaja no header HTTP
em texto claro se não houver TLS. É por isso que HttpOnly
e Secure são sempre usados em conjunto: HttpOnly protege
contra XSS, Secure protege contra intercepção em trânsito.
Os dois flags são complementares, não alternativos.
Uma resposta HTTP tem a mesma estrutura de um pedido: linha de início com o status code, headers, linha vazia, e body opcional.
// Resposta HTTP completa
HTTP/1.1 201 Created ← linha de início: versão + status code + reason phrase
Content-Type: application/json ← formato do body
Content-Length: 156 ← tamanho do body
Location: /api/accounts/43 ← URL do recurso criado (convenção para 201)
← linha vazia
{ ← body — o DTO de resposta serializado pelo Jackson
"id": 43,
"ownerName": "Ana Silva",
"balance": 1500.0,
"type": "SAVINGS",
"status": "ACTIVE",
"createdAt": "2026-06-01T10:30:00Z"
}
// Nota o que NÃO está na resposta:
// ✅ id, ownerName, balance, type, status, createdAt — dados públicos do recurso
// ❌ ownerId — campo interno de isolamento, nunca exposto (ver Módulo Java OOP)
// ❌ passwordHash — campo de segurança, nunca exposto
// ❌ internalStatus — detalhe de implementação interna
O status code comunica o resultado do pedido de forma padronizada — o cliente sabe o que aconteceu sem ter de analisar o body. Usar os códigos correctos é parte do contrato de uma API bem desenhada.
| Código | Significado | Quando usar em Spring Boot |
|---|---|---|
200 OK |
Sucesso com body | GET bem-sucedido. Retorna o recurso. |
201 Created |
Recurso criado | POST bem-sucedido. Incluir header Location com URL do novo recurso. |
204 No Content |
Sucesso sem body | DELETE bem-sucedido. PUT/PATCH sem necessidade de retornar o recurso. |
400 Bad Request |
Pedido malformado | Validação falhou (@Valid), JSON inválido, parâmetros em falta. |
401 Unauthorized |
Não autenticado | Sem cookie, cookie expirado, JWT inválido. |
403 Forbidden |
Autenticado, sem permissão | Role insuficiente. Nunca para recursos alheios — usar 404. |
404 Not Found |
Recurso não existe (ou não é teu) | Recurso inexistente e recurso que existe mas pertence a outro utilizador — isolamento lógico anti-IDOR. |
409 Conflict |
Conflito de estado | Email já registado. Operação impossível no estado actual. |
422 Unprocessable Entity |
Semântica inválida | Dados bem formados mas semanticamente inválidos (ex: data no passado obrigatoriamente futura). |
500 Internal Server Error |
Erro não tratado | Excepção inesperada apanhada pelo GlobalExceptionHandler. Mensagem genérica — sem detalhes internos. |
Com a anatomia de pedido e resposta clara, o fluxo completo da arquitectura de segurança deste projecto torna-se legível ao nível do protocolo HTTP.
// ════════════════════════════════════════════════════
// PASSO 1: Login
// ════════════════════════════════════════════════════
→ POST /api/auth/login
Content-Type: application/json
{"email":"ana@example.com","password":"secreta123"}
← 200 OK
Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Max-Age=900
{"message":"Login successful"}
// O browser guarda o cookie. O JavaScript não vê o JWT.
// ════════════════════════════════════════════════════
// PASSO 2: Pedido autenticado
// ════════════════════════════════════════════════════
→ GET /api/accounts/42
Cookie: access_token=eyJ... ← browser anexa automaticamente
Accept: application/json
[JwtAuthFilter]
1. lê Cookie: access_token=eyJ...
2. valida assinatura JWT
3. extrai sub="ana@example.com", id=99
4. coloca no SecurityContext
[AccountController]
Long userId = ((AppUserDetails) user).getId(); // → 99
[AccountService]
repository.findByIdAndOwnerId(42, 99)
[AccountRepository]
SELECT * FROM accounts WHERE id = 42 AND owner_id = 99
→ encontra: a conta 42 pertence ao utilizador 99 ✅
← 200 OK
Content-Type: application/json
{"id":42,"ownerName":"Ana Silva","balance":1500.0,...}
// ════════════════════════════════════════════════════
// PASSO 3: Tentativa de IDOR — aceder à conta de outro
// ════════════════════════════════════════════════════
→ GET /api/accounts/99 ← atacante tenta aceder à conta 99
Cookie: access_token=eyJ... (JWT válido, mas userId=99 no token)
[AccountRepository]
SELECT * FROM accounts WHERE id = 99 AND owner_id = 99
→ a conta 99 não pertence ao utilizador 99 → 0 resultados
[AccountService]
Optional.empty() → ResourceNotFoundException("Account not found")
[GlobalExceptionHandler]
ResourceNotFoundException → 404 Not Found
← 404 Not Found
{"status":404,"error":"Not Found","message":"Account not found"}
// O atacante não sabe se a conta 99 existe ou não.
// A resposta é indistinguível de um recurso genuinamente inexistente.
// ════════════════════════════════════════════════════
// PASSO 4: Logout
// ════════════════════════════════════════════════════
→ POST /api/auth/logout
Cookie: access_token=eyJ...
← 200 OK
Set-Cookie: access_token=; HttpOnly; Secure; SameSite=Strict; Max-Age=0
{"message":"Logged out"}
// Max-Age=0 instrui o browser a apagar o cookie imediatamente.
// Pedidos subsequentes chegam sem cookie → 401 Unauthorized.
Para além do Set-Cookie, uma API Spring Boot bem configurada
deve incluir um conjunto de headers de segurança que instruem o browser
a aplicar protecções adicionais.
// Configurar headers de segurança no Spring Boot
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.headers(headers -> headers
// HSTS — forçar HTTPS mesmo na primeira visita
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000) // 1 ano
)
// Impedir embedding em iframe (protecção contra clickjacking)
.frameOptions(frame -> frame.deny())
// Impedir browser de "adivinhar" o Content-Type
.contentTypeOptions(Customizer.withDefaults())
// Content Security Policy — restringir origens de recursos
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
)
)
// ... resto da configuração
.build();
}
// Resultado nos headers de resposta:
// Strict-Transport-Security: max-age=31536000; includeSubDomains
// X-Frame-Options: DENY
// X-Content-Type-Options: nosniff
// Content-Security-Policy: default-src 'self'; script-src 'self'
HttpOnly cookie — nunca no header Authorization de pedidos do browser (exposto ao JavaScript).HttpOnly (bloqueia JS) + Secure (força HTTPS) + SameSite=Strict (bloqueia CSRF).Max-Age=0 — o servidor instrui o browser a apagar o cookie.ownerId, passwordHash, ou detalhes de implementação interna.