Pular para o conteúdo
< samuelsantana.dev />
Voltar para o BlogAs cinco etapas do login OAuth numa linha de diagnóstico, cada uma com seu erro típico; o fluxo para na etapa 3, OAUTH_STATE_MISMATCH, marcada com um carimbo vermelho "AQUI".

Erro ao inicializar o OAuth: como descobrir em que etapa o login quebrou

Samuel Santana
Publicado em 03 de outubro de 2026
OAuthSecurityNext.js

"Erro ao inicializar o OAuth", "Não foi possível entrar", "Login failed". A mensagem que chega ao usuário quase nunca diz onde o fluxo parou, e um login OAuth tem pelo menos cinco lugares onde parar. Cada um deixa um rastro diferente, e o rastro aparece antes de qualquer linha de código ser lida.

Este post é um mapa organizado pelo sintoma: onde olhar, como provar a causa, o que corrigir. Os exemplos vêm do login com Google e GitHub do samuelsantana.dev, com código aberto no vertex-api e no vertex-web. Um deles é uma correção minha: até 2 de outubro o meu login não mandava state nem PKCE.

Primeiro, descubra a etapa

No fluxo de authorization code, o login passa por cinco etapas, e cada uma falha de um jeito:

EtapaOnde olharComo a falha aparece
1. A aplicação monta a URL de autorizaçãoprimeira requisição do popup ou da abapágina de erro do provedor, ou erro no console
2. O provedor autentica e redireciona de voltapágina do provedor, ou ?error= no callbackredirect_uri_mismatch, invalid_client, access_denied
3. O callback confere o staterequisição ao callback e os cookies delastate divergente ou ausente
4. O backend troca o code por tokenslog do servidor (o navegador não vê)invalid_grant, invalid_client
5. A sessão é criada e a interface avisadacookie da sessão, window.openerlogin feito, interface sem saber

A ferramenta é a aba Network do DevTools, aberta no popup (ele é outra janela, com o próprio DevTools), com "Preserve log" marcado para sobreviver aos redirects.

Um detalhe que custa tempo: o HAR que o Chrome exporta por padrão é sanitizado e exclui Cookie, Set-Cookie e Authorization, justamente os headers que explicam um erro de state. Para esse caso, leia a aba Cookies ao vivo.

Etapa 1: a URL de autorização já sai errada

Comece lendo a URL que o navegador recebeu para ir ao provedor. No samuelsantana.dev, o popup abre uma rota da API, e a API responde com o redirect:

$ curl -sI https://api.samuelsantana.dev/auth/google
HTTP/1.1 302 Found
cross-origin-opener-policy: same-origin-allow-popups
Set-Cookie: oauth_state_google=<state>.<verifier>.<assinatura>; Max-Age=600; Path=/auth/google/callback; HttpOnly; Secure; SameSite=Lax
location: https://accounts.google.com/o/oauth2/v2/auth?code_challenge=YiKK...&code_challenge_method=S256&response_type=code&redirect_uri=https%3A%2F%2Fapi.samuelsantana.dev%2Fauth%2Fgoogle%2Fcallback&scope=email%20profile&state=kxzm...&client_id=<client-id>

Essa resposta responde metade das perguntas: client_id presente, redirect_uri absoluto e com https, state, e code_challenge com S256. Os dois headers de cima voltam nas etapas 3 e 5.

client_id=undefined e a variável que não estava no build

Se a URL é montada no navegador de um app Next.js, o valor vem de uma variável NEXT_PUBLIC_*, que é embutida no JavaScript no momento do next build. A documentação é explícita: depois do build, o app "não responde mais a mudanças nessas variáveis". Mudar o valor no painel da hospedagem sem um build novo não muda nada. E só a referência literal é substituída: process.env[nome] e const env = process.env; env.NEXT_PUBLIC_X ficam de fora.

O sintoma depende do que o código faz com o valor ausente. Uma URL com client_id=undefined termina na página de erro do Google. O meu código tem a variante silenciosa:

// LoginModal.tsx (vertex-web)
const API_URL = process.env.NEXT_PUBLIC_VERTEX_API_URL ?? "http://localhost:3333";

O fallback existe para desenvolvimento. O preço é que um build sem a variável não quebra: ele publica um popup que abre localhost:3333 na máquina do visitante, e o sintoma vira "não foi possível acessar o site", sem nenhuma palavra sobre OAuth. A favor desse desenho: o client_id nunca vai para o bundle do navegador, porque quem monta a URL do provedor é a API.

A biblioteca que falha ao inicializar

Se o código usa a biblioteca antiga do Google (platform.js, gapi.auth2), o erro de inicialização dela é idpiframe_initialization_failed, "falha ao inicializar um iframe necessário do Google". Ela está descontinuada desde 31 de março de 2023, e client IDs criados depois de 29 de julho de 2022 não podem usá-la. A saída é o Google Identity Services, em que faltar client_id ou scope gera erro no console e os erros fora do protocolo chegam pelo error_callback (popup_failed_to_open, popup_closed). No Auth.js, o equivalente é OAuthSignInError: o login por OAuth "não pôde ser iniciado".

Etapa 2: o provedor recusa

A RFC 6749 divide os erros desta etapa em dois grupos, e a divisão já é um diagnóstico. Se o problema é o redirect_uri ou o client_id, o provedor não deve redirecionar de volta: o usuário fica numa página de erro do provedor e o seu servidor nunca fica sabendo. Os outros erros voltam ao callback como ?error=...&state=.... Log de callback vazio aponta para o primeiro grupo.

redirect_uri_mismatch

A RFC 9700, o guia de boas práticas de segurança do OAuth publicado em janeiro de 2025, exige comparação exata de string entre o redirect_uri recebido e o cadastrado. A pergunta, então, não é "parece igual?", é "qual byte é diferente?": http contra https, barra no final, www, porta, prefixo de idioma no caminho.

O Google esconde a resposta na URL da própria página de erro, num parâmetro authError. É base64url de uma estrutura binária, sem formato documentado, mas legível:

# Valor de authError copiado da barra de endereço da página de erro do Google
node -e "console.log(Buffer.from(process.argv[1], 'base64url').toString('utf8'))" 'ChVyZWRpcmVjdF91cmlf...'

Num teste com o client_id do meu site e um redirect_uri de propósito errado, a saída trouxe o código redirect_uri_mismatch, a mensagem ao usuário, o link para a documentação do erro e o mais útil: o redirect_uri que o Google recebeu, https://example.com/callback. É esse valor que se compara, byte a byte, com o do Console. Se o código monta o redirect_uri a partir da requisição e há um proxy terminando TLS na frente, é aqui que aparece o http.

invalid_client, deleted_client e access_denied

Com um client_id que não existe, o authError decodificado traz invalid_client e "The OAuth client was not found". A lista oficial do Google tem um parente menos óbvio, deleted_client: clientes apagados à mão ou "automaticamente, no caso de clientes não usados". Um projeto de demonstração esquecido pode perder o login sem ninguém tocar nele.

access_denied é o usuário ou o servidor de autorização negando o pedido. Ele volta ao callback, e o callback precisa tratar error antes de procurar code. É a primeira coisa que o guard do vertex-api verifica, e um erro não mexe no cookie de state, porque uma resposta de erro não prova quem a enviou.

No Google, a recusa também vem do modo de teste: um app com status "Testing" fica limitado a até 100 usuários de teste listados. Nesse status, o refresh token expira em 7 dias, salvo se os escopos forem só nome, e-mail e perfil. Um app de login puro escapa; um app que pede o Google Drive perde o acesso offline depois de uma semana, sem erro nenhum no login.

Etapa 3: o callback recusa o state

Sem state, um atacante leva o navegador da vítima até o seu callback com o authorization code dele, e a vítima termina logada na conta do atacante. É o ataque que a RFC 6749 descreve. A RFC 9700 obriga o cliente a impedi-lo: se o provedor comprovadamente suporta PKCE, o cliente pode se apoiar nele; em OpenID Connect, o nonce cumpre o papel; fora disso, é obrigatório um token de uso único no state, vinculado ao navegador que começou o fluxo.

Eu não tinha nenhum dos dois. Até o vertex-api #57, em 2 de outubro, o callback aceitava qualquer code. Agora a API gera state e verifier, guarda os dois num cookie assinado e confere na volta:

// oauth-state.util.ts (vertex-api), simplificado
if (query.error) {
  return {}; // o provedor recusou; não há code para conferir
}

if (query.code) {
  const attempt = takeOAuthAttempt(request, reply, provider); // lê e apaga o cookie
  if (!attempt || !statesMatch(attempt.state, query.state)) {
    throw new OAuthStateMismatchException(); // antes do passport: o code nunca é trocado
  }
  return { codeVerifier: attempt.codeVerifier };
}

const attempt = createOAuthAttempt(); // 32 bytes aleatórios para state e verifier
rememberOAuthAttempt(reply, provider, attempt);
return { state: attempt.state, codeChallenge: codeChallengeFor(attempt.codeVerifier) };

A comparação usa timingSafeEqual, e o cookie é apagado na leitura, então uma tentativa vale para um callback só. O código completo justifica cada atributo do cookie. Os jeitos de o state não bater:

Expirou. O cookie vive 10 minutos (Max-Age=600).

Outra aba, ou dois cliques. Há um cookie por provedor. A segunda tentativa sobrescreve a primeira, e o callback da primeira compara o state dela com o da segunda. Daí a mensagem do site para esse erro: "O login expirou ou foi iniciado em outra aba. Tente entrar de novo."

O cookie não foi enviado. O grupo grande. O culpado está num atributo do Set-Cookie da etapa 1:

  • SameSite=Strict: a volta do provedor é uma navegação vinda de outro site, e cookies Strict só acompanham requisições originadas no mesmo site. Lax vai em navegação de topo com método seguro, como o GET do redirect. Por isso o vertex-api usa Lax.
  • response_mode=form_post: a volta é um POST, e Lax não vai em POST entre sites. Quando o navegador aplica Lax como padrão (o cookie não declarou SameSite), o MDN registra uma exceção: o POST ainda leva o cookie gravado há no máximo dois minutos. Quem consente rápido entra; quem demora, não. O Google oferece form_post no discovery; com ele, o cookie precisa de SameSite=None; Secure.
  • Path: o cookie do vertex-api tem Path=/auth/google/callback e, pelas regras do atributo, não vai para /en/auth/google/callback. Um middleware de idioma que redireciona o callback derruba o state. Em julho, o vertex-web chegou a tirar /auth do middleware do next-intl para um visitante em inglês não ir de /auth/google para /en/auth/google (fc59b9e).
  • Host: sem Domain, o cookie é host-only. Começar em exemplo.com e voltar em www.exemplo.com quebra.
  • Iframe: num iframe de outro site, o cookie é de terceiro. Firefox e Safari o isolam ou restringem por padrão. O Chrome não bloqueia por padrão, só em modo anônimo ou por escolha do usuário, e em outubro de 2025 o Google confirmou que mantém essa abordagem. Login em iframe funciona ou não conforme o navegador de quem clica.

A assinatura mudou. O cookie é assinado com COOKIE_SECRET. Trocar o segredo entre a ida e a volta dá o mesmo erro.

A prova é sempre a aba Cookies da requisição ao callback. Se o oauth_state_* não está lá, volte ao redirect da etapa 1, encontre o Set-Cookie e passe o mouse no ícone de informação: o Chrome mostra o motivo do bloqueio. No Auth.js, essa família toda aparece como InvalidCheck, "uma verificação de PKCE, state ou nonce não pôde ser feita", e a própria documentação aponta provedor mal configurado ou navegador bloqueando cookies.

Etapa 4: a troca do código falha

Esta etapa é de servidor para servidor. O navegador não vê nada; a resposta do endpoint de token está no seu log, ou em lugar nenhum.

invalid_grant e invalid_client

Pela RFC 6749, cabe aqui um code inválido, expirado, revogado, emitido para outro cliente ou que não corresponde ao redirect_uri da autorização. Na prática, quatro perguntas:

O code foi usado duas vezes? Ele é de uso único, com vida máxima recomendada de 10 minutos, e um segundo uso deve ser recusado. O GitHub responde bad_verification_code para code incorreto ou expirado, e o dele vale 10 minutos. Um jeito de gastar duas vezes sem perceber é trocar o code dentro de um useEffect: em desenvolvimento, o Strict Mode executa um ciclo extra de setup e cleanup em todo Effect, e no App Router ele vem ligado por padrão. A segunda chamada leva o mesmo code. Em produção, o ciclo extra não existe, e o bug é só de dev.

O redirect_uri da troca é o mesmo da autorização? A RFC exige valores idênticos.

O verifier do PKCE é o certo? Se o hash do code_verifier não bate com o code_challenge da ida, a resposta é invalid_grant. Acontece quando o verifier é gerado de novo na volta, ou ficou na memória de outro processo. O vertex-api o guarda no mesmo cookie assinado do state, então o callback sempre tem o verifier daquela tentativa, sem estado no servidor. O Google anuncia S256 no discovery, e o GitHub aceita PKCE desde 14 de julho de 2025, só com S256.

O estado da troca mora na memória de um processo só? Este é do meu código. A API não entrega o token ao navegador: entrega um código próprio, de uso único e 60 segundos, que o frontend troca de servidor para servidor (o motivo está no post sobre token na URL). No post de julho, ele morava num Map em memória; com mais de um processo atrás da URL, quem cria e quem gasta o código podem cair em memórias diferentes. Corrigi antes de ver o sintoma, no vertex-api #36: o código vive no Postgres, como SHA-256, e é consumido com um DELETE ... RETURNING, que também dá exatamente um vencedor a duas trocas simultâneas.

Já invalid_client na troca é o segredo, não o ID: "o client secret está incorreto", nas palavras do Google, ou incorrect_client_credentials no GitHub.

O ID token e o relógio

Quem valida o ID token do OpenID Connect por conta própria confere iss, aud, exp e nonce, e a especificação admite "uma pequena folga, normalmente não mais que alguns minutos" para diferença de relógio. Um servidor atrasado vê um token do futuro. Medi com o jose 6.2.3, com iat e nbf 90 segundos à frente:

await jwtVerify(token, publicKey, { issuer, audience, clockTolerance: 0 });
// ERR_JWT_CLAIM_VALIDATION_FAILED: "nbf" claim timestamp check failed

await jwtVerify(token, publicKey, { issuer, audience, clockTolerance: 120 });
// ok

Sem nbf, só com o iat à frente, a mesma chamada passou com clockTolerance: 0. A folga deve ser pequena e explícita; o conserto de verdade é sincronizar o relógio do servidor.

Etapa 5: o login aconteceu e a interface não sabe

Se a API grava o cookie da sessão no domínio dela e o frontend está em outro, o frontend nunca o vê. Esse foi o bug do post de julho; a escolha entre cookie de sessão e JWT está no post sobre JWT e sessões. O caso que merece espaço aqui é outro.

O popup que perde a janela de origem

Sintomas: window.opener é null no callback, o postMessage para a janela de origem não chega e, na janela de origem, popup.closed vira true com o popup ainda na tela. Código que espera o popup fechar para concluir "o usuário desistiu" conclui isso no primeiro segundo.

A causa é o Cross-Origin-Opener-Policy, e o primeiro suspeito costuma ser o provedor. Em 2 de outubro, com Playwright no Chromium, eu testei essa suspeita: um popup aberto direto no Google ou no GitHub manteve o opener: o Google manda só Cross-Origin-Opener-Policy-Report-Only, que relata e não aplica, e o GitHub não manda COOP. O mesmo popup aberto pela rota /auth/* da minha API perdeu o opener. O corte vinha do primeiro header da etapa 1, same-origin-allow-popups, que o Helmet do vertex-api aplica em toda resposta (o padrão do Helmet é same-origin).

O MDN explica. A página do blog não manda COOP, o que equivale a unsafe-none, e um documento unsafe-none sempre abre documentos com qualquer outro valor num novo grupo de contextos de navegação. same-origin-allow-popups preserva as referências de uma página aos popups que ela abre; não faz nada pela janela de origem de um documento que é o popup. O popup troca de grupo no primeiro redirect, antes de chegar ao provedor, e nada depois restaura a referência.

A saída foi não depender do opener. O callback e a janela de origem estão na mesma origem, e um BroadcastChannel é escopado por origem, não por referência entre janelas:

// No callback, dentro do popup
new BroadcastChannel("vertex-oauth").postMessage("oauth-success");
window.close();

// Na janela de origem, montado uma vez
const channel = new BroadcastChannel("vertex-oauth");
channel.onmessage = (event) => {
  if (event.data === "oauth-success") refreshSession();
};

Pela tabela do MDN, unsafe-none nas rotas /auth/* deveria manter o opener. Não testei, e não preciso: o canal funciona com ou sem ele. O raciocínio completo está num comentário do vertex-web, corrigido no #96.

Mix-up, o erro que não aparece

Um cliente que fala com mais de um provedor pode ser levado a tratar a resposta de um como se viesse do outro. A RFC 9700 torna a defesa obrigatória: de preferência o parâmetro iss da RFC 9207, que identifica o emissor na própria resposta; na falta dele, um redirect_uri distinto por provedor. O Google anuncia authorization_response_iss_parameter_supported: true no discovery, mas o vertex-api não lê o iss: usa um callback por provedor e um cookie de state preso ao Path de cada um.

Por que isso importa

Para quem clica, todas essas falhas são uma só: "erro ao inicializar o OAuth". Para quem mantém, são mais de uma dúzia de problemas, cada um com sua prova. Fundir todos numa mensagem genérica deixa o usuário sem saber o que tentar, e quem investiga começa do zero.

A regra que eu sigo agora: cada falha sai da API com um código legível por máquina, e a tradução acontece na borda. Quando o state não bate, a API redireciona o popup para /auth/callback?oauth_error=OAUTH_STATE_MISMATCH, o callback repassa o código pelo BroadcastChannel, e a janela de origem o traduz para a língua do visitante (vertex-web #99): "O login expirou ou foi iniciado em outra aba. Tente entrar de novo." A mensagem diz ao usuário o que fazer; o código me diz em que etapa procurar.

Antes de mexer em código por causa de um erro de OAuth, a primeira pergunta é em qual das cinco etapas ele parou. A resposta quase sempre está numa URL ou num Set-Cookie, e quase nunca no lugar onde a mensagem apareceu.

Referências

Comentários

Carregando comentários...