Skip to content

4.2 Supabase Email Authentication

Learning Objectives

  • Create and configure a Supabase project for authentication
  • Install the Supabase client SDK with secure storage integration
  • Implement email/password registration and login flows
  • Build an Auth Context to manage authentication state app-wide
  • Handle auth state changes with real-time listeners
  • Test authentication in the Supabase dashboard

Supabase is a SaaS (software-as-a-service) platform that provides us with a cloud-based PostgreSQL database with a generous free tier: 50,000 monthly active users, 500MB database, and more. Supabase has many features including edge/serverless functions and realtime data, but we’re only going to use it for handling users. It’s a complete authentication service that handles password hashing, token generation, session management, and security best practices for us so we don’t spend time reinventing the wheel.1

Let’s create our first Supabase project.

  1. Visit the Supabase dashboard and sign in with your GitHub account.

  2. Click New Project and fill in:

    Supabase New Project

  3. Wait for initialization while Supabase provisions your database and auth server. This takes 1-2 minutes. You’ll see a loading screen.

  4. Once ready, go to Integrations in the sidebar, then Data API, and copy the Project ID: https://xxxxx.supabase.co.

  5. Go to Project Settings in the sidebar, then API Keys, and copy the Publishable key: sb_publishable_....

  6. Go to Authentication in the sidebar, then Sign In / Providers, and disable Confirm email.

You now have a Supabase project! Let’s connect it to your Expo app.2

In your Expo app, install the following packages:

Terminal window
bun install @supabase/supabase-js @react-native-async-storage/async-storage@2.2.0 react-native-url-polyfill
Deeper Dive: What Each Package Does
  • @supabase/supabase-js is the official Supabase JavaScript client library. This provides all the functions you need to interact with Supabase services like signUp(), signIn(), signOut(), and database queries. It handles communication with Supabase’s REST API and manages authentication tokens automatically.

  • @react-native-async-storage/async-storage is a simple, asynchronous key-value storage system for React Native.3 Supabase requires this as a peer dependency for storing non-sensitive session metadata (like refresh times).

  • react-native-url-polyfill adds full support for the JavaScript URL API to React Native. Modern web browsers have built-in URL parsing capabilities, but React Native’s JavaScript environment doesn’t include this by default. Supabase’s client library uses URL parsing internally for things like constructing API endpoints and parsing OAuth redirect URLs, so we need this polyfill to make it work.

:::

Create a .env file at your project root if you don’t have one already, and replace the values with your credentials copied earlier from the Supabase Dashboard.

.env
EXPO_PUBLIC_SUPABASE_URL=https://xxxxxx.supabase.co
EXPO_PUBLIC_SUPABASE_KEY=sb_publishable_your_key_here

Create a new file to initialize the Supabase client:

lib/supabase.ts
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabasePublishableKey = process.env.EXPO_PUBLIC_SUPABASE_KEY!;
export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

Now let’s create a React Context to manage authentication state throughout the app.

contexts/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Session, User } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
interface AuthContextType {
7 collapsed lines
user: User | null;
session: Session | null;
isLoading: boolean;
isLoggedIn: boolean;
signUp: (email: string, password: string) => Promise<{ error?: string }>;
signIn: (email: string, password: string) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
59 collapsed lines
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Initialize session on mount
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
});
}, []);
// Listen for auth state changes
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
});
return () => subscription.unsubscribe();
}, []);
const signUp = async (email: string, password: string) => {
const { error } = await supabase.auth.signUp({ email, password });
return error ? { error: error.message } : {};
};
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
return error ? { error: error.message } : {};
};
const signOut = async () => {
await supabase.auth.signOut();
};
return (
<AuthContext.Provider
value={{
user,
session,
isLoading,
isLoggedIn: !!user,
signUp,
signIn,
signOut,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
  1. State management:

    • user: Current user object (or null if logged out)
    • session: Current session (contains tokens)
    • isLoading: True while checking for existing session on app start
  2. Session initialization (first useEffect):

    • Runs once when app opens
    • Checks secure storage for existing session
    • Restores user if valid session found
    • Sets isLoading to false when done
  3. Auth state listener (second useEffect):

    • Listens for auth events (login, logout, token refresh)
    • Automatically updates user and session state
    • Unsubscribes on cleanup to prevent memory leaks
  4. Auth functions:

    • signUp: Creates new user account
    • signIn: Logs in existing user
    • signOut: Logs out and clears tokens
    • All return error messages if something goes wrong

Set up your file structure to support auth flow with route groups:

  • Directoryapp/
    • _layout.tsx Root layout with AuthProvider
    • Directory(auth)/ Public routes (logged out)
      • _layout.tsx
      • login.tsx
      • register.tsx
    • Directory(tabs)/ Protected routes (logged in) containing your existing tabs/screens
  • Directorylib/
    • supabase.ts Supabase client
  • Directorycontexts/
    • AuthContext.tsx Auth state management

The root layout checks auth state and renders the appropriate route group using Expo Router’s built-in protection:4

(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { AuthProvider, useAuth } from '../contexts/AuthContext';
export default function RootLayoutNav() {
const { isLoggedIn } = useAuth();
return (
<Tabs>
13 collapsed lines
{/* Public tab - always visible */}
<Tabs.Screen name="weather" options={{ title: 'Weather' }} />
{/* Protected tabs - only visible when logged in */}
<Tabs.Protected guard={isLoggedIn}>
<Tabs.Screen name="todos" options={{ title: 'Todos' }} />
<Tabs.Screen name="profile" options={{ title: 'Profile' }} />
</Tabs.Protected>
{/* Login tab - only visible when logged out */}
<Tabs.Protected guard={!isLoggedIn}>
<Tabs.Screen name="login" options={{ title: 'Login' }} />
</Tabs.Protected>
</Tabs>
);
}
export default function RootLayout() {
return (
<AuthProvider>
<RootLayoutNav />
</AuthProvider>
);
}

Create the login screen with email/password inputs:

app/(auth)/login.tsx
import { useState } from 'react';
import { Link } from 'expo-router';
import { useAuth } from '../../contexts/AuthContext';
export default function LoginScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { signIn } = useAuth();
const handleLogin = async () => {
12 collapsed lines
if (!email || !password) {
// Show appropriate error message.
return;
}
setLoading(true);
const { error } = await signIn(email, password);
setLoading(false);
if (error) {
// Show appropriate error message.
}
};
return (
<View>
26 collapsed lines
<Text>Login</Text>
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Button
title={loading ? 'Loading...' : 'Login'}
onPress={handleLogin}
disabled={loading}
/>
<Link href="/(auth)/register">
<Text>Don't have an account? Register</Text>
</Link>
</View>
);
}

Notice we don’t manually call router.push() upon successful login. Expo Router handles navigation when the guard conditions change.

  1. signIn() calls supabase.auth.signInWithPassword() in the AuthContext
  2. Supabase validates credentials and returns a session with JWT tokens
  3. The tokens are automatically saved to AsyncStorage (thanks to our config)
  4. The onAuthStateChange listener fires immediately
  5. AuthContext updates session and user state
  6. The isLoggedIn value becomes true
  7. Expo Router evaluates the Tabs.Protected guards in the root layout
  8. The (auth) routes become inaccessible, (tabs) routes become accessible
  9. The app automatically navigates to the protected home screen

The register screen is almost identical to login, but calls signUp:

app/(auth)/register.tsx
import { useState } from 'react';
import { View, Text, TextInput, Button, Alert } from 'react-native';
import { Link } from 'expo-router';
import { useAuth } from '../../contexts/AuthContext';
export default function RegisterScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const { signUp } = useAuth();
const handleRegister = async () => {
22 collapsed lines
if (!email || !password || !confirmPassword) {
// Show appropriate error message.
return;
}
if (password !== confirmPassword) {
// Show appropriate error message.
return;
}
if (/* Password validation conditions */) {
// Show appropriate error message.
return;
}
setLoading(true);
const { error } = await signUp(email, password);
setLoading(false);
if (error) {
// Show appropriate error message.
}
};
return (
<View>
37 collapsed lines
<Text>Register</Text>
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
editable={!loading}
/>
<TextInput
placeholder="Password (min 6 characters)"
value={password}
onChangeText={setPassword}
secureTextEntry
editable={!loading}
/>
<TextInput
style={styles.input}
placeholder="Confirm Password"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
editable={!loading}
/>
<Button
title={loading ? 'Loading...' : 'Register'}
onPress={handleRegister}
disabled={loading}
/>
<Link href="/(auth)/login">
<Text>Already have an account? Login</Text>
</Link>
</View>
);
}

Finally, the protected home screen, or whatever you want your first screen to be. This is just an example that shows how you would access user properties and log the user out.

app/(tabs)/index.tsx
import { View, Text, Button } from 'react-native';
import { useAuth } from '../../contexts/AuthContext';
export default function HomeScreen() {
const { user, signOut } = useAuth();
return (
<View>
<Text>Welcome!</Text>
<Text>You are logged in</Text>
<View>
<Text>Email: {user?.email}</Text>
<Text>User ID: {user?.id}</Text>
</View>
<Button title="Logout" onPress={signOut} />
</View>
);
}
  1. Run your app:

    Terminal window
    bun run start
    # Press 'i' for iOS or 'a' for Android
  2. Register a new account:

    • Should see the register screen first (no user logged in)
    • Enter email and password (min 6 characters)
    • Tap “Register”
  3. Check Supabase Dashboard:

    • Go to your Supabase project
    • Navigate to Authentication → Users
    • You should see your newly registered user!
  4. Test login:

    • Close and reopen your app
    • Should automatically log you in (session persists!)
    • If you logout, you’ll see the login screen
  5. Test logout:

    • Tap “Logout” from the home screen
    • Should return to login screen
    • Session cleared from secure storage
  6. Test wrong password:

    • Try logging in with incorrect password
    • Should see error alert

When you close and reopen the app:

  1. AuthContext runs initial useEffect
  2. Calls supabase.auth.getSession()
  3. Supabase SDK checks AsyncStorage for saved tokens
  4. If valid tokens found → restores session
  5. If no tokens or expired → returns null
  6. UI renders based on result (login screen vs home screen)
Deeper Dive: Supabase's 'Session' Terminology

Supabase uses token-based authentication with JWTs, but they call the auth state a Session.

Example of a Supabase Session object:

{
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
refresh_token: "xYz123AbC456...",
expires_at: 1678903600,
expires_in: 3600,
user: {
id: "550e8400-e29b-41d4-a716-446655440000",
email: "alice@example.com",
created_at: "2024-03-15T10:30:00Z"
}
}

When you see session.access_token, that’s the JWT being sent with every API request.

In Assignment 4, you’ll add this auth system to your existing app and make all data user-scoped!

  1. Supabase Auth Documentation

  2. Supabase with Expo Tutorial

  3. Async Storage

  4. Expo Router Authentication