Login com Google no Astro + Supabase: guia completo (com as armadilhas que ninguém conta)

Como implementar entrar/cadastrar com um clique via Google OAuth num app Astro com Supabase Auth — incluindo o bug do e-mail apontando pra localhost, o pepino do redirect caindo no Site URL, SMTP customizado com Resend e vinculação de contas.

Cadastro com email e senha funciona, mas a fricção mata conversão. Quem chega no checkout precisa inventar uma senha, confirmar por email, voltar pro site… e uma fatia considerável desiste no meio do caminho. “Entrar com Google” num clique elimina tudo isso — e com o Supabase Auth integrar isso é mais simples do que parece.

O problema é que “mais simples do que parece” esconde um punhado de armadilhas que me custaram algumas horas na vida real. Neste artigo eu documento o caminho completo, do zero ao botão funcionando em produção, usando como exemplo a implementação deste próprio site (Astro + Supabase, deploy na Cloudflare). Incluindo os bugs que eu encarei e como os resolvi — porque é exatamente essa a parte que os tutoriais costumam pular.

O que você vai ter no final: um botão “Continuar com Google” que cadastra ou loga o usuário num clique, reutiliza o mesmo callback do fluxo de confirmação por email, vincula automaticamente contas duplicadas e ainda funciona no seu ambiente de dev.


Visão geral do fluxo

Antes de pôr a mão na massa, vale entender a coreografia completa. O OAuth não é uma chamada direta do seu site pro Google — o Supabase fica no meio como intermediário de confiança:

[ Usuário no /entrar ] --1--> [ Supabase /auth/v1/authorize ]
        |                              |
        |                       2. redireciona
        v                              v
[ Google — consentimento ] ----3----> [ Supabase /auth/v1/callback ]
                                       |
                                4. troca o code
                                5. grava sessão (cookie PKCE)
                                       |
                                       v
                        [ Seu /entrar/callback?code=... ]
                                 |
                          6. exchangeCodeForSession(code)
                          7. redireciona pra `next`

Os pontos que confundem:

  • A URI de callback do Google aponta pro Supabase, não pro seu site. É https://<seu-projeto>.supabase.co/auth/v1/callback. Todo mundo erra isso na primeira vez (eu errei).
  • O Supabase recebe o code do Google, ele mesmo troca por um token, cria a sessão, e só depois redireciona o navegador pro seu callback (/entrar/callback) carregando um outro code (o do fluxo PKCE).
  • O seu callback roda no servidor (SSR), troca esse segundo code por sessão, grava o cookie e manda o usuário pra página final.

Entendeu por que existem dois codes e dois callbacks? Um par é Google↔Supabase, o outro é Supabase↔seu app. Não tente encurtar isso.


Pré-requisitos

  • Um app Astro (ou qualquer framework SSR) com Supabase Auth já funcional por email/senha.
  • Acesso ao Google Cloud Console e ao dashboard do Supabase.
  • Um domínio próprio com DNS sob seu controle (aqui uso valmor.net.br como exemplo — troque pelo seu).
  • O cliente Supabase no browser (@supabase/ssr). Se você já tem login por senha funcionando, já tem isso.

Vou partir do princípio de que sua página de login é /entrar e seu callback é /entrar/callback — ajuste as rotas à sua realidade.


Comece pelo https://console.cloud.google.com. Crie (ou selecione) um projeto. Depois, no menu esquerdo, vá em Google Auth Platform (a interface nova; na clássica é APIs & Services → OAuth consent screen).

1.1 Tela de consentimento (Branding)

Preencha o que o usuário vai ver quando o Google pedir autorização:

CampoValor
App nameo nome do seu site (ex.: valmor.net.br)
User support emailum email que você lê
Application home pagehttps://<seu-dominio>
Application privacy policyhttps://<seu-dominio>/<sua-rota-de-privacidade>
Application terms of servicehttps://<seu-dominio>/<sua-rota-de-termos>
Authorized domains<seu-dominio> (só o host, sem https://)

Não suba logo agora. A própria tela avisa: fazer upload de logo dispara exigência de verificação pelo Google. Sem logo, e usando só scopes não-sensíveis (openid, email, profile), você fica isento de verificação. O aviso vermelho “Your branding needs to be verified” é só sobre o logo — pode ignorar enquanto não subir um.

1.2 Crie o OAuth Client

Vá em Clients → Create client → Web application:

  • Name: o que quiser (ex.: webapp-supabase). É só pra você se localizar.
  • Authorized redirect URIs: cole exatamente
    https://<seu-projeto>.supabase.co/auth/v1/callback
    Substitua <seu-projeto> pelo ref do seu projeto Supabase (encontra em Project Settings → API → Project URL). É a URI do Supabase, não a do seu site.
  • Authorized JavaScript origins: pode deixar vazio. O fluxo via Supabase é redirect de página inteira, não XHR.

Clique em Create. Vão aparecer duas coisas que você copia uma única vez:

  • Client ID — termina em .apps.googleusercontent.com.
  • Client Secret — começa com GOCSPX-.

⚠️ Segurança: o Google só mostra o Client Secret na criação. Se perder, é preciso gerar outro. Nunca commite isso no git. Eu colo direto no dashboard do Supabase, sem passar por arquivo nenhum.

1.3 Publique o app

Em Audience (ou Publishing status), certifique-se de que está “In production”. Se ficar em “Testing”, só 100 usuários de teste que você cadastrar manualmente conseguem autorizar — péssimo pra produção. Para scopes não-sensíveis, publicar é instantâneo e não exige revisão do Google.


Passo 2 — Supabase: habilitar o provider Google

No dashboard do Supabase, em Authentication → Sign In / Providers → Google:

  1. Ative o toggle.
  2. Cole o Client ID e o Client Secret do passo 1.2.
  3. Save.

Pronto, o Google já está conectado. Agora vem a parte que mais quebra gente: as URLs de redirecionamento.

2.1 Site URL e Redirect URLs (o coração do problema)

Vá em Authentication → URL Configuration. Há dois campos:

  • Site URL — a URL fallback. O Supabase manda pra aqui quando um redirect pedido não bate com a allowlist. Não suporta wildcard.
  • Redirect URLs — a allowlist de URLs permitidas. Suporta wildcard (https://*.domain.com).

A regra de ouro do Supabase Auth:

Se a URL que o código pede pra redirecionar (redirectTo) não casar com uma entrada da allowlist, o Supabase descarta tudo (incluindo query params customizados) e manda pro Site URL com ?code=... limpo.

Isso é uma medida de segurança contra open-redirect, mas é a origem de ~80% dos bugs de “o login foi parar no lugar errado”.

No meu caso, a configuração final ficou:

Site URL:

https://valmor.net.br/entrar/callback

Redirect URLs:

https://valmor.net.br/entrar/callback
http://localhost:4321/entrar/callback
http://localhost:4322/entrar/callback
https://preview.<seu-projeto>.pages.dev/entrar/callback
https://valmor.net.br/entrar/redefinir-senha

Inclua produção, preview e dev local. Mais pra frente explico por que não dá pra incluir IPs privados tipo http://192.168.0.10:4321 aqui.


Passo 3 — O código

A boa notícia: se você já tem um callback de confirmação de email funcionando, não precisa criar outro. O OAuth do Supabase desemboca no mesmo callback PKCE.

3.1 O botão (em /entrar/index.astro)

No frontmatter da página, leio o destino (redirect) da query string, sanitizando pra evitar open-redirect:

---
function safeRedirect(value: string | null): string {
  const fallback = "/conta/perfil";
  if (!value) return fallback;
  // Só caminhos relativos; bloqueia "//evil.com" e "https://..."
  if (!value.startsWith("/") || value.startsWith("//")) return fallback;
  return value;
}

const redirectTo = safeRedirect(Astro.url.searchParams.get("redirect"));
---

No template, o botão com o “G” multicolor do Google e um divisor “ou” acima dos formulários de email/senha:

<div class="auth-social">
  <button type="button" id="google-btn" class="btn-google" data-redirect={redirectTo}>
    <span class="btn-google__icon" aria-hidden="true">
      <!-- o SVG do "G" multicolor do Google vai aqui -->
    </span>
    Continuar com Google
  </button>
  <div class="auth-divider" aria-hidden="true"><span>ou</span></div>
</div>

No <script>, o handler de clique. Repare em dois detalhes importantes: eu escondo o next num cookie (e não na query string do redirectTo — já já explico por quê), e o redirectTo vai sem nenhum ?param=:

const googleBtn = document.getElementById("google-btn") as HTMLButtonElement | null;
if (googleBtn) {
  googleBtn.addEventListener("click", async () => {
    if (!supabase) return;

    const rawRedirect = googleBtn.dataset.redirect || "";
    const next = rawRedirect.startsWith("/") && !rawRedirect.startsWith("//")
      ? rawRedirect
      : "/conta/perfil";

    // Stash do destino num cookie de 5 minutos. O callback vai ler depois.
    const secure = window.location.protocol === "https:" ? "; Secure" : "";
    document.cookie =
      `auth-next=${encodeURIComponent(next)}; path=/; max-age=300; SameSite=Lax${secure}`;

    await supabase.auth.signInWithOAuth({
      provider: "google",
      options: {
        redirectTo: `${window.location.origin}/entrar/callback`,
        queryParams: { prompt: "consent" },
      },
    });
  });
}

O prompt: "consent" força o Google a mostrar a tela de consentimento sempre (em vez de reusar uma autorização anterior silenciosa). Útil pra garantir que o usuário passa pela tela do Google pelo menos uma vez.

3.2 O callback (em /entrar/callback.astro)

O callback roda no servidor. Ele troca o code PKCE por sessão, grava o cookie de auth, e redireciona pro destino final — que ele lê do nosso cookie auth-next (com fallback pra URL, que é o que o fluxo de confirmação por email ainda usa):

---
import { createSupabaseClient } from "../../lib/supabase";

function safeNext(value: string | null): string {
  const fallback = "/conta/perfil";
  if (!value) return fallback;
  if (!value.startsWith("/") || value.startsWith("//")) return fallback;
  return value;
}

const code = Astro.url.searchParams.get("code");
const errorMessage = Astro.url.searchParams.get("error_description");

// Preferencial: cookie stash do OAuth. Fallback: query string (email flow).
const cookieNext = safeNext(Astro.cookies.get("auth-next")?.value ?? null);
const urlNext = safeNext(Astro.url.searchParams.get("next"));
const next = Astro.cookies.has("auth-next") ? cookieNext : urlNext;

let error: string | null = errorMessage || null;

if (code && !error) {
  const supabase = createSupabaseClient({
    request: Astro.request,
    cookies: Astro.cookies,
    locals: Astro.locals as any,
  });

  if (!supabase) {
    error = "Service unavailable";
  } else {
    const { error: exchangeError } =
      await supabase.auth.exchangeCodeForSession(code);

    if (exchangeError) {
      error = exchangeError.message;
    } else {
      Astro.cookies.delete("auth-next", { path: "/" });
      return Astro.redirect(next);
    }
  }
}
---

Pronto. O exchangeCodeForSession faz toda a mágica de PKCE — recebe o code, devolve a sessão, grava os cookies via @supabase/ssr. Em sucesso, redirecionamos pra next.


As armadilhas que eu encarei (e como resolver)

Aqui é onde o tutorial fica útil de verdade. Cada uma dessas me mordeu na prática.

Sintoma: usuário se cadastra, recebe o email de confirmação, clica… e o link aponta pra http://localhost:3000, não pro seu domínio.

Causa: o Site URL no Supabase ainda era o default de projeto novo (http://localhost:3000). Quando o emailRedirectTo pedido pelo código não casa com a allowlist, o Supabase cai pro Site URL.

Solução: ir em Authentication → URL Configuration e configurar conforme a seção 2.1 acima. Esse único passo resolve ao mesmo tempo o bug do email, o OAuth e qualquer outro redirect.

Armadilha 2 — O remetente do email é “Supabase Auth”

Sintoma: usuário reclama que não associou o email de confirmação ao seu site, porque veio de um remetente genérico “Supabase Auth”.

Causa: o Supabase, no plano Free, envia emails pelo SMTP interno deles, com remetente fixo. O assunto você edita em Authentication → Emails → Templates. O remetente (From), não.

Solução: configurar SMTP customizado em Authentication → Emails → SMTP Settings. Usei o Resend (free tier: 3.000 emails/mês, 100/dia):

  1. No Resend, adicione seu domínio e adicione os registros DNS (DKIM, SPF) que ele te der. Aguarde ficar Verified.

  2. Crie uma API key (começa com re_).

  3. No Supabase, preencha:

    CampoValor
    Sender emailnoreply@<seu-dominio>
    Sender name<seu-dominio>
    Hostsmtp.resend.com
    Port465
    Usernameresend
    Passwordsua API key re_...
  4. Em Emails → Templates, cole o HTML do seu template no campo Message body (Source) de cada fluxo (Confirm signup, Reset password, Change email) e troque o Subject pra algo como Confirme seu email · <seu-dominio>.

Bônus: com DKIM assinando pelo seu domínio, deliverability melhora e os emails param menos no spam.

Armadilha 3 — 401: invalid_client na tela do Google

Sintoma: o usuário clica em “Continuar com Google”, o Google abre e mostra: “The OAuth client was not found. Erro 401: invalid_client”.

Causa: o Google não reconhece o Client ID que o Supabase enviou. No meu caso, o OAuth Client estava marcado com o aviso “This OAuth client will be deleted because it has not been used for over 6 months” — o Google limpou clientes ociosos, e o ID deixou de existir. Também pode ser simplesmente erro de digitação ao colar o ID no Supabase.

Solução: confira se o Client ID no Supabase bate exatamente com o do Google (incluindo o sufixo .apps.googleusercontent.com, sem espaços). Se o client foi deletado ou está marcado pra deleção, crie um OAuth Client novo no Google Cloud e reconfigure no Supabase.

Armadilha 4 — Depois do Google, o redirect cai no Site URL mesmo com tudo certo

Sintoma: login com Google funciona (o ?code= aparece), mas o navegador volta em https://<seu-dominio>/entrar/callback?code=... em vez de voltar pra http://localhost:4321/... onde você estava testando. E o ?next=... some.

Diagnóstico: repare que o next sumiu. Essa é a assinatura do fallback do Site URL — quando o Supabase aceita o redirect pedido, ele preserva seus query params e só acrescenta &code=. Quando ele rejeita, descarta tudo e manda pro Site URL com ?code= limpo.

Causa raiz: eu estava passando o destino dentro da própria URL de redirect:

// ❌ NÃO faça isso
redirectTo: `${origin}/entrar/callback?next=${encodeURIComponent(next)}`

O Supabase recusa casar um redirect com query string customizada contra a allowlist (que tem /entrar/callback puro). Resultado: fallback sempre.

Solução: tire o next da URL de redirect e transporte ele por fora — num cookie de vida curta, setado antes do signInWithOAuth e lido no callback:

// ✅ redirectTo limpo, next vai num cookie
document.cookie =
  `auth-next=${encodeURIComponent(next)}; path=/; max-age=300; SameSite=Lax${secure}`;

await supabase.auth.signInWithOAuth({
  provider: "google",
  options: { redirectTo: `${origin}/entrar/callback` },  // sem query!
});

No callback, leia o cookie (com fallback pra query string, que o fluxo de email ainda usa). É exatamente o código da seção 3.2.

O cookie sobrevive porque é trafegado no request de volta (navegação top-level GET, permitida por SameSite=Lax), e não depende do Supabase preservar nada.

Armadilha 5 — “Criei conta com email/senha, agora não consigo entrar com o Google do mesmo email”

Sintoma: usuário cadastrou com [email protected] + senha. Depois tenta “Entrar com Google” com o mesmo email. Recebe erro de conta conflitante.

Causa: por padrão, o Supabase recusa OAuth quando o email já existe com outro provider (medida de segurança contra account takeover via email não-verificado).

Solução: se você confia que o email foi verificado (e no fluxo de signup por senha você exige confirmação por email, como deveria), ative a vinculação automática em Authentication → Sign In / Providers → Email → Enable automatic linking. Aí o Google vincula à conta existente e o usuário entra direto, sem duplicar contas.


Bônus: testando no dev por rede privada (ZeroTier, IP local)

Eu acesso meu dev de outros dispositivos pela rede do ZeroTier, num IP tipo http://172.29.73.113:4321. Tentei adicionar essa URL na allowlist do Supabase — ele aceita cadastrar, mas rejeita em runtime: só http://localhost (e 127.0.0.1) são isentos da exigência de HTTPS. Qualquer outro host, IP incluso, precisa ser https://.

Isso me deixou duas opções pra OAuth no dev fora da minha máquina:

  1. Testar OAuth só via http://localhost:4321 — funciona, é o suficiente pra maioria dos casos.
  2. Cloudflare Tunnel (cloudflared) — expõe o localhost:4321 num domínio público tipo https://dev.<seu-dominio> com HTTPS do Cloudflare, sem abrir portas. Aí adiciona https://dev.<seu-dominio>/entrar/callback na allowlist e OAuth funciona de qualquer dispositivo da rede ZeroTier.

Pra debugar o resto do app (que não envolve redirect do Supabase), o IP do ZeroTier continua valendo normalmente — só o OAuth mesmo que exige HTTPS ou localhost.


Conclusão

O login com Google é uma daquelas features que parecem trivial até você implementar. A mecânica básica (um botão + signInWithOAuth + um callback PKCE) é pouco código — no meu caso, um arquivo modificado pra adicionar o botão, e o callback de email reutilizado sem mudança estrutural.

O tempo real vai nas configurações externas e nos bugs sutis:

  • Site URL e allowlist configurados direito (resolve email, OAuth e redirect de uma vez).
  • SMTP customizado pra o remetente não ser “Supabase Auth”.
  • Client ID/Secret válidos e não prestes a serem deletados por inatividade.
  • Sem query string no redirectTo — transporte estado por cookie, não por URL.
  • Vinculação automática ligada se você quer fundir contas de email/senha com Google do mesmo endereço.

Feito isso, você entrega uma experiência de cadastro/login que reduz friction sem abrir mão de segurança. O usuário clica uma vez no Google, autoriza, e já está dentro — sem inventar senha, sem esperar email, sem saltar de aba.

Se você também usa ZeroTier pra acessar seu dev de outros dispositivos, vale o Cloudflare Tunnel só pro OAuth — pro resto, a rede privada segue perfeita.


Implementação de referência deste artigo é a mesma que roda neste site. Dúvidas? Me chame.