Autenticação com Cookies HttpOnly, Secure e SameSite
https://www.lucasmantuan.com.br
https://github.com/lucasmantuan
https://www.linkedin.com/in/lucasmantuan
Introdução
Podemos trabalhar com diversas formas de autenticação de usuários nas aplicações atuais, desde abordagens mais comuns, porém mais arriscadas, como as que salvam o token em localStorage, até soluções mais modernas e também mais complexas que, confesso, ainda estou estudando, como Cookie HttpOnly + Secure + SameSite + Refresh Rotativo + Mecanismos Sender-Constrained Atuais.
Assim, para aplicações de médio porte, gostaria de adotar um meio-termo, fugindo do padrão clássico de Bearer Token no header Authorization, evitando manter o token acessível ao JavaScript para reduzir o impacto de XSS.
XSS (Cross-Site Scripting) - É um ataque em que código JavaScript malicioso é injetado e executado no navegador, permitindo o roubo de dados ou a execução de ações indevidas no contexto da aplicação.
Após algumas pesquisas, encontrei um padrão interessante, o Cookie HttpOnly + Secure + SameSite. Como este é um universo novo para mim, vou começar explicando cada um dos elementos que compõem esse padrão. Vamos lá.
Cookies
Atributo HttpOnly
Cookies, como sabemos, são pequenas informações armazenadas pelo navegador, geralmente enviadas pelo servidor, para serem reenviadas em requisições futuras.
Dentro do header Set-Cookie enviado pelo servidor, temos diversos parâmetros de configuração do cookie. Em nosso exemplo, vamos configurar o atributo HttpOnly, que, quando definido, impede que scripts do lado do cliente leiam ou modifiquem esse cookie.
Na prática, isso significa que um cookie HttpOnly fica fora do alcance do código JavaScript, por exemplo, via document.cookie. O token não é salvo em localStorage e não é acessado diretamente pelo frontend, sendo o navegador o responsável por armazenar e enviar esse valor automaticamente, cabendo então ao backend validar o token. Isso torna o cookie HttpOnly, em conjunto com os atributos apresentados a seguir, uma alternativa mais segura para transportar o token de autenticação entre backend e frontend.
Uma diferença crucial em relação ao padrão Bearer é que, aqui, o frontend não acessa o token e não tem controle direto sobre ele. O envio sai do controle do JavaScript e passa a ser responsabilidade do navegador, diferentemente do Bearer, em que o frontend precisa anexar manualmente o token ao header da requisição.
Vale ressaltar que, independentemente de o token estar no cookie, outras informações do usuário ainda podem ser enviadas no body das respostas. Além do HttpOnly, outro atributo importante nesse padrão é o Secure.
O Atributo Secure
O atributo Secure garante que o cookie seja enviado pelo navegador somente em conexões seguras, isto é, via HTTPS sobre TLS. Com isso, a transmissão do token ocorre de forma criptografada, reduzindo o risco de interceptação.
TLS - Protocolo de segurança que cria um canal criptografado entre duas partes na rede, permitindo a verificação da identidade do servidor por meio de certificados digitais.
HTTPS sobre TLS - É o HTTP transmitido dentro de uma conexão TLS, o que significa que todas as requisições e respostas são criptografadas, íntegras e autenticadas, reduzindo fortemente o risco de interceptação e manipulação durante a comunicação.
E o Atributo SameSite
Por fim, temos o atributo SameSite, que define em quais contextos o navegador deve enviar o cookie. Essa configuração é importante porque reduz o envio automático do cookie em cenários cross-site, o que contribui para a proteção contra ataques CSRF.
Cross-Site - No contexto de cookies, é quando a navegação ou requisição parte de um site diferente, e não apenas de um endereço que muda por ter domínio, protocolo ou porta diferentes.
CSRF (Cross-Site Request Forgery) - É um ataque em que o navegador de uma vítima autenticada é induzido a enviar requisições não intencionais para um sistema confiável, usando automaticamente suas credenciais.
Os valores desse atributo são Strict, Lax e None:
Strict- Aqui, o cookie só é enviado em navegação e requisições estritamente dentro do mesmo site. É a configuração mais restritiva, com maior proteção contra CSRF, mas pode quebrar alguns fluxos legítimos, como redirecionamentos vindos de outro domínio.Lax- Já aqui, o cookie não é enviado em requisições cross-site de terceiros, mas costuma ser enviado em navegação de topo (top-level navigation), por exemplo, ao clicar em um link que leva ao seu site. É um bom equilíbrio entre segurança e compatibilidade para muitos sistemas.None- O cookie é enviado também em contextos cross-site, o que pode ser necessário em alguns fluxos. No nosso exemplo, queremos evitar isso, então, para não depender doNone, prefira manter frontend e backend no mesmo site, por exemplo, usando subdomínios.
Top-level Navigation - É quando o navegador abre uma nova página principal, como ao clicar em um link ou digitar uma URL.
Também vale lembrar outros atributos comuns do cookie:
PatheDomain- Definem o escopo de envio do cookie, ou seja, quais caminhos e domínios recebem o cookie.Max-AgeouExpires- Definem a validade do cookie, isto é, por quanto tempo ele permanece ativo. Essa configuração, como veremos abaixo, é importante em nosso fluxo.
Fluxo Básico
Vou procurar descrever aqui um fluxo básico, em alto nível, do uso desse padrão, utilizando como backend o Python com FastAPI e, no frontend, o React com Axios.
Do lado do backend, o que devemos fazer é, ao receber os dados de login enviados pelo frontend e realizar a validação, retornar um header Set-Cookie, criando um cookie que, além de armazenar o token de autenticação, deve possuir os atributos HttpOnly, Secure e SameSite, comentados anteriormente.
@app.post("/auth/login")
def login(response: Response):
access_token_jwt = "eyJhbGciOi..."
refresh_token = "rft_123..."
response.set_cookie(
key="access_token",
value=access_token_jwt,
httponly=True,
secure=True,
samesite="lax",
max_age=15 * 60,
path="/"
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="lax",
max_age=7 * 24 * 60 * 60,
path="/auth"
)
return {"message": "ok"}
Como o token não é mais visível para o frontend, a validação efetiva ocorre no backend a cada requisição, ou seja, o frontend apenas faz requisições e interpreta respostas como 200, para autenticado, ou 401, para não autenticado. Mesmo que o frontend mantenha dados de autenticação em memória, a validação real ocorre sempre no backend.
Nesse padrão, é necessário criar um novo endpoint no backend, por exemplo, GET /auth/me, que deve ser chamado sempre que se for necessário decidir se o usuário está autenticado, o que torna a navegação mais burocrática. Mais adiante, vamos falar sobre uma abordagem que simplifica esse processo.
Ao chamar esse endpoint, o navegador envia automaticamente o cookie na requisição, que é validado pelo backend, retornando, como dito anteriormente, se o usuário está ou não autenticado, cabendo então ao frontend interpretar essa resposta.
export const api = axios.create({
baseURL: "https://api.seudominio.com",
withCredentials: true
});
export function ProtectedPage() {
const [isAuthed, setIsAuthed] = useState(false);
useEffect(() => {
let cancelled = false;
async function validateSession() {
try {
await api.get("/auth/me");
if (!cancelled) setIsAuthed(true);
} catch {
if (!cancelled) setIsAuthed(false);
}
}
validateSession();
return () => {
cancelled = true;
};
}, []);
if (!isAuthed) return <div>Acesso negado. Faça login.</div>;
return <div>Conteúdo protegido</div>;
}
Outro detalhe é que o backend envia um token de acesso de curta duração no padrão JWT (access_token), utilizado nas rotas protegidas, e um token de refresh (refresh_token), geralmente tratado apenas como uma credencial pelo backend, com duração maior e usado para renovar a sessão.
JWT - É um token compacto e autocontido que carrega dados assinados digitalmente, permitindo que um sistema valide a identidade e as permissões do usuário. Em um modelo stateless, o JWT emitido não é revogado, ele expira. A revogação imediata exige estado no servidor.
Com a utilização do token de refresh, a aplicação deve estar preparada para fazer a renovação do token de acesso, funcionando basicamente da seguinte forma:
- O usuário navega normalmente.
- Quando uma rota protegida é acessada, uma chamada para
GET /auth/meé realizada, retornando 401 ou 200. - Se o acesso a essa rota protegida for realizado com o token expirado, o backend retorna 401.
- Ao receber o 401, o frontend chama
POST /auth/refresh, fazendo com que o navegador envie também, para essa rota, o cookie de refresh. - O backend então valida o refresh token e verifica se ele não foi revogado.
- Se estiver válido, emite um novo access token (JWT) e, opcionalmente, rotaciona o refresh token, enviando novos cookies.
- Caso o token seja revogado, o backend responde com 401, fazendo com que o frontend trate a resposta como um logout, redirecionando o usuário para a tela de login.
Dessa forma, o usuário não perde a sessão enquanto ainda possuir um token de refresh válido.
Para que o envio automático do cookie pelo navegador funcione quando frontend e backend estão em domínios diferentes, é necessário que o backend configure o CORS corretamente, além de permitir que a política de cookies (SameSite e Secure) possibilite esse envio. Mesmo que o backend responda, mas sem possuir o CORS adequado, o navegador impede que o JavaScript leia a resposta, ainda que o request tenha sido enviado.
CORS - Mecanismo do navegador que define se uma aplicação web pode acessar recursos de outra origem, usando cabeçalhos HTTP para permitir ou bloquear requisições cross-site de forma controlada.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.seudominio.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Como você deve ter percebido, serão realizadas diversas chamadas para GET /auth/me sempre que for necessário validar o acesso do usuário a uma determinada página. Para evitar isso, podemos criar um interceptor com refresh automático no frontend, que valida qualquer requisição como válida ou não válida, trata o retorno 401, tenta realizar o refresh e reexecuta a requisição original.
Interceptor é um componente no frontend, geralmente acoplado ao cliente HTTP, por exemplo, Axios, que intercepta requisições e respostas antes de chegarem ao código da aplicação, permitindo aplicar regras como anexar credenciais, detectar
401, acionar o refresh do token e reexecutar a requisição original.
Mesmo assim, ainda utilizaremos o GET /auth/me quando realizarmos o primeiro acesso ao site, pois o frontend ainda não fez nenhuma requisição ao backend, sendo necessário validar a sessão atual.
O logout também passa a ser responsabilidade do backend. O frontend realiza uma chamada para POST /auth/logout, revogando o refresh_token (ou encerrando a sessão no servidor, se houver) e removendo os cookies. Vale lembrar que o access_token JWT já emitido permanece válido até expirar.