← Back to all posts

3 May 2026 · 6 min read · Supabase, Expo, React Native, Mobile Development, Authentication

Supabase Auth + Expo: The Gotchas Nobody Tells You

Struggling with Supabase Auth in Expo? Learn the 5 critical gotchas involving session management, deep linking, and native modules that cause production crashes.


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 that supabase.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.

The Gotcha: The default adapter in the @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:
  • Redirect URLs in Supabase Dashboard: You must add your custom scheme (e.g., myapp://auth/callback).
  • Expo Scheme Configuration: In your app.json, you need to define the scheme.
  • Handling the Link in React Navigation: You need to listen for the URL.
  • 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. Use npx 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:
  • User is on the "Home" tab (Logged in).
  • User backgrounds the app.
  • Session expires.
  • User returns and switches to the "Profile" tab.
  • The Gotcha: The Profile tab tries to fetch user data immediately. The request fails with a 401 Unauthorized. The app shows a generic error instead of redirecting to Login. The Fix: Do not rely on local state (React Context) to determine if a user is logged in. Always verify the session on the Supabase server side when a protected screen mounts.

    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?

  • Speed: We can spin up a backend in minutes, not months.
  • Cost: The free tier is generous. You only pay for usage as you scale.
  • TypeScript Support: The type safety you get from 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-audit

    We can review your current setup or help you architect your MVP from scratch to ensure you don't hit these walls in production.


    Supabase Expo React Native Mobile Development Authentication
    ← All posts