Supabase Auth + Google Sign-In in Expo: The Full Guide
The Google sign-in button took me four hours to get right. Not four hours of coding — four hours of navigating between Google Cloud Console, Supabase Dashboard, EAS build configs, and a half-dozen documentation pages that each assumed you'd already read the others.
The code itself? Maybe 60 lines. But those 60 lines sit on top of a configuration pyramid that will silently break your app if any single layer is wrong. And the error messages won't tell you which layer.
This is the guide I wish existed when I built authentication for Ocean Drop — a wellness app for couples running on Expo SDK 55 with Supabase as the backend. I'll cover every step, every gotcha, and the production patterns that don't appear in any quickstart.
Why Native Google Sign-In Instead of a WebView?
Most tutorials show you signInWithOAuth — Supabase opens a browser window, the user authenticates with Google, and a redirect brings them back. It works. It's also the worst user experience on mobile.
The browser flow means:
- A jarring switch from your app to a system browser
- The user might need to log into Google again (different browser session)
- The redirect back can fail silently if your deep link scheme isn't configured perfectly
- On Android, you're fighting with Chrome Custom Tabs behavior
Native Google Sign-In does something fundamentally different. It uses the Google account already on the device. The user sees a clean bottom-sheet account picker — no browser, no redirect, no URL schemes. They tap their account, and they're in.
The tradeoff: you need @react-native-google-signin/google-signin, which requires native code. That means no Expo Go — you need a development build via EAS. But if you're shipping a real app, you should already be on development builds. Expo Go is for prototyping, not production.
The Configuration Pyramid
Here's what makes this hard. You need to configure four systems in the right order, and each one references credentials from the others:
- Google Cloud Console — create OAuth client IDs (Web + Android + optionally iOS)
- Supabase Dashboard — register Google as an auth provider with the Web client ID and secret
- EAS / app.json — register the
@react-native-google-signin/google-signinplugin - Your code — configure the library with the Web client ID and wire the token flow
Miss a step, and you get cryptic errors like "DEVELOPER_ERROR" (wrong SHA-1), "SIGN_IN_CANCELLED" (wrong client ID), or a silent failure where signInWithIdToken returns no user (nonce mismatch or Supabase provider not enabled).
Let me walk through each layer.
Step 1: Google Cloud Console
Go to the Google Auth Platform in your Google Cloud project. You need to create OAuth client IDs — and this is where the first confusion hits.
You need a Web client ID even though this is a mobile app. The Web client ID is what Supabase validates against. It's also what you pass to GoogleSignin.configure(). The Android client ID (with the SHA-1 fingerprint) only needs to exist — you never reference it in code.
Web OAuth Client
Create an OAuth client of type Web application:
- Name: something like "Ocean Drop Web" (for your reference only)
- Authorized redirect URI:
https://YOUR_PROJECT.supabase.co/auth/v1/callback
Save the Client ID and Client Secret. You'll need both for Supabase.
Android OAuth Client
Create another OAuth client of type Android:
- Package name: your app's package name from
app.json(e.g.,com.oceandrop.app) - SHA-1 fingerprint: this is the tricky part
For development builds, you need the SHA-1 from your EAS development keystore. Run:
eas credentials -p android
Select your build profile and look for the SHA-1 certificate fingerprint. For production, use the SHA-1 from your Play Store upload key or Google Play App Signing key.
The silent killer: if the SHA-1 doesn't match the signing key used to build the APK, Google Sign-In will fail with DEVELOPER_ERROR. No other information. I lost an hour to this because my development and preview builds used different keystores.
Step 2: Supabase Dashboard
In your Supabase project:
- Go to Authentication → Providers → Google
- Toggle it Enabled
- Paste the Web Client ID and Web Client Secret from Google Cloud
- Optionally enable "Skip nonce checks" if you're not passing nonces (I'll explain why below)
That's it for Supabase. The dashboard config is mercifully straightforward.
Step 3: Expo Configuration
In your app.json, add the plugin:
{
"expo": {
"plugins": [
"@react-native-google-signin/google-signin"
]
}
}
In your eas.json, add the Web Client ID as an environment variable:
{
"build": {
"production": {
"env": {
"EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID": "1058508052560-xxxxx.apps.googleusercontent.com"
}
}
}
}
After adding the plugin, you need a new development build:
eas build --profile development --platform android
This is non-negotiable. The Google Sign-In library needs native modules that Expo Go doesn't include. Every time you add or change a plugin that requires native code, you rebuild.
Step 4: The Code
Now the part that's actually elegant. The entire native sign-in flow is under 40 lines:
import {
GoogleSignin,
isSuccessResponse,
statusCodes,
} from '@react-native-google-signin/google-signin';
import { supabase } from './supabase';
GoogleSignin.configure({
webClientId: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID ?? '',
scopes: ['profile', 'email'],
});
export async function googleSignIn(): Promise<{ error: string | null }> {
try {
await GoogleSignin.hasPlayServices({
showPlayServicesUpdateDialog: true,
});
const response = await GoogleSignin.signIn();
if (!isSuccessResponse(response)) {
return { error: null }; // user cancelled — not an error
}
const idToken = response.data.idToken;
if (!idToken) {
return { error: 'Google did not return an ID token.' };
}
const { error } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: idToken,
});
if (error) return { error: error.message };
return { error: null };
} catch (err: any) {
if (err?.code === statusCodes.SIGN_IN_CANCELLED) {
return { error: null };
}
if (err?.code === statusCodes.IN_PROGRESS) {
return { error: null };
}
if (err?.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
return { error: 'Google Play Services not available.' };
}
return { error: err?.message ?? 'Google sign-in failed.' };
}
}
The flow: GoogleSignin.signIn() shows the native account picker → returns a Google ID token → supabase.auth.signInWithIdToken() validates it server-side and creates (or finds) the user.
Notice the webClientId — not the Android client ID. This is counter-intuitive but correct. The Web client ID is what Google uses to scope the ID token. Supabase validates against the same Web client ID. The Android client ID is only there so Google's native SDK recognizes your app via SHA-1.
The Supabase Client Configuration That Nobody Explains
The Supabase client needs different configurations depending on platform. This is where most guides fall short — they show a single createClient call and move on.
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
import { Platform } from 'react-native';
const isNative = Platform.OS !== 'web';
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
...(isNative ? { storage: AsyncStorage } : {}),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: Platform.OS === 'web',
flowType: 'implicit',
},
});
Why conditional storage? On native (iOS/Android), there's no localStorage. You need AsyncStorage to persist the session across app restarts. On web, you don't want AsyncStorage — it wraps localStorage in async Promises, which breaks the PKCE code_verifier lookup timing during the OAuth redirect flow.
Why detectSessionInUrl only on web? On web, Supabase needs to detect the ?code= parameter in the URL after the OAuth redirect and automatically exchange it for a session. On native, there's no URL redirect — the ID token flow is direct. If you enable this on native, Supabase tries to parse the app's launch URL and may interfere with Expo Router's deep linking.
Why flowType: 'implicit'? The implicit flow returns tokens directly instead of an authorization code. For the native ID token flow, this doesn't matter much — signInWithIdToken is a direct token exchange regardless. But for the web fallback (where you might use signInWithOAuth), implicit avoids PKCE complexity.
The Profile Timing Problem
Here's a production issue that no tutorial mentions. When a new user signs up via Google, Supabase Auth creates the user record immediately. But if you have a database trigger that creates a profile row (which you should), that trigger runs asynchronously. There's a gap — sometimes 200ms, sometimes 2 seconds — where the user exists in auth.users but their profile row in public.profiles doesn't exist yet.
If your app tries to load the profile immediately after sign-in, it gets null. The user sees a blank screen or an error.
The fix is a retry loop with exponential backoff:
const fetchProfile = async (
userId: string,
retries = 3
): Promise<Profile | null> => {
for (let attempt = 0; attempt <= retries; attempt++) {
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.maybeSingle();
if (data) return data;
if (attempt < retries) {
await new Promise(r =>
setTimeout(r, 800 * (attempt + 1))
);
}
}
return null;
};
Three retries with increasing delays (800ms, 1600ms, 2400ms). .maybeSingle() instead of .single() so a missing row returns null instead of throwing an error. This small function saved me from a class of bugs that would have been invisible in testing — because local Supabase triggers run faster than production ones.
In Ocean Drop's AuthProvider, I go further: on SIGNED_IN events (fresh sign-ins, not token refreshes), the retry count increases to 4. And if all retries fail, there's a final fallback that refreshes the session token and tries once more. That handles the rare edge case where PostgREST doesn't immediately accept a brand-new access token.
Web vs Native: One Interface, Two Flows
Ocean Drop supports both web and native. The auth layer uses platform-specific files — Expo resolves google-auth.ts on web and google-auth.native.ts on native:
Native (google-auth.native.ts):
// Uses @react-native-google-signin/google-signin
// Shows native account picker
// Returns ID token → signInWithIdToken
Web (google-auth.ts):
// Uses Supabase OAuth redirect
// Opens Google in browser
// Returns via URL → detectSessionInUrl handles exchange
Both export the same googleSignIn() and googleSignOut() functions. The calling code doesn't know or care which platform it's on:
import { signInWithGoogle } from '@/lib/auth';
// Works everywhere — Expo resolves the right file
This pattern — same interface, platform-specific implementations — is one of the cleanest architectural decisions in the project. The login screen calls signInWithGoogle() and navigates on success. That's it. No Platform.OS checks in UI code.
The Sign-Out Detail Everyone Forgets
Google caches your credential on the device. If you sign out of Supabase but don't sign out of Google's native session, the next GoogleSignin.signIn() will automatically pick the same account without showing the account picker.
For some apps, that's fine. For Ocean Drop — where a couple might share a device, or someone might want to switch from their personal to partner account — it's a problem.
export async function signOut(): Promise<void> {
await GoogleSignin.signOut(); // clears native cache
await supabase.auth.signOut(); // clears Supabase session
}
Order matters. Clear Google's cache first, then Supabase. If Supabase sign-out fails (network issue), the Google cache is still cleared, which is the behavior you want — the user can pick a different account next time.
Auth Middleware for Edge Functions
Once the user is authenticated, every Supabase Edge Function needs to verify their identity. I use a shared authenticateUser function across all six edge functions in Ocean Drop:
export async function authenticateUser(
req: Request
): Promise<AuthResult> {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw { status: 401, error: "Missing authorization" };
}
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: authHeader } },
});
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw { status: 401, error: "Unauthorized" };
}
const supabaseAdmin = createClient(
SUPABASE_URL,
SUPABASE_SERVICE_ROLE_KEY
);
return { user, supabaseAdmin };
}
Two clients: one scoped to the user's JWT (for row-level security queries), one with the service role key (for admin operations like reading other users' data in partner features). The user-scoped client enforces RLS policies automatically — you can't accidentally leak data across users because the database won't return it.
This shared auth module is imported by every edge function. DRY from day one. When I built the AI chat feature, the auth was already there — three lines to authenticate, then straight to business logic.
Rate Limiting Sign-In Attempts
On the client side, Ocean Drop limits login attempts to prevent brute-force attacks against email/password auth. Five failed attempts trigger a 60-second lockout:
const MAX_ATTEMPTS = 5;
const LOCKOUT_MS = 60000;
const attempts = useRef(0);
const lockedUntil = useRef(0);
const handleSignIn = async () => {
if (Date.now() < lockedUntil.current) {
const secs = Math.ceil(
(lockedUntil.current - Date.now()) / 1000
);
setError(`Too many attempts. Try again in ${secs}s`);
return;
}
// ... attempt sign-in
if (result.error) {
attempts.current++;
if (attempts.current >= MAX_ATTEMPTS) {
lockedUntil.current = Date.now() + LOCKOUT_MS;
attempts.current = 0;
}
}
};
Client-side rate limiting is a speed bump, not a wall. Supabase has its own server-side rate limiting on auth endpoints. But the client-side version gives instant feedback and prevents the UI from hammering the server during a credential-stuffing scenario.
Google Sign-In doesn't need this — there's no password to brute-force. The ID token flow is either valid or not.
The Safety Timeout
One more production pattern. Your AuthProvider needs a safety timeout:
const safetyTimer = setTimeout(() => {
if (loadingRef.current) {
finishLoading();
}
}, 10000);
If onAuthStateChange never fires — network issue at startup, corrupted storage, some edge case you haven't imagined — the app shouldn't hang on a loading screen forever. Ten seconds, then show the unauthenticated state. The user can retry manually.
Without this, I had bug reports where users would see an infinite spinner after a network hiccup during launch. The safety timeout turned a support ticket into a "try again" tap.
Common Errors and What They Actually Mean
After shipping this to production, here's my error translation table:
| Error | Actual Cause |
|-------|-------------|
| DEVELOPER_ERROR | SHA-1 fingerprint mismatch — your build was signed with a different key than what's registered in Google Cloud |
| SIGN_IN_CANCELLED | User dismissed the picker. Not an error — handle gracefully |
| No idToken returned | webClientId is wrong or missing in GoogleSignin.configure() |
| Invalid token from Supabase | Google provider not enabled in Supabase Dashboard, or client ID mismatch |
| Profile is null after sign-in | Database trigger hasn't created the profile row yet — add retry logic |
| PLAY_SERVICES_NOT_AVAILABLE | Emulator without Google Play Services, or a Huawei device |
The debugging workflow: if Google Sign-In fails, check SHA-1 first. If Supabase rejects the token, check the Web Client ID matches between Google Cloud, Supabase Dashboard, and your code. If the profile is missing, add retries.
What I'd Do Differently
Looking back at six months with this auth system in production:
I'd add Apple Sign-In from day one. iOS App Store requires it if you offer any social sign-in. I deferred it and now it's a separate migration project. If you're building for both platforms, wire up Apple alongside Google before your first TestFlight.
I'd use Supabase's built-in rate limiting hooks instead of client-side only. Server-side rate limiting at the Supabase Auth level gives you protection even if someone bypasses your client.
I'd centralize all auth error messages. Right now, error strings are scattered across the sign-in, sign-up, and auth provider files. A single AUTH_ERRORS map would make localization and consistency easier.
But the core pattern — native ID token flow, platform-specific files, retry-based profile loading, shared edge function middleware — has been rock solid. Zero auth-related production incidents since launch.
The Pattern That Makes It Work
Authentication in mobile apps is a configuration problem disguised as a coding problem. The code is simple. The configuration pyramid — Google Cloud, Supabase, EAS, your client — is where every developer loses time.
The system I built for Ocean Drop handles native sign-in with no browser redirects, works identically across web and native with one function signature, retries profile creation timing, validates every edge function request through shared middleware, and degrades gracefully when things go wrong.
It's not the only way to do auth. But it's a way that's survived production traffic, partner sync, AI chat sessions, and real users on real devices for months. And sometimes that's more valuable than elegant theory.
The configuration pyramid is real. What makes it manageable isn't cleverness — it's having a checklist that accounts for every layer before you write a single line of code.
That checklist is exactly what Day 1 of the playbook covers. Not the auth code — the 47 pre-build items that prevent you from losing hours to missing SHA-1 fingerprints and unregistered redirect URIs.
Want the Full Workflow?
This is one piece of the system I used to ship Ocean Drop in 7 days.
The full playbook has the exact prompts, the pre-build checklist, the security audit workflow — all personalized for your stack via a single Day 0 prompt.
Transmissions from the workshop
Code, consciousness, and the craft of building with soul. No spam, no filler — just signal.