
O Erro Silencioso do OAuth: Por Que o Token Nunca Deveria Passar pela URL
O login OAuth funciona. O popup abre, o usuário aprova no Google, o popup fecha, e a sua aplicação recebe ?token=eyJhbGciOi... na URL de callback. Você lê o token da query string, salva ele em algum lugar, e a demo funciona perfeitamente na sua tela.
Esse é exatamente o momento em que a maioria das implementações de OAuth introduz uma falha de segurança silenciosa — uma que passa em todo teste manual, todo QA, e só aparece quando alguém audita os logs de produção seis meses depois.
1. Por que a barra de endereço não é um lugar seguro para um segredo
Uma URL parece efêmera — aparece, o usuário navega pra outro lugar, some. Na prática, ela é registrada e replicada em vários lugares que você não controla:
- Logs de acesso. Servidores web, proxies reversos, CDNs e até o load balancer costumam logar a URL completa de cada requisição, query string incluída, por padrão — muitas vezes por meses, em texto puro, em um sistema de logging que dezenas de pessoas têm acesso de leitura.
- Histórico do navegador. Fica salvo localmente, sincronizado entre dispositivos se o usuário usa sync de navegador, e sobrevive muito além da sessão de login.
- Header
Referer. Se a página que recebeu o token carregar qualquer recurso de terceiro (uma fonte, um script de analytics, um pixel de tracking) antes de você limpar a URL, o navegador pode enviar a URL completa — token incluído — comoRefererpara esse terceiro. - Ferramentas de observabilidade client-side. Sentry, LogRocket, Google Analytics e afins frequentemente capturam a URL atual como parte do contexto de erro ou de sessão automaticamente. Um SDK de terceiro mal configurado pode literalmente exfiltrar o token pra fora da sua infraestrutura sem que ninguém tenha escrito uma linha de código pra isso — só configurou o SDK do jeito default.
Nenhum desses é um ataque sofisticado. É o comportamento normal e documentado de navegador, proxy e ferramentas de observabilidade — o que faz esse vazamento ser sistemático, não uma exploração pontual.
2. O padrão correto: um código, não o token
A solução não é nova — é literalmente o motivo de o Authorization Code Grant existir como fluxo separado do Implicit Grant na especificação OAuth 2.0 (e é por isso que o Implicit Grant foi formalmente descontinuado no OAuth 2.1). A ideia:
- O provedor (Google, GitHub) redireciona de volta pra sua aplicação não com o token real, mas com um código de autorização — uma string opaca, de uso único, com TTL curto (tipicamente 30-60 segundos).
- O frontend recebe esse código na URL (
?code=xxx) — que ainda é uma URL, mas agora carrega algo que não vale nada sozinho e expira quase instantaneamente. - O frontend faz uma requisição
POST(nuncaGET, nunca na URL) pro seu próprio backend, trocando esse código pelo token real. - O backend valida o código (existe? já foi usado? expirou?), troca com o provedor OAuth pelos tokens reais, e devolve o resultado pro frontend como um cookie
httpOnly,secure,sameSite— nunca como JSON que o JavaScript do frontend consegue ler.
// auth.controller.ts (NestJS) — endpoint de callback do provedor
@Get('google/callback')
async googleCallback(@Query('code') providerCode: string, @Res() res: Response) {
const { accessToken } = await this.authService.exchangeWithGoogle(providerCode);
// Gera um código PRÓPRIO, opaco, de uso único e curta duração —
// nunca redireciona com o token real na URL.
const exchangeCode = await this.authService.issueShortLivedExchangeCode(accessToken);
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback?code=${exchangeCode}`);
}
// endpoint separado, chamado via POST pelo frontend
@Post('exchange')
async exchange(@Body('code') code: string, @Res() res: Response) {
const { jwt } = await this.authService.redeemExchangeCode(code); // uso único, TTL curto
res.cookie('session', jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
});
return res.json({ ok: true });
}
Repare no que muda: mesmo que esse exchangeCode da URL vaze em algum log, ele não serve pra nada depois de resgatado uma vez (ou depois de alguns segundos). O token de verdade nunca aparece em texto claro em nenhum lugar que não seja a memória do servidor e um cookie httpOnly — que, por definição, o JavaScript do navegador nem consegue ler, o que também mitiga roubo de token via XSS.
3. Duas camadas extras que costumam faltar
stateparameter: um valor aleatório gerado antes de redirecionar pro provedor, validado quando ele volta. Sem isso, um atacante pode iniciar o próprio fluxo OAuth e enganar a vítima pra completar o login dele — um CSRF clássico no fluxo de autenticação.- PKCE (Proof Key for Code Exchange): originalmente pensado pra apps mobile/SPA sem client secret seguro, hoje é recomendado até para clientes confidenciais. Um
code_verifiergerado no cliente, hasheado emcode_challengeenviado no início do fluxo, e ocode_verifieroriginal enviado na troca do código — isso garante que só quem iniciou o fluxo consegue completá-lo, mesmo que o código de autorização seja interceptado no meio do caminho.
Conclusão
O login "funciona" nos dois desenhos — com o token na URL ou com o padrão de exchange code. A diferença nunca aparece no happy path que você testa manualmente; ela aparece seis meses depois, num log de acesso de um proxy que ninguém lembrava que existia, ou num relatório de segurança perguntando por que tokens de sessão de usuários aparecem em texto puro num sistema de observabilidade de terceiro.
Trate a barra de endereço como um canal público, permanentemente registrado — porque é exatamente isso que ela é. Qualquer coisa que valha alguma coisa sozinha não deveria passar por ali, nem por um segundo.
Comentários
Carregando comentários...
Participe da conversa
Entre com sua conta para comentar neste artigo.