DEV Community

Cover image for Supabase OAuth in Expo: Why Sign-In Never Comes Back to Your App
Chris F A
Chris F A

Posted on Originally published at applighter.hashnode.dev

Supabase OAuth in Expo: Why Sign-In Never Comes Back to Your App

TL;DR

  • OAuth in Expo returns to your app through a custom scheme like myapp://.
  • Three things must agree: scheme in app.json, the redirectTo from makeRedirectUri(), and Supabase's Redirect URLs allowlist.
  • makeRedirectUri() returns exp://... in Expo Go and myapp://... in a dev build. Allowlist the one you actually use.
  • Changing scheme needs 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" } }
Enter fullscreen mode Exit fullscreen mode

Supabase dashboard, Authentication > URL Configuration > Redirect URLs:

myapp://**
Enter fullscreen mode Exit fullscreen mode

(* stays inside one path segment, ** matches anything including /.)

The code

npx expo install expo-auth-session expo-web-browser expo-linking
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode

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

  1. console.log(makeRedirectUri()) on the device
  2. Does a pattern in Redirect URLs match it exactly, path included?
  3. Does scheme match, and did you rebuild?
  4. Is the provider (Google/Apple/GitHub) redirecting to https://<ref>.supabase.co/auth/v1/callback?
  5. 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)

Collapse
 
davekurian profile image
Dave Kurian •

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?