TL;DR
- OAuth in Expo returns to your app through a custom scheme like
myapp://. - Three things must agree:
schemeinapp.json, theredirectTofrommakeRedirectUri(), and Supabase's Redirect URLs allowlist. -
makeRedirectUri()returnsexp://...in Expo Go andmyapp://...in a dev build. Allowlist the one you actually use. - Changing
schemeneeds a rebuild. An OTA update won't do it. - Debug step one:
console.log(makeRedirectUri())on the device.
The setup
app.json:
{ "expo": { "scheme": "myapp" } }
Supabase dashboard, Authentication > URL Configuration > Redirect URLs:
myapp://**
(* stays inside one path segment, ** matches anything including /.)
The code
npx expo install expo-auth-session expo-web-browser expo-linking
import { makeRedirectUri } from 'expo-auth-session';
import * as QueryParams from 'expo-auth-session/build/QueryParams';
import * as WebBrowser from 'expo-web-browser';
import { supabase } from '@/lib/supabase';
const redirectTo = makeRedirectUri();
async function createSessionFromUrl(url: string) {
const { params, errorCode } = QueryParams.getQueryParams(url);
if (errorCode) throw new Error(errorCode);
const { access_token, refresh_token } = params;
if (!access_token) return;
const { error } = await supabase.auth.setSession({ access_token, refresh_token });
if (error) throw error;
}
export async function signInWithGoogle() {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo, skipBrowserRedirect: true },
});
if (error) throw error;
const res = await WebBrowser.openAuthSessionAsync(data?.url ?? '', redirectTo);
if (res.type === 'success') await createSessionFromUrl(res.url);
}
skipBrowserRedirect: true returns the URL instead of trying to navigate a browser that doesn't exist. openAuthSessionAsync only closes and returns when it sees a redirect to redirectTo, so if Supabase redirects anywhere else, nothing happens.
This mirrors Supabase's native deep linking guide.
"Works for me, not for you"
| Where it runs | makeRedirectUri() |
|---|---|
| Dev build | myapp://redirect |
| Expo Go | exp://127.0.0.1:8081/--/redirect |
| Web dev | https://localhost:19006/redirect |
So:
- Tested in Expo Go → your
exp://URL has your IP. Teammates' don't match. - Allowlisted
exp://, then switched to a dev build →myapp://isn't allowed. - Changed
scheme, didn't rebuild → the installed app still registers the old one.
Test OAuth in a dev build.
Debug checklist
-
console.log(makeRedirectUri())on the device - Does a pattern in Redirect URLs match it exactly, path included?
- Does
schemematch, and did you rebuild? - Is the provider (Google/Apple/GitHub) redirecting to
https://<ref>.supabase.co/auth/v1/callback? - Where did the browser stop? Landing on your Site URL usually means your redirect wasn't accepted.
Skip the boilerplate
I work on AppLighter: Expo SDK 54 templates with TypeScript, Expo Router, NativeWind and Supabase (RLS on every table) already set up. The fix above works in any Expo app though.
Which provider fought you hardest in Expo: Google, Apple or GitHub?
Top comments (1)
Good checklist. One gap I'd add: on Android the OS can kill the app while the auth browser is open, and then openAuthSessionAsync never resolves for the new process. I'd also run the same createSessionFromUrl on Linking.getInitialURL() and on URL events, so a cold start after the redirect still creates the session. And if you switch the client to flowType: 'pkce', the redirect carries a code instead of tokens, so that function would call exchangeCodeForSession and keep access tokens out of the URL. Did you see the killed-app case while testing?