Notes
Expo + Supabase: why the OAuth deep link never comes back to the app
You call supabase.auth.signInWithOAuth() in an Expo app, the browser opens, the user signs in with Google or another provider, and then the app never gets control back. The browser tab stays open, or it closes and you are still looking at the sign-in screen. Sometimes it lands on http://localhost:3000 instead. In almost every report, the cause is that the URL your Expo runtime actually produces from makeRedirectUri() does not match what you hardcoded, what Supabase's redirect allow-list expects, or what the OAuth provider's console has on file.
1. makeRedirectUri() doesn't return what you hardcoded
Most integrations start with a hardcoded string, redirectTo: 'myapp://auth-callback', passed straight into signInWithOAuth(). That string has to match, character for character, whatever makeRedirectUri() produces for the runtime the app is actually running in. It never matches every runtime at once, because Expo computes a different value depending on the environment:
- A development build or a standalone build uses your custom scheme directly:
scheme://path, no leading slash on the path. - Expo Go ignores your scheme and always uses
exp://<dev-server-host>:<port>/--/path. - Web uses your site's own URL plus the path.
Call makeRedirectUri({ scheme, path }) at runtime and pass its result into redirectTo, instead of hardcoding a string. One more thing it is picky about: a path with a leading slash, /auth-callback instead of auth-callback, produces a stray extra slash in the final URL, for example myapp:///auth-callback. Strip leading and trailing slashes from the path before you pass it in.
2. The scheme itself has to be valid, or nothing can open the app
A native redirect only works if expo.scheme in app.json is a real custom URL scheme: lowercase letters, digits, +, -, ., starting with a letter, no spaces. http and https cannot be used here: they are reserved, and the OS will not hand control to your app for them. If the scheme is empty or invalid, makeRedirectUri() still returns a string, but no app on the device claims it, so the browser has nowhere to send the user.
{ "expo": { "scheme": "myapp" } }
Confirm that value actually reached the native build. A stale development build or standalone binary can keep an old scheme after app.json changes, until you rebuild it.
3. The redirect isn't on Supabase's allow-list
signInWithOAuth() sends redirectTo to Supabase, which checks it against Authentication → URL Configuration → Redirect URLs before it lets the browser go anywhere near it. No match, no redirect: Supabase falls back to the Site URL instead, silently.
The allow-list uses glob patterns, and . and / count as separator characters:
*matches a run of characters that are not.or/**matches any run of characters, including.and/?matches exactly one non-separator character
An entry like myapp://auth-callback only matches that one exact path. Add myapp://** instead, so every path under your scheme stays covered as the app grows. If you also test in Expo Go, allow-list its dev URL separately: Expo Go never uses your custom scheme.
4. Supabase Site URL still points to localhost
Site URL is not only the default for password-reset emails: it is where Supabase sends every rejected redirect, in every environment, including production. If Site URL is still http://localhost:8081 from early development, a redirect mismatch anywhere on this page quietly lands a production user on a URL their phone cannot reach, and the app never reopens.
Set Site URL, in Supabase → Authentication → URL Configuration, to your actual production URL, and let the redirect allow-list, not Site URL, decide where a given sign-in goes.
5. The provider console is missing the exact Supabase callback
Before the browser reaches your app, the OAuth provider redirects back to Supabase first, at a fixed URL: https://<project-ref>.supabase.co/auth/v1/callback, shown on that provider's page in the Supabase dashboard. Unlike Supabase's own allow-list, providers do not accept wildcards here. Google Cloud Console's Authorized redirect URIs, the Apple Services ID's Return URLs, and the GitHub OAuth App's Authorization callback URL all need that exact string, with no trailing slash added.
A missing or mistyped callback here fails before your app's own redirect logic ever runs: the user sees an error page from the provider, not from Supabase or from your code.
6. Native code still needs skipBrowserRedirect, PKCE and exchangeCodeForSession
Three more settings decide whether a correctly matched redirect turns into an actual session on native:
- Pass
skipBrowserRedirect: truein the options tosignInWithOAuth(). Without it, supabase-js tries to issue a browser redirect the way it would on web; on native, you open the URL yourself, typically withexpo-web-browser. - Use
flowType: 'pkce'when creating the client. The implicit flow returns tokens in a URL fragment, and fragments do not always survive a deep link. - Call
exchangeCodeForSession(url)in theLinkinglistener that receives the deep link. PKCE's callback carries acodeparameter, not a session: nothing exchanges it unless your code does.
Skip any one of the three and the URL can come back to the app correctly and still leave the user signed out.
7. Checklist
- Build
redirectTowithmakeRedirectUri({ scheme, path }), never a hardcoded string. - Confirm
expo.schemeinapp.jsonis lowercase, starts with a letter, and is nothttp/https. - Strip leading and trailing slashes from the path you pass to
makeRedirectUri. - Add
myapp://**to Supabase → Authentication → URL Configuration → Redirect URLs. - Allow-list the
exp://dev URL too if you test in Expo Go. - Set Site URL to your production URL, not
localhost. - Add the exact
https://<project-ref>.supabase.co/auth/v1/callbackto the provider's console. - Set
flowType: 'pkce'andskipBrowserRedirect: true, and callexchangeCodeForSession(url)in the deep-link handler.
8. Check it in 30 seconds
Paste your scheme, path, Site URL, redirect allow-list and provider callback into the Redirect Doctor for Expo and Supabase and it lists exactly which of the above does not match, for the runtime you pick.