Estalio
← All insights

Magic-link auth in an Expo app, end to end

2026-06-02·6 min read

Email magic links are the right login UX for most consumer mobile apps. They're cheap to implement on the backend, they don't require remembering passwords, and on iOS they Just Work in Mobile Safari — until you try to land the session back inside an Expo app, at which point a small ritual is required to keep the user out of the browser.

This is the shape of that ritual.

The full path

The round-trip looks like this:

[Expo app] -> signInWithOtp({ email, emailRedirectTo })
            -> [Supabase] sends email
            -> [user] taps link in Mail
            -> [Mobile Safari] follows verify URL
            -> [Supabase] redirects to yourapp://#access_token=...
            -> [iOS] opens Expo app via the scheme
            -> [Expo] Linking event fires
            -> [app] supabase.auth.setSession({ access_token, refresh_token })
            -> session lands, app re-renders

Three places to get it wrong:

1. emailRedirectTo must use the custom scheme on native. Linking.createURL('/') resolves to yourapp:// (or whatever you set in app.json's scheme). Pass that as the redirect; leave it undefined on web. 2. The redirect URL has to be allowlisted in Supabase. Authentication → URL Configuration → Redirect URLs. Add yourapp://* (or the exact scheme without a wildcard if the dashboard accepts it). Skip this and the link bounces back to web. 3. The fragment, not the query. Supabase puts access_token and refresh_token in the URL fragment (#), not the query string. Linking.parse only gives you query params; you have to split on # and parse the fragment yourself.

The handler:

async function handleMagicLinkUrl(url: string) {
  const fragment = url.includes('#') ? url.split('#')[1] : ''
  const params = Object.fromEntries(new URLSearchParams(fragment))
  if (params.access_token && params.refresh_token) {
    await supabase.auth.setSession({
      access_token: params.access_token,
      refresh_token: params.refresh_token,
    })
  }
}

Register it with Linking.addEventListener('url', ...) plus a Linking.getInitialURL() for cold start. Don't forget the cold-start case — that's the one that catches everyone, because in dev you usually have the app already running when the link fires.

What this is not

We're not going through Universal Links here. Universal Links are the right answer for a published app with a verified apple-app-site-association file on a public domain, but they're a heavy lift for first-day onboarding. Custom URL schemes get you the same UX with one line in app.json. Switch to Universal Links when the app is in TestFlight and you have a real marketing site.

What's left

The remaining gotcha is AsyncStorage on native versus localStorage on web. The Supabase JS client doesn't ship a storage adapter that picks the right one for you; you wire it yourself:

import { Platform } from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'

export const supabase = createClient(url, anonKey, {
  auth: {
    storage: Platform.OS === 'web' ? undefined : AsyncStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: Platform.OS === 'web',
  },
})

detectSessionInUrl is the web-side equivalent of the Linking listener — it picks up the fragment from window.location automatically. Setting it to false on native means we own the parsing, which is what we want.

That's the whole thing. Five minutes of code, an hour of debugging if you don't know the fragment-vs-query trap, and a working magic-link login that holds up in production.