Pular para o conteúdo
< samuelsantana.dev />
Voltar para o Blog

O Erro Silencioso do OAuth: Por Que o Token Nunca Deveria Passar pela URL

Samuel Santana
Publicado em 25 de julho de 2026(Editado em 27 de julho de 2026)
OAuthSecurityNext.js

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 — como Referer para 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:

  1. 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).
  2. 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.
  3. O frontend faz uma requisição POST (nunca GET, nunca na URL) pro seu próprio backend, trocando esse código pelo token real.
  4. 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

  • state parameter: 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_verifier gerado no cliente, hasheado em code_challenge enviado no início do fluxo, e o code_verifier original 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.