Logo

Adding Google Sign-In to a Supabase App (And What Actually Trips You Up)

Google OAuth looks simple on paper. It is simple — once you’ve done it once. The first time, there are just enough moving parts to make it confusing.

This is a breakdown of how it actually works, where the gotchas are, and how to handle the edge cases that the docs don’t always spell out clearly.

How the flow works

Before touching any code, it helps to understand what’s actually happening.

When a user clicks “Continue with Google”, three things happen in sequence:

  1. Your app sends the user to Google with a request
  2. Google authenticates them and redirects back to Supabase
  3. Supabase completes the session and redirects to your app

The key thing to understand is that Supabase sits in the middle. You don’t handle the Google redirect yourself — Supabase does. Your job is to tell Supabase where to send the user after it’s done.

Step 1: Google Cloud Console

Go to console.cloud.google.com and create or select a project.

Navigate to API’s & Services > Credentials > Create Credentials > OAuth Client ID:

The Supabase callback URL looks like this:

https://<your-project-id>.supabase.co/auth/v1/callback

You’ll get it from the Supabase dashboard in the next step. Add it here once you have it.

Save and copy your Client ID and Client Secret.

Step 2: Supabase Dashboard

Go to Authentication > Sign In / Up > Auth Providers, find Google, enable it, and paste in your Client ID and Client Secret.

Copy the Callback URL shown there — that’s what goes into the Google Console’s Authorized redirect URIs field above.

Then go to Authentication > URL Configuration and set:

This allowlist matters. If you try to redirect somewhere that isn’t in this list, Supabase will ignore it and fall back to the Site URL.

Step 3: The code

await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: `${window.location.origin}/dashboard`,
  },
})

Using window.location.origin means this works in both local development and production without any changes. In dev it resolves to http://localhost:3000. In prod it resolves to your domain.

That’s really all there is to triggering the flow. Supabase handles everything else.

The trigger problem

This is where most people get stuck.

When a new user signs up with Google, Supabase creates a row in auth.users. If you have a database trigger that creates a corresponding row in a profiles table, that trigger fires immediately — before the user has set a username or filled in any profile details.

If your profiles table has a NOT NULL constraint on username, the insert fails. The whole auth flow breaks. You get a Database error saving new user error in the URL and the user lands on your homepage confused.

The fix is two things:

First, make username nullable:

alter table public.profiles alter column username drop not null;

Second, update your trigger to handle both email/password and Google metadata formats:

create or replace function handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, username, display_name, avatar_url)
  values (
    new.id,
    new.raw_user_meta_data->>'username',
    coalesce(
      new.raw_user_meta_data->>'display_name',
      new.raw_user_meta_data->>'full_name'
    ),
    new.raw_user_meta_data->>'avatar_url'
  );
  return new;
end;
$$ language plpgsql security definer;

Email/password users have display_name in metadata. Google users have full_name. The coalesce handles both. username is null for Google users and gets filled in during onboarding.

Handling users who skip registration and go straight to login

If you have separate login and registration pages, a Google user might click “Continue with Google” on the login page even though they’ve never signed up. Supabase creates the account — but now you have a user with no username.

The cleanest way to handle this is a check on your dashboard or protected page. After the session loads, fetch the profile and check if username is set:

const { data: profile } = await supabase
  .from('profiles')
  .select('username')
  .eq('id', user.id)
  .single();

if (!profile?.username) {
  window.location.href = '/onboarding';
  return;
}

This way, it doesn’t matter how they got in or where they were redirected — anyone without a complete profile gets bounced to onboarding. One check covers every case.

The metadata trap

One more thing worth knowing: user.user_metadata reflects what’s stored in auth.users, not your profiles table. When a Google user completes onboarding and you update profiles.username, their user_metadata.username is still null unless you explicitly call supabase.auth.updateUser().

If you’re displaying profile data in your UI, fetch it from profiles directly rather than trusting user_metadata. It keeps things consistent across both auth methods and avoids subtle display bugs.

The short version

Once it’s wired up it’s solid. The first time is just more moving parts than it looks.