I have built over a dozen production mobile apps using Expo and Supabase. On paper, it is the perfect developer experience. You get a PostgreSQL database, real-time subscriptions, and authentication out of the box without managing a single server. However, when you move past the "Hello World" stage and try to implement Supabase Auth + Expo in a real-world scenario, things get messy quickly.
Most tutorials stop at the login screen. They don't tell you what happens when the user backgrounds the app for three hours, or how to handle the chaotic redirect nature of magic links on Android. After 12 years of full-stack engineering—specifically running Thea Tech Solutions LTD here in Bangkok—I’ve seen these specific issues drain weeks of development time.
If you are a Founder or CTO planning your architecture, or a developer knee-deep in a build, here are the five critical gotchas nobody tells you about integrating Supabase Auth with Expo.
1. The "Silent Refresh" Trap
The most common failure point I see in mobile apps is the session expiring silently. Supabase issues a JWT (JSON Web Token) that typically expires after one hour. In a web environment, the browser handles a lot of this gracefully. In a mobile app, you are on your own.
If a user opens your app after that hour is up, they are technically logged out. If your app tries to make a request to a protected RLS (Row Level Security) policy in Supabase, it will fail.
The Gotcha: Developers assume thatsupabase.auth.getSession() is enough. It is not. You must actively listen to auth state changes.
The Fix: You need to implement TokenRefreshed events and ensure your storage mechanism is correctly wired.
// In your root layout or App.tsx
import { useEffect } from 'react';
import { supabase } from './lib/supabase';
useEffect(() => {
// Listen to auth state changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (event === 'TOKEN_REFRESHED' || event === 'SIGNED_IN') {
// Force a re-render or update your global context
// This ensures the UI knows the user is valid
}
if (event === 'SIGNED_OUT') {
// Clear your local state and redirect to login
}
}
);
return () => {
subscription.unsubscribe();
};
}, []);
2. Storage Wars: SecureStore vs. Asyncstorage
This is the single biggest decision you make regarding security.
By default, the Supabase JS client uses localStorage or AsyncStorage. On iOS and Android, these storage mechanisms are not encrypted. If a user has a rooted phone or specific malware installed, they can extract the access token and impersonate your user.
@supabase/supabase-js package does not automatically switch to secure storage.
The Fix: You must write a custom storage adapter using expo-secure-store.
Here is the exact adapter pattern I use in production:
import * as SecureStore from 'expo-secure-store';
import { AsyncStorage } from 'react-native';
// Custom adapter required for Expo to use SecureStore
const ExpoSecureStoreAdapter = {
getItem: (key: string) => {
return SecureStore.getItemAsync(key);
},
setItem: (key: string, value: string) => {
return SecureStore.setItemAsync(key, value);
},
removeItem: (key: string) => {
return SecureStore.deleteItemAsync(key);
},
};
// Initialize Supabase with this adapter
const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
storage: ExpoSecureStoreAdapter as any,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false, // Critical for mobile
},
}
);
Opinionated take: If you are handling any financial or health data (PHI), using standard AsyncStorage is a liability. Use expo-secure-store from day one.
3. The Deep Linking Headache (Magic Links)
Magic links are fantastic for user conversion—no passwords to remember. But implementing them in Expo is a notorious pain point.
The Gotcha: When a user clicks a "Confirm Email" link on their mobile device, it opens in their default browser (Chrome/Safari). It does not automatically open your app.You have to configure Deep Linking to redirect the user from the browser back into your app, passing the access token in the URL hash.
The Fix: You need three things configured perfectly:myapp://auth/callback).app.json, you need to define the scheme.Here is the configuration snippet for app.json:
{
"expo": {
"scheme": "myapp",
"android": {
"intentFilters": [
{
"action": "VIEW",
"data": [{ "scheme": "myapp" }]
}
]
}
}
}
And the code to handle the incoming link:
import { Linking } from 'react-native';
// Handle the incoming URL when the app opens from a link
Linking.getInitialURL().then((url) => {
if (url) {
// Pass this URL to supabase.auth.getSessionFromUrl()
}
});
Pro Tip: On Android, if you don't verify the intent filter properly, the user clicks the link and gets a "404 Not Found" in Chrome. This kills user trust instantly.
4. Development Build vs. Go (The "It Works on My Machine" Bug)
I see this constantly in the React Native community. Developers use Expo Go to test their authentication flows. They get it working, push to production, and the app crashes immediately.
The Gotcha: Expo Go does not support native modules that aren't included in the Expo client. While standard Supabase JS works, if you use specific cryptographic libraries or secure storage, Expo Go might fail or behave differently than a Development Build (Dev Build). The Fix: Stop using Expo Go for apps that require complex auth flows involving native modules. Usenpx expo run:android or npx expo run:ios to create a custom development build.
This mimics the production environment much closer. It allows you to test expo-secure-store and deep linking schemes accurately. If you skip this, you will find yourself debugging production issues that don't exist in your local environment.
5. Managing Session State Across Tabs
If you are using a bottom tab navigator (which 90% of apps do), you will run into race conditions.
The Scenario:I implement a "Protected Route" wrapper component:
// components/ProtectedRoute.tsx
import { useEffect, useState } from 'react';
import { useRouter } from 'expo-router';
import { supabase } from '../lib/supabase';
export function useProtectedRoute() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
// Redirect to auth if no session
router.replace('/login');
} else {
setIsLoading(false);
}
};
return { isLoading };
}
Wrap your sensitive screens with this logic. It adds 200ms of latency on mount, but it saves you from showing private data to a logged-out user.
The Cost of Getting It Wrong
If you are a CTO evaluating Supabase Auth + Expo, you need to budget for these edge cases. A simple "Login" feature usually takes a junior dev 2-3 days. Implementing it securely with session persistence, secure storage, and deep linking usually takes 1-2 weeks.
At Thea Tech Solutions LTD, we charge between $3,000 and $5,000 just for a robust authentication module when we rescue failed projects. It is cheaper to do it right the first time.
Why I Still Recommend This Stack
Despite these gotchas, I still recommend Supabase and Expo for 90% of B2B apps and SaaS MVPs. Why?
supabase-js generation is superior to most REST APIs.The gotchas only exist because mobile is inherently more complex than web. Once you solve the deep linking and secure storage issues, the system is incredibly robust.
Summary
Integrating Supabase Auth + Expo requires more than just copying the signIn function from the docs. You need to:
* Use expo-secure-store for encrypted token storage.
* Implement onAuthStateChange to handle silent token refreshes.
* Configure deep linking schemes to handle magic links properly.
* Test on a Development Build, not Expo Go.
If you are building a mobile app and need this architecture implemented securely, don't let authentication be the bottleneck.
Book a free AI audit at theatechsolutions.com/ai-auditWe can review your current setup or help you architect your MVP from scratch to ensure you don't hit these walls in production.