Methodology. This tutorial synthesizes the Supabase Auth and provider documentation as of May 2026. Code patterns shown here mirror the canonical Supabase Next.js examples. Where security guidance comes from outside Supabase’s docs — for example, on unverified emails — that’s called out inline.

OAuth is table stakes in 2026. Users expect “Sign in with Google,” developers expect GitHub, B2B buyers expect Microsoft. Wiring it up is straightforward — Supabase does most of the heavy lifting — but the steps that fail silently are the ones every founder hits: a redirect URI typo, a missing App Router callback, an unverified email trusted as identity. This tutorial walks the eight steps from zero to a working production flow, plus the gotchas you only learn the hard way.

Prerequisite: a Next.js 14 (App Router) project already wired to Supabase with email sign-in working. If you’re not there, the magic link tutorial covers the email path first — OAuth piggybacks on the same callback infrastructure.

1 Pick which providers to support

The instinct is to add every provider on day one — Google, GitHub, Apple, Microsoft, Twitter, Facebook, LinkedIn, Discord. Don’t. Each one multiplies maintenance and makes the sign-in screen look indecisive.

Pick by audience:

  • Google — B2C. If your users are consumers or prosumers, Google is the highest-conversion OAuth option. Default for almost every non-developer SaaS.
  • GitHub — developer tools. For dev-focused products, GitHub is what your audience expects, with useful scopes (repo metadata) for later.
  • Microsoft — B2B enterprise. For companies on Microsoft 365. Also the path to Microsoft Entra (formerly Azure AD) SSO when you reach enterprise contracts.

For a B2C SaaS: Google + email. For a dev tool: Google + GitHub. For B2B: Google + Microsoft. Two providers is enough; three is the practical ceiling.

2 Register OAuth apps with each provider

Each provider has a different console, but the workflow is the same: create an OAuth app, name it, set the homepage, set the callback URL, capture the client ID + secret.

Google — Google Cloud Console

Go to console.cloud.google.com, create or select a project, then navigate to APIs & Services → Credentials → Create Credentials → OAuth client ID. Choose “Web application,” then add your callback URL. The Supabase callback URL is the one Google redirects back to:

Authorized redirect URIs:
  https://<your-project-ref>.supabase.co/auth/v1/callback
  http://localhost:54321/auth/v1/callback   (only if using local Supabase)

Note: this is the Supabase callback URL, not your app’s. Supabase intercepts the response, exchanges the provider code for a Supabase session, then redirects to your app’s callback URL (configured separately in Step 3). Mixing these is the most common Step 2 mistake.

Also fill in the OAuth consent screen: app name, support email, scopes (email + profile for sign-in only), publishing status. While in Testing mode, only allowlisted users can sign in — switch to Production before launch.

GitHub — Developer Settings

Go to github.com/settings/developers → OAuth Apps → New OAuth App. Set:

  • Application name: Your SaaS
  • Homepage URL: https://www.your-domain.com
  • Authorization callback URL: https://<your-project-ref>.supabase.co/auth/v1/callback

GitHub allows only one callback URL per OAuth app. To support both production and localhost, register a separate “Your SaaS (Dev)” OAuth app for the localhost callback. Click Generate a new client secret and capture both the client ID and secret — the secret is shown once.

3 Configure redirect URIs

There are two layers of redirect URIs to keep straight, and confusing them is responsible for roughly half of all OAuth bugs:

  • Provider → Supabase callback. The URL Google or GitHub redirects to after the user approves. This must match exactly what Supabase expects: https://<ref>.supabase.co/auth/v1/callback. Configured in Step 2.
  • Supabase → your app callback. The URL Supabase redirects to after exchanging the provider code for a Supabase session. This is configured in the Supabase dashboard under Authentication → URL Configuration → Redirect URLs.

In Supabase, set the Site URL to your production domain (no trailing slash) and add both production and localhost callback URLs to the Redirect URLs allowlist:

Site URL:       https://www.your-domain.com

Redirect URLs:
  http://localhost:3000/auth/callback
  https://www.your-domain.com/auth/callback

If your callback URL isn’t on the allowlist, Supabase silently redirects to the Site URL with no session — manifesting as a sign-in that “works” but leaves the user logged out. Check this list whenever OAuth misbehaves.

4 Add provider credentials to Supabase Auth

In Supabase, go to Authentication → Providers. Toggle Google and GitHub on, paste in the client ID and secret from Step 2.

Provider-specific options worth a glance:

  • Google. Leave “Skip nonce check” off — it weakens OIDC nonce protection.
  • GitHub. No extra options; just paste credentials.
  • Microsoft. Choose “common” (multi-tenant) for B2B sign-in. A specific tenant ID is only for single-org SSO.

Save. Supabase now has everything it needs to complete the OAuth handshake server-side.

5 Add the OAuth button to your sign-in page

The browser client exposes signInWithOAuth, which constructs the provider URL, redirects the user, and after the round-trip lands them at your callback. The canonical pattern from the Supabase docs adapted for App Router:

// src/app/auth/login/page.tsx
'use client';
import { createClient } from '@/lib/supabase/client';

export default function LoginPage() {
  const supabase = createClient();

  async function signInWith(provider: 'google' | 'github') {
    const { error } = await supabase.auth.signInWithOAuth({
      provider,
      options: {
        redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
        queryParams: provider === 'google'
          ? { access_type: 'offline', prompt: 'consent' }
          : undefined
      }
    });
    if (error) console.error(error);
  }

  return (
    <div className="auth-card">
      <h1>Sign in</h1>
      <button onClick={() => signInWith('google')}>
        Continue with Google
      </button>
      <button onClick={() => signInWith('github')}>
        Continue with GitHub
      </button>
    </div>
  );
}

Two details:

  • redirectTo uses window.location.origin so the same code works locally and in production. Both origins must be on the allowlist from Step 3.
  • Google’s access_type: 'offline' + prompt: 'consent' ensures a refresh token is issued. Required only if you’ll later call Google APIs on the user’s behalf.

The ?next=/dashboard is a convention — the callback handler reads it and bounces the user onward, so a single callback works for every sign-in surface.

6 Handle the callback redirect

This is the step most often missing in App Router projects, because older Supabase tutorials assume Pages Router. After the user approves, the browser lands at /auth/callback?code=...&next=/dashboard. That route must exist, exchange the code for a session, set the cookie, and redirect onward.

// src/app/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';

export async function GET(req: Request) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code');
  const next = url.searchParams.get('next') ?? '/dashboard';

  if (!code) {
    return NextResponse.redirect(
      new URL('/auth/login?error=missing_code', req.url)
    );
  }

  const cookieStore = cookies();
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get: (name) => cookieStore.get(name)?.value,
        set: (name, value, options) =>
          cookieStore.set({ name, value, ...options }),
        remove: (name, options) =>
          cookieStore.set({ name, value: '', ...options })
      }
    }
  );

  const { error } = await supabase.auth.exchangeCodeForSession(code);
  if (error) {
    return NextResponse.redirect(
      new URL(`/auth/login?error=${encodeURIComponent(error.message)}`, req.url)
    );
  }

  return NextResponse.redirect(new URL(next, req.url));
}

The handler reads the code Supabase placed in the URL, calls exchangeCodeForSession (which sets the session cookie via the adapter), and redirects to next on success or back to /auth/login with an error.

If this route is missing, every OAuth click 404s and you spend 90 minutes debugging Supabase before realizing the bug is in your filesystem.

7 Sync the OAuth profile to your profiles table

Supabase populates auth.users automatically, but most apps want a public profiles table for application data. The cleanest sync is a Postgres trigger on insert into auth.users:

-- Run once in the Supabase SQL editor
create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  email text,
  full_name text,
  avatar_url text,
  provider text,
  created_at timestamptz default now()
);

alter table public.profiles enable row level security;

create policy "users read own profile"
  on public.profiles for select
  using (auth.uid() = id);

create policy "users update own profile"
  on public.profiles for update
  using (auth.uid() = id);

-- Trigger to auto-populate from OAuth metadata
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer as $$
begin
  insert into public.profiles (id, email, full_name, avatar_url, provider)
  values (
    new.id,
    new.email,
    coalesce(
      new.raw_user_meta_data->>'full_name',
      new.raw_user_meta_data->>'name'
    ),
    new.raw_user_meta_data->>'avatar_url',
    new.raw_app_meta_data->>'provider'
  );
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

OAuth metadata lives on auth.users.raw_user_meta_data. Field names vary — Google sends full_name, GitHub sends name — the coalesce handles both.

Important: don’t trust raw_user_meta_data for security-sensitive values. Users can manipulate it via provider profile changes. Use it for display only; for authorization, use the verified email column on auth.users.

8 Test the flow end-to-end

The happy path is easy. The bugs hide in the unhappy paths. Before you ship, walk through each of these manually:

  • Happy path on localhost. Click “Continue with Google”, approve, end up signed in with a populated profile row.
  • Happy path on production. Same on your real domain. Catches Site URL and allowlist mistakes that hide in dev.
  • Account linking. Sign up via Google with alice@example.com, sign out, click “Continue with GitHub” with the same email. Supabase’s default is to not auto-link — the user gets stuck. Decide your linking story and surface a clear UX.
  • Email mismatch. Some users have different emails on Google vs GitHub. Without detection, you end up with duplicate user rows.
  • Denied permissions. Click the button, then “Cancel” on the consent screen. The callback should redirect to /auth/login?error=..., not a stack trace.
  • Stale session. Sign in, leave the app for an hour, return to a protected page. The middleware refresh from the magic link tutorial is mandatory — without it, OAuth sessions silently expire.

Security gotchas you must handle

Don’t trust unverified provider emails

Google guarantees the email it returns is verified. GitHub does not — the email field is whatever the user has set as public, even if unconfirmed. If you trust an unverified email as identity, an attacker can register a GitHub account claiming alice@example.com and impersonate the real Alice.

Two defenses: (1) check email_confirmed_at on auth.users before granting access — Supabase only sets it for verified emails; (2) for GitHub, request the user:email scope and call the GitHub API to fetch the primary verified email instead of trusting the profile field.

Account linking is a UX problem, not a config toggle

The hardest OAuth question: what happens when a user signs up via Google then later clicks GitHub with the same email? Auto-linking is convenient but a hijack vector if the second provider’s email isn’t verified. Forcing original-provider sign-in is safer but confusing. The right answer for most solo founders: disable auto-linking, detect the conflict in the callback, and route through an explicit “Sign in with Google to link your GitHub account” flow.

Refresh tokens, stale sessions, and revoked apps

OAuth refresh tokens stay valid a long time, but not forever — users revoke apps from their Google/GitHub settings, and tokens rotate. Handle the case where refreshSession() fails: redirect to /auth/login, show “your session expired,” don’t loop. The middleware refresh from the magic link tutorial covers this if you wire the error handler.

Don’t store OAuth provider tokens in your client

If you call Google or GitHub APIs on the user’s behalf, provider tokens live on session.provider_token and session.provider_refresh_token. Never expose these to client JS. Use a server route handler that reads the session server-side, makes the call, returns the result. Otherwise an XSS bug exposes Google API access for every signed-in user.

The OAuth consent screen needs review for production

Google’s OAuth consent screen has a Testing/Production toggle. While in Testing, only allowlisted emails can sign in — everyone else sees “This app isn’t verified.” Switching to Production may require verification (logo, privacy policy, security review for sensitive scopes). For email + profile, verification is fast. Sensitive scopes mean days of back-and-forth — plan for it.

Summary
Pick → register → redirect URIs → credentials → button → callback → profile sync → test

OAuth is a solved problem when you stick to the canonical Supabase pattern. The eight steps above ship a working flow; the security gotchas separate “works for me” from “works for the attacker too.” Don’t trust unverified emails, decide your account-linking story up front, and test every unhappy path before launch.

Related guides

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.