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
storageadapter 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.
-
autoRefreshTokenwon't fire while the app is backgrounded. You have to nudge it onAppStatechange 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
},
},
);
Three things in that config block matter and get missed:
-
storage: AsyncStorageis what actually persists the session across launches. Newer Expo setups useexpo-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/autoat 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"
}
}
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 },
});
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();
}, []);
}
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();
}
});
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
getInitialURLcatch 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)