DEV Community

Chris F A
Chris F A

Posted on

Your Expo + Supabase Login Works. Now Restart the App.

TL;DR

  • supabase.auth.signInWithPassword "working" in Expo Go proves almost nothing. The failures live in session persistence, deep-link callbacks, token refresh, and standalone builds.
  • Supabase needs an explicit storage adapter on React Native, or the session never survives an app restart.
  • OAuth and magic-link callbacks that work in Expo Go silently break in a standalone build unless the redirect scheme is wired correctly.
  • autoRefreshToken won't fire while the app is backgrounded. You have to nudge it on AppState change or users get logged out mid-session.
  • Test auth on a real standalone build, not just Expo Go. The gap between them is where the bug reports come from.

You wired up signInWithPassword, the happy path works, the demo looks great. Then a user restarts the app and they're back on the login screen. Or they tap the magic link and land on a blank screen. Or they open the app after lunch and every request 401s.

Expo + Supabase auth is one of those stacks where the tutorial ends exactly where the real problems start. Here are the four that account for most of the "auth is broken" tickets, and the fix for each.

Gap 1: The session doesn't survive a restart

This is the first one everyone hits. Sign-in works, you close the app, reopen it, and the user is logged out. On the web, Supabase persists the session in localStorage automatically. React Native has no localStorage, so unless you hand Supabase a storage adapter, the session lives only in memory and dies with the process.

The fix is to pass an explicit storage implementation when you create the client:

import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  process.env.EXPO_PUBLIC_SUPABASE_URL!,
  process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
  {
    auth: {
      storage: AsyncStorage,
      autoRefreshToken: true,
      persistSession: true,
      detectSessionInUrl: false, // there's no URL to read on native
    },
  },
);
Enter fullscreen mode Exit fullscreen mode

Three things in that config block matter and get missed:

  • storage: AsyncStorage is what actually persists the session across launches. Newer Expo setups use expo-sqlite's storage instead. Either works. The point is you must supply one.
  • detectSessionInUrl: false. The web client reads the session from a URL fragment after redirect. On iOS and Android there's no URL, so leave this off or the client waits on something that never arrives.
  • react-native-url-polyfill/auto at the very top. Supabase's SDK uses the URL API internally, and without the polyfill you get cryptic failures on some RN versions.

Gap 2: The magic-link / OAuth callback dies in a standalone build

Deep links are the second wall. Your email-link or OAuth flow works perfectly in Expo Go, you ship a standalone build, and tapping the link opens the app to a blank screen with no session.

The reason: Expo Go handles deep links through its own exp:// scheme. Your standalone build uses your app's own scheme, and Supabase has to be told to redirect there. Set your scheme in app.json:

{
  "expo": {
    "scheme": "myapp"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then build the redirect URL from that scheme and pass it into the auth call:

import * as Linking from 'expo-linking';

const redirectTo = Linking.createURL('auth/callback');

await supabase.auth.signInWithOtp({
  email,
  options: { emailRedirectTo: redirectTo },
});
Enter fullscreen mode Exit fullscreen mode

And handle the callback when the link opens the app. The link carries the tokens in its fragment, and you feed them back to Supabase:

import * as Linking from 'expo-linking';
import { useEffect } from 'react';

function useAuthDeepLink() {
  useEffect(() => {
    const handle = async (url: string) => {
      const { params } = Linking.parse(url);
      if (params?.access_token && params?.refresh_token) {
        await supabase.auth.setSession({
          access_token: String(params.access_token),
          refresh_token: String(params.refresh_token),
        });
      }
    };

    const sub = Linking.addEventListener('url', ({ url }) => handle(url));
    Linking.getInitialURL().then((url) => url && handle(url)); // cold start
    return () => sub.remove();
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

The getInitialURL call is the part people forget. addEventListener only catches links that arrive while the app is already running. If the link cold-starts the app, you need getInitialURL or the very first tap silently does nothing.

Gap 3: The token expires while the app is backgrounded

autoRefreshToken: true sounds like it handles everything. It doesn't. The refresh timer only runs while the JS engine is active. When the app is backgrounded (which is most of the time), the timer is frozen. The user opens the app after an hour, the access token has expired, and the first request 401s before the refresh catches up.

Supabase's own guidance is to drive the refresh off AppState. Start and stop it as the app foregrounds and backgrounds:

import { AppState } from 'react-native';
import { supabase } from './supabase';

AppState.addEventListener('change', (state) => {
  if (state === 'active') {
    supabase.auth.startAutoRefresh();
  } else {
    supabase.auth.stopAutoRefresh();
  }
});
Enter fullscreen mode Exit fullscreen mode

With this in place, the moment the app comes back to the foreground it refreshes the token before your screens fire their queries, and the 401-on-resume bug disappears.

Gap 4: You're testing in the wrong environment

Notice the pattern in the first three gaps: each one behaves differently in Expo Go than in a standalone build. That's the real trap. Expo Go is a shared sandbox with its own scheme, its own lifecycle handling, and forgiving defaults. Your production build has none of that.

So the rule is simple: auth is not "done" until you've tested sign-in, restart-survival, deep-link callback, and resume-after-an-hour on an actual dev-client or standalone build. Everything green in Expo Go is a necessary check, never a sufficient one.

A quick pre-ship checklist:

  • Sign in, force-quit, reopen. Still logged in?
  • Trigger a magic link / OAuth from a standalone build. Does the callback land a session?
  • Cold-start the app from the auth link. Does getInitialURL catch it?
  • Background the app past the token lifetime, reopen. Does the first request succeed?
  • Sign out. Is the session actually cleared from storage, not just from memory?

The boilerplate is the same every time

Here's the thing about all four of these: the fix is identical from app to app. The storage adapter, the scheme wiring, the deep-link handler, the AppState refresh loop. It's the same 80 lines whether you're building a fitness app or a marketplace, and it's exactly the kind of plumbing that's tedious to write and easy to get subtly wrong.

That's why a pre-built starter earns its place here. AppLighter's Expo + Supabase templates ship with this auth layer already wired: session persistence, deep-link callbacks, and the AppState refresh loop, tested on standalone builds. You start from a login flow that already survives a restart, instead of discovering gap 1 through a user complaint.

Whether you use a template or write it by hand, the checklist above is the same. Auth that works in the demo and auth that works after a restart are two different features.

Your turn

Which of these four bit you first? I'm betting it's the restart one. Drop a comment with the auth bug that cost you the most time, and what you're building.

Top comments (0)