Skip to content
< samuelsantana.dev />
Back to the Blog

Por que meu login OAuth funcionava em dev e travava em produção

Samuel Santana
Published on July 07, 2026(Edited on July 08, 2026)
ReactPostgreSQLNestJSNext.jsNodeJSTailwindAmazon Web Services

Durante semanas o login com Google e GitHub do samuelsantana.dev funcionou perfeitamente — no meu ambiente local. Em produção, o popup abria, o usuário autorizava, e depois disso nada acontecia. Sem erro no console, sem 500, sem nada óbvio. Só um popup fechado e uma aplicação que continuava achando que ninguém tinha logado.

Esse tipo de bug — funciona local, quebra em produção, sem stack trace — costuma significar uma coisa: uma suposição que era verdadeira por acidente, não por design.

A suposição escondida

O frontend (vertex-web, na Vercel) e a API (vertex-api, no Render) são duas aplicações separadas, em domínios diferentes. O fluxo de OAuth, na primeira versão, era o mais óbvio possível: o popup ia direto pro Google, o Google devolvia pro callback da API, e a API — depois de validar o usuário e gerar o JWT — setava um cookie HttpOnly na própria resposta e fechava o popup.

Fazia sentido. E funcionava, em dev.

O problema é por que funcionava: localhost:3000 (frontend) e localhost:3333 (API) têm o mesmo hostname. Cookies não são escopados por porta — só por domínio. Então, localmente, o cookie que a API setava era, sem eu perceber, "visível" pro frontend só porque os dois compartilhavam localhost. Em produção, vertex-web-zeta.vercel.app e vertex-api-xxxx.onrender.com não têm absolutamente nada em comum. O cookie que a API setava ficava preso no domínio dela — o Next.js nunca teria como enxergá-lo, não importa quanto tempo eu ficasse fazendo polling.

Não era um bug. Era uma arquitetura que só fazia sentido por uma coincidência do ambiente de desenvolvimento.

O caminho errado (e por que ele é tentador)

A correção óbvia parece ser: fazer a API redirecionar pro frontend depois do callback, passando o token na URL (?token=...), e deixar o frontend setar o cookie ele mesmo, no domínio certo.

Funciona. E é uma péssima ideia.

Um JWT numa query string vaza para todo lugar que loga URLs por padrão: histórico do navegador, logs de acesso de proxies e CDNs, o header Referer de qualquer link clicado a partir daquela página. É um token de sessão válido, sentado em texto puro em lugares que eu não controlo.

O padrão que ficou: código de troca, não token

A solução final separa duas coisas que a primeira tentativa misturava: provar que o login aconteceu e entregar a credencial real.

  1. O popup vai direto pro /auth/google da API (URL absoluta).
  2. Depois que o Google confirma, a API não gera o JWT ali. Ela gera um código aleatório, de curtíssima duração e uso único — nunca o token de verdade — e redireciona o popup pro frontend com esse código na URL.
  3. A página de callback do frontend faz duas coisas, nessa ordem: primeiro remove o código da URL com history.replaceState — antes mesmo de qualquer requisição sair — e só depois troca esse código pelo token de verdade, numa chamada servidor-a-servidor (POST /auth/exchange). O cookie é setado ali, no domínio do frontend, por uma Server Action.
// vertex-api — o código nunca é o token, e nunca sobrevive além de 60s
createOAuthExchangeCode(payload: JwtPayload): string {
  const code = randomBytes(32).toString("hex");

  this.oauthExchangeCodes.set(code, {
    payload,
    expiresAt: Date.now() + OAUTH_EXCHANGE_CODE_TTL_MS, // 60s
  });

  return code;
}

A parte que mais gosto dessa implementação é o exchangeOAuthCode: o código é apagado do mapa antes de checar se ele é válido, não depois.

async exchangeOAuthCode(code: string): Promise<string> {
  const entry = this.oauthExchangeCodes.get(code);

  // Apagado incondicionalmente — é uso único independente do resultado
  this.oauthExchangeCodes.delete(code);

  if (!entry || entry.expiresAt < Date.now()) {
    throw new UnauthorizedException("Invalid or expired exchange code");
  }

  return this.generateAccessToken(entry.payload);
}

Isso não é um detalhe cosmético. Se o delete acontecesse só no caminho de sucesso, duas requisições simultâneas com o mesmo código (um replay, ou só um retry de rede mal comportado) poderiam ambas encontrar a entrada ainda válida numa condição de corrida. Apagando sempre, na entrada da função, o código morre no primeiro uso — válido ou inválido — e a janela de replay fecha.

Mesmo que alguém capture esse código de algum log ou do histórico do navegador, ele expira em 60 segundos e só pode ser trocado uma vez. Na pior hipótese, ele já foi usado pelo dono legítimo antes de qualquer outra pessoa conseguir fazer algo com ele.

A pegadinha que eu não esperava

Depois de resolver o problema do domínio, faltava avisar a janela original (a aba onde o usuário estava lendo o blog) que o login tinha terminado, pra ela reagir e mostrar o usuário logado. A forma óbvia é o popup chamar window.opener.postMessage(...) antes de fechar.

Isso simplesmente não funcionava, e por um bom tempo eu não entendi por quê. A resposta: as próprias páginas de OAuth do Google e do GitHub enviam um header Cross-Origin-Opener-Policy estrito, que existe justamente pra isolar essas páginas de terceiros — e um efeito colateral dele é cortar a referência window.opener permanentemente, de forma irreversível, assim que o popup navega pra lá. Não importa que a navegação seguinte seja de volta pro meu próprio domínio: a referência já foi cortada no meio do caminho.

Passei por duas soluções intermediárias antes de aceitar isso — uma tentativa de detectar o fechamento do popup via polling, depois uma tentativa de fazer polling numa rota de sessão — as duas funcionavam "quase sempre", o que em autenticação é o mesmo que não funcionar. A solução final foi trocar a comunicação direta entre janelas por um BroadcastChannel: um canal com escopo de origem, que sobrevive exatamente ao tipo de isolamento que o COOP impõe, porque não depende de uma referência entre as duas janelas — só do fato de que ambas estão na mesma origem.

Por que isso importa

Nenhuma dessas decisões aparece num tutorial de "adicione login com Google em 5 minutos". Elas só surgem quando duas aplicações reais, em domínios reais, com headers de segurança reais que você não controla, precisam conversar — e é exatamente aí que a diferença entre "funciona no meu ambiente" e "funciona em produção" se paga (ou cobra a conta).

O código completo — o serviço de troca de código, a página de callback, o uso do BroadcastChannel e os testes que cobrem o fluxo — está aberto nos repositórios do vertex-api e vertex-web.

Comments

Loading comments...