An eight-step walkthrough for wiring Google and GitHub OAuth into a Next.js + Supabase SaaS — provider apps, redirect URIs, the App Router callback, profile sync, and the security gotchas no one warns you about.
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.
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:
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.
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.
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.
Go to github.com/settings/developers → OAuth Apps → New OAuth App. Set:
https://www.your-domain.comhttps://<your-project-ref>.supabase.co/auth/v1/callbackGitHub 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.
There are two layers of redirect URIs to keep straight, and confusing them is responsible for roughly half of all OAuth bugs:
https://<ref>.supabase.co/auth/v1/callback. Configured in Step 2.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.
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:
Save. Supabase now has everything it needs to complete the OAuth handshake server-side.
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.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.
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.
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.
The happy path is easy. The bugs hide in the unhappy paths. Before you ship, walk through each of these manually:
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./auth/login?error=..., not a stack trace.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.
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.
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.
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.
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.
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.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.