DEV Community

ARLing
ARLing

Posted on Originally published at arling.sk

Expo + Supabase: why the OAuth deep link never comes back to the app

Notes

Expo + Supabase: why the OAuth deep link never comes back to the app
Andrej Lauko, ARLing · 6 September 2026

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.
Enter fullscreen mode Exit fullscreen mode

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" } }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: true in the options to signInWithOAuth(). Without it, supabase-js tries to issue a browser redirect the way it would on web; on native, you open the URL yourself, typically with expo-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 the Linking listener that receives the deep link. PKCE's callback carries a code parameter, not a session: nothing exchanges it unless your code does.
Enter fullscreen mode Exit fullscreen mode

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 redirectTo with makeRedirectUri({ scheme, path }), never a hardcoded string.

- Confirm expo.scheme in app.json is lowercase, starts with a letter, and is not http/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/callback to the provider's console.

- Set flowType: 'pkce' and skipBrowserRedirect: true, and call exchangeCodeForSession(url) in the deep-link handler.
Enter fullscreen mode Exit fullscreen mode

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](https://arling.sk/expo-supabase-auth-doctor/) and it lists exactly which of the above does not match, for the runtime you pick.
Enter fullscreen mode Exit fullscreen mode

9. Sources

- Expo docs: AuthSession, makeRedirectUri()

- Expo docs: Linking, deep link structure

- Supabase docs: Redirect URLs, Site URL fallback and allow-list glob syntax

- Supabase docs: Native mobile deep linking

- Supabase docs: Google sign-in, callback URL and PKCE
Enter fullscreen mode Exit fullscreen mode

Originally published at arling.sk. Written by Andrej Lauko with AI assistance; every rule links to the official documentation it comes from.

Top comments (0)