DEV Community

Sarah
Sarah

Posted on Originally published at sarah-dyne.hashnode.dev

Supabase Auth in Expo: Sessions, OAuth, and Protected Routes That Hold Up in Production

TL;DR

  • The default expo-sqlite localStorage session store is not encrypted. Your refresh token sits in plain text in the app sandbox.
  • Access tokens expire while the app is backgrounded. Wire startAutoRefresh / stopAutoRefresh to AppState or eat a burst of 401s on resume.
  • Stack.Protected in Expo Router replaces all your manual router.replace auth redirects.
  • Use email OTP instead of magic links on mobile. Deep links from email clients are a mess.
  • OAuth needs flowType: 'pkce', skipBrowserRedirect: true, an auth/callback route, and both myapp://** and exp://** allowlisted in Supabase.
  • Auth tells you who the user is. RLS is what actually stops them reading everyone else's rows.

Getting a Supabase login button working in Expo takes ten minutes. Keeping it working in production is a different story, because none of the real problems show up in the simulator. Here's the full auth layer I'd ship, with the gotchas fixed.

1. Install

npx create-expo-app@latest my-app
cd my-app
npx expo install @supabase/supabase-js expo-sqlite expo-web-browser expo-linking expo-splash-screen
Enter fullscreen mode Exit fullscreen mode

.env at the project root (values are in Project Settings → API Keys):

EXPO_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxxxxxxxxx
Enter fullscreen mode Exit fullscreen mode

EXPO_PUBLIC_ vars get bundled into the app. That's fine for the publishable key — it's meant to be public. Never ship the service_role / secret key in an Expo app.

2. The Supabase client

// lib/supabase.ts
import 'expo-sqlite/localStorage/install';
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!;

export const supabase = createClient(supabaseUrl, supabaseKey, {
  auth: {
    storage: localStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false, // no URL bar in a native app
    flowType: 'pkce',          // needed for OAuth in section 6
  },
});
Enter fullscreen mode Exit fullscreen mode

This is what the official Supabase and Expo docs recommend now: a SQLite-backed localStorage polyfill that works on iOS, Android and web.

The part nobody mentions: it's not encrypted

That SQLite file holds your refresh token, which is basically a long-lived password. Pick storage based on what your app does:

Storage Encrypted Size limit Web Use it for
expo-sqlite localStorage No None in practice Yes Prototypes, low-risk apps
AsyncStorage No None in practice Yes Legacy setups
expo-secure-store alone Yes ~2 KB per value No Nothing — sessions often exceed 2 KB
Encrypted AsyncStorage + key in SecureStore Yes None in practice No Fintech, health, anything sensitive

The last row is the pattern Supabase documents: encrypt the session with a random AES key, put the ciphertext in AsyncStorage, and keep only the tiny key in SecureStore.

npx expo install expo-secure-store @react-native-async-storage/async-storage aes-js react-native-get-random-values
Enter fullscreen mode Exit fullscreen mode
// lib/large-secure-store.ts
import 'react-native-get-random-values';
import * as aesjs from 'aes-js';
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';

export class LargeSecureStore {
  private async encrypt(key: string, value: string) {
    const encryptionKey = crypto.getRandomValues(new Uint8Array(256 / 8));
    const cipher = new aesjs.ModeOfOperation.ctr(encryptionKey, new aesjs.Counter(1));
    const encryptedBytes = cipher.encrypt(aesjs.utils.utf8.toBytes(value));
    await SecureStore.setItemAsync(key, aesjs.utils.hex.fromBytes(encryptionKey));
    return aesjs.utils.hex.fromBytes(encryptedBytes);
  }

  private async decrypt(key: string, value: string) {
    const encryptionKeyHex = await SecureStore.getItemAsync(key);
    if (!encryptionKeyHex) return encryptionKeyHex;
    const cipher = new aesjs.ModeOfOperation.ctr(
      aesjs.utils.hex.toBytes(encryptionKeyHex),
      new aesjs.Counter(1)
    );
    const decryptedBytes = cipher.decrypt(aesjs.utils.hex.toBytes(value));
    return aesjs.utils.utf8.fromBytes(decryptedBytes);
  }

  async getItem(key: string) {
    const encrypted = await AsyncStorage.getItem(key);
    if (!encrypted) return encrypted;
    return this.decrypt(key, encrypted);
  }

  async setItem(key: string, value: string) {
    const encrypted = await this.encrypt(key, value);
    await AsyncStorage.setItem(key, encrypted);
  }

  async removeItem(key: string) {
    await AsyncStorage.removeItem(key);
    await SecureStore.deleteItemAsync(key);
  }
}
Enter fullscreen mode Exit fullscreen mode

Swap it in. SecureStore doesn't exist on web, so branch if you ship a web build:

import { Platform } from 'react-native';
import { LargeSecureStore } from './large-secure-store';

storage: Platform.OS === 'web' ? localStorage : new LargeSecureStore(),
Enter fullscreen mode Exit fullscreen mode

3. Refresh tokens on foreground

autoRefreshToken runs on a timer, and mobile OSes don't keep that timer alive in the background. User comes back after an hour → first few requests 401.

Tie the refresh loop to AppState, at module level in lib/supabase.ts:

import { AppState, Platform } from 'react-native';

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

Module level, not inside a component — you want exactly one listener for the app's lifetime.

4. AuthProvider

// providers/auth-provider.tsx
import { createContext, useContext, useEffect, useState, PropsWithChildren } from 'react';
import type { Session } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase';

type AuthState = {
  session: Session | null;
  isLoading: boolean;
};

const AuthContext = createContext<AuthState>({ session: null, isLoading: true });

export function AuthProvider({ children }: PropsWithChildren) {
  const [session, setSession] = useState<Session | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // Restore any persisted session on launch
    supabase.auth.getSession().then(({ data }) => {
      setSession(data.session);
      setIsLoading(false);
    });

    // Stay in sync with sign-in, sign-out and token refresh
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, newSession) => {
      setSession(newSession);
    });

    return () => subscription.unsubscribe();
  }, []);

  return (
    <AuthContext.Provider value={{ session, isLoading }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);
Enter fullscreen mode Exit fullscreen mode

Two things that will bite you:

  • Don't await Supabase calls inside onAuthStateChange. It can deadlock the auth client. Fetch the profile in a separate effect keyed on session.
  • getSession() is not proof of identity. It just reads local storage. On a server or edge function, verify the JWT with getClaims() or getUser().

5. Protected routes with Stack.Protected

app/
  _layout.tsx
  sign-in.tsx
  auth/
    callback.tsx
  (app)/
    _layout.tsx
    index.tsx
    profile.tsx
Enter fullscreen mode Exit fullscreen mode
// app/_layout.tsx
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { AuthProvider, useAuth } from '@/providers/auth-provider';

SplashScreen.preventAutoHideAsync();

function RootNavigator() {
  const { session, isLoading } = useAuth();

  useEffect(() => {
    if (!isLoading) SplashScreen.hideAsync();
  }, [isLoading]);

  if (isLoading) return null; // splash stays up — no sign-in screen flash

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>

      <Stack.Protected guard={!session}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>

      {/* Outside both guards so OAuth redirects never hit "Unmatched Route" */}
      <Stack.Screen name="auth/callback" />
    </Stack>
  );
}

export default function RootLayout() {
  return (
    <AuthProvider>
      <RootNavigator />
    </AuthProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

When session flips, the router redirects on its own. No router.replace sprinkled across screens. Holding the splash until the session is restored is what stops returning users from seeing the sign-in screen flash for half a second.

6. Sign-in methods

You never navigate after a successful sign-in — onAuthStateChange updates the session and the guard handles routing.

Email + password

import { Alert } from 'react-native';
import { supabase } from '@/lib/supabase';

export async function signUpWithEmail(email: string, password: string) {
  const { data, error } = await supabase.auth.signUp({ email, password });
  if (error) return Alert.alert('Sign-up failed', error.message);
  if (!data.session) {
    Alert.alert('Check your inbox', 'Confirm your email to finish signing up.');
  }
}

export async function signInWithEmail(email: string, password: string) {
  const { error } = await supabase.auth.signInWithPassword({ email, password });
  if (error) Alert.alert('Sign-in failed', error.message);
}
Enter fullscreen mode Exit fullscreen mode

Email OTP (skip magic links)

Hot take: magic links are a bad default on mobile. People open the email on their laptop, in-app mail browsers eat the deep link, and Expo Go's scheme differs from your prod build. A 6-digit code keeps the user inside your app:

export async function sendEmailCode(email: string) {
  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: { shouldCreateUser: true },
  });
  if (error) throw error;
}

export async function verifyEmailCode(email: string, token: string) {
  const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' });
  if (error) throw error;
}
Enter fullscreen mode Exit fullscreen mode

In Authentication → Emails, edit the Magic Link template to use {{ .Token }} instead of {{ .ConfirmationURL }}.

Google OAuth with PKCE

// lib/oauth.ts
import * as WebBrowser from 'expo-web-browser';
import * as Linking from 'expo-linking';
import { supabase } from '@/lib/supabase';

WebBrowser.maybeCompleteAuthSession();

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

export async function signInWithGoogle() {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: {
      redirectTo,
      skipBrowserRedirect: true, // we open the browser ourselves
    },
  });
  if (error) throw error;

  const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo);
  if (result.type !== 'success') return; // user cancelled

  const { queryParams } = Linking.parse(result.url);
  const code = queryParams?.code;
  if (typeof code !== 'string') throw new Error('No auth code returned');

  const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code);
  if (exchangeError) throw exchangeError;
}
Enter fullscreen mode Exit fullscreen mode

The callback route exists only so the router has somewhere to land:

// app/auth/callback.tsx
import { Redirect } from 'expo-router';

export default function AuthCallback() {
  return <Redirect href="/" />;
}
Enter fullscreen mode Exit fullscreen mode

The step everyone forgets: Linking.createURL returns different URLs per environment.

Environment Redirect URL
Expo Go exp://192.168.1.10:8081/--/auth/callback
Dev / production build myapp://auth/callback

Allowlist both in Authentication → URL Configuration → Redirect URLs:

myapp://**
exp://**
Enter fullscreen mode Exit fullscreen mode

And make sure app.json has "scheme": "myapp". If the URL isn't allowlisted, Supabase silently falls back to your Site URL and the browser never comes back to the app.

For production, native Google/Apple sign-in via signInWithIdToken is a smoother UX — and Apple requires Sign in with Apple if you offer other social logins on iOS.

7. RLS: the actual security boundary

Without RLS, anyone with your publishable key can read every row. Run this in the SQL editor:

create table public.profiles (
  id uuid primary key references auth.users on delete cascade,
  full_name text,
  avatar_url text,
  updated_at timestamptz default now()
);

alter table public.profiles enable row level security;

create policy "Users can read their own profile"
  on public.profiles for select
  using ((select auth.uid()) = id);

create policy "Users can update their own profile"
  on public.profiles for update
  using ((select auth.uid()) = id);

-- Auto-create a profile on sign-up
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, full_name, avatar_url)
  values (
    new.id,
    new.raw_user_meta_data ->> 'full_name',
    new.raw_user_meta_data ->> 'avatar_url'
  );
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();
Enter fullscreen mode Exit fullscreen mode

(select auth.uid()) instead of bare auth.uid() lets Postgres evaluate it once per query instead of once per row. Free perf win on big tables.

Client queries don't need a user filter anymore — RLS scopes them:

const { data: profile } = await supabase.from('profiles').select('*').single();
Enter fullscreen mode Exit fullscreen mode

Writing policies for every table is where most of the real auth work lives. If you want a reference, the AppLighter Expo + Supabase templates ship with reviewed RLS on every table and secrets kept in edge functions — worth skimming just for the policies.

8. Sign out and account deletion

await supabase.auth.signOut();
Enter fullscreen mode Exit fullscreen mode

The guard sends the user back to sign-in.

If you ship to the App Store, in-app account deletion is mandatory for apps with account creation. Deleting a user needs the secret key, so do it in a Supabase Edge Function that verifies the caller's JWT and calls auth.admin.deleteUser(userId). Never from the client.

9. Debugging cheat sheet

Symptom Likely cause Fix
Logged out on every restart No storage or persistSession: false Pass a storage adapter, keep persistSession: true
401s right after resume Refresh timer paused in background AppState listener from section 3
OAuth browser never returns Redirect URL not allowlisted Add myapp://** and exp://**
"Unmatched Route" after OAuth No auth/callback route Add it outside the protected groups
PKCE verifier error on exchange Missing flowType: 'pkce' or storage wiped mid-flow Set flowType: 'pkce', keep one storage adapter
Sign-in screen flashes Rendering before session restore Hold the splash while isLoading
Empty arrays after sign-in RLS on, no select policy Add a policy for that operation
App freezes after sign-in Awaiting inside onAuthStateChange Move work into a separate effect

Wrapping up

The sign-in call is the easy part. Choose session storage on purpose, refresh on foreground, guard routes declaratively, and treat RLS as the real boundary.

What are you using for auth in your Expo app — OTP, browser OAuth, or native Apple/Google? Drop a comment with what you're building and which edge case bit you.

Top comments (0)