Skip to main content

Module 6: Navigation with Expo Router

Authentication Flows

Building secure, user-friendly authentication with protected routes

🎯 Learning Objectives

  • Build a complete authentication context with React Context
  • Store authentication tokens securely with Expo SecureStore
  • Implement protected routes that redirect unauthenticated users
  • Handle loading states during authentication checks
  • Create login, registration, and password reset flows
  • Implement persistent login across app restarts
  • Handle token refresh and session expiration

Authentication Overview

Authentication in mobile apps involves verifying user identity, managing sessions, and controlling access to protected content. A well-designed auth flow provides security without sacrificing user experience.

Auth Flow Architecture

flowchart TD
    A[App Starts] --> B{Check stored token}
    B -->|No token| C[Show Auth Screens]
    B -->|Has token| D{Validate token}
    D -->|Valid| E[Show Main App]
    D -->|Invalid/Expired| F{Can refresh?}
    F -->|Yes| G[Refresh token]
    F -->|No| C
    G -->|Success| E
    G -->|Failure| C
    
    C --> H[User logs in]
    H --> I[Store token securely]
    I --> E
    
    E --> J[User logs out]
    J --> K[Clear stored token]
    K --> C
    
    style E fill:#e8f5e9
    style C fill:#fff3cd
                

Key Components

πŸ” Authentication System Components

Component Purpose
Auth Context Global state for user and authentication status
Secure Storage Encrypted storage for tokens (SecureStore)
Protected Routes Routes that require authentication to access
Auth Screens Login, Register, Forgot Password screens
API Integration Backend communication with token headers

File Structure

app/
β”œβ”€β”€ _layout.tsx                 # Root layout with AuthProvider
β”œβ”€β”€ (auth)/                     # Public auth screens
β”‚   β”œβ”€β”€ _layout.tsx
β”‚   β”œβ”€β”€ login.tsx
β”‚   β”œβ”€β”€ register.tsx
β”‚   └── forgot-password.tsx
β”œβ”€β”€ (app)/                      # Protected screens
β”‚   β”œβ”€β”€ _layout.tsx             # Protected layout wrapper
β”‚   └── (tabs)/
β”‚       β”œβ”€β”€ _layout.tsx
β”‚       └── ...
context/
β”œβ”€β”€ AuthContext.tsx             # Auth state and methods
hooks/
β”œβ”€β”€ useAuth.ts                  # Auth hook
β”œβ”€β”€ useProtectedRoute.ts        # Route protection hook
services/
β”œβ”€β”€ auth.ts                     # Auth API calls
β”œβ”€β”€ secureStorage.ts            # Token storage utilities

Secure Token Storage

Never store authentication tokens in plain AsyncStorage. Use Expo SecureStore which provides encrypted storage backed by the device's secure enclave (iOS Keychain / Android Keystore).

Installing SecureStore

npx expo install expo-secure-store

Storage Utility

// services/secureStorage.ts
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';

const TOKEN_KEY = 'auth_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
const USER_KEY = 'user_data';

// SecureStore is not available on web, use a fallback
const isSecureStoreAvailable = Platform.OS !== 'web';

export const secureStorage = {
  // Store auth token
  async setToken(token: string): Promise<void> {
    if (isSecureStoreAvailable) {
      await SecureStore.setItemAsync(TOKEN_KEY, token);
    } else {
      // Web fallback (less secure, for development only)
      localStorage.setItem(TOKEN_KEY, token);
    }
  },

  // Get auth token
  async getToken(): Promise<string | null> {
    if (isSecureStoreAvailable) {
      return await SecureStore.getItemAsync(TOKEN_KEY);
    } else {
      return localStorage.getItem(TOKEN_KEY);
    }
  },

  // Store refresh token
  async setRefreshToken(token: string): Promise<void> {
    if (isSecureStoreAvailable) {
      await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token);
    } else {
      localStorage.setItem(REFRESH_TOKEN_KEY, token);
    }
  },

  // Get refresh token
  async getRefreshToken(): Promise<string | null> {
    if (isSecureStoreAvailable) {
      return await SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
    } else {
      return localStorage.getItem(REFRESH_TOKEN_KEY);
    }
  },

  // Store user data (as JSON string)
  async setUser(user: object): Promise<void> {
    const userString = JSON.stringify(user);
    if (isSecureStoreAvailable) {
      await SecureStore.setItemAsync(USER_KEY, userString);
    } else {
      localStorage.setItem(USER_KEY, userString);
    }
  },

  // Get user data
  async getUser<T>(): Promise<T | null> {
    let userString: string | null;
    if (isSecureStoreAvailable) {
      userString = await SecureStore.getItemAsync(USER_KEY);
    } else {
      userString = localStorage.getItem(USER_KEY);
    }
    return userString ? JSON.parse(userString) : null;
  },

  // Clear all auth data (for logout)
  async clearAll(): Promise<void> {
    if (isSecureStoreAvailable) {
      await SecureStore.deleteItemAsync(TOKEN_KEY);
      await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
      await SecureStore.deleteItemAsync(USER_KEY);
    } else {
      localStorage.removeItem(TOKEN_KEY);
      localStorage.removeItem(REFRESH_TOKEN_KEY);
      localStorage.removeItem(USER_KEY);
    }
  },
};

βœ… SecureStore Best Practices

  • Always use SecureStore for sensitive data (tokens, passwords)
  • Keep stored data minimalβ€”don't store entire user objects
  • Clear storage completely on logout
  • Handle storage errors gracefully
  • Use a web fallback for development, but note it's less secure

⚠️ SecureStore Limitations

  • Maximum value size: 2048 bytes
  • Not available on web (use fallback)
  • Data is tied to the appβ€”reinstalling clears it
  • No automatic backup to iCloud/Google (which is good for security)

Building the Auth Context

The auth context provides global access to authentication state and methods throughout your app.

Types Definition

// types/auth.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
}

export interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
}

export interface LoginCredentials {
  email: string;
  password: string;
}

export interface RegisterData {
  email: string;
  password: string;
  name: string;
}

export interface AuthContextType extends AuthState {
  login: (credentials: LoginCredentials) => Promise<void>;
  register: (data: RegisterData) => Promise<void>;
  logout: () => Promise<void>;
  refreshSession: () => Promise<boolean>;
}

Auth Context Implementation

// context/AuthContext.tsx
import { 
  createContext, 
  useContext, 
  useState, 
  useEffect, 
  useCallback,
  ReactNode 
} from 'react';
import { secureStorage } from '@/services/secureStorage';
import { authApi } from '@/services/auth';
import type { User, AuthContextType, LoginCredentials, RegisterData } from '@/types/auth';

const AuthContext = createContext<AuthContextType | null>(null);

interface AuthProviderProps {
  children: ReactNode;
}

export function AuthProvider({ children }: AuthProviderProps) {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const isAuthenticated = !!token && !!user;

  // Initialize auth state from storage
  useEffect(() => {
    initializeAuth();
  }, []);

  const initializeAuth = async () => {
    try {
      setIsLoading(true);
      
      // Try to get stored token
      const storedToken = await secureStorage.getToken();
      const storedUser = await secureStorage.getUser<User>();

      if (storedToken && storedUser) {
        // Validate token with backend
        const isValid = await validateToken(storedToken);
        
        if (isValid) {
          setToken(storedToken);
          setUser(storedUser);
        } else {
          // Token invalid, try to refresh
          const refreshed = await refreshSession();
          if (!refreshed) {
            // Refresh failed, clear storage
            await secureStorage.clearAll();
          }
        }
      }
    } catch (error) {
      console.error('Auth initialization error:', error);
      await secureStorage.clearAll();
    } finally {
      setIsLoading(false);
    }
  };

  const validateToken = async (token: string): Promise<boolean> => {
    try {
      await authApi.validateToken(token);
      return true;
    } catch {
      return false;
    }
  };

  const login = useCallback(async (credentials: LoginCredentials) => {
    try {
      setIsLoading(true);
      
      const response = await authApi.login(credentials);
      
      // Store tokens securely
      await secureStorage.setToken(response.token);
      if (response.refreshToken) {
        await secureStorage.setRefreshToken(response.refreshToken);
      }
      await secureStorage.setUser(response.user);
      
      // Update state
      setToken(response.token);
      setUser(response.user);
    } catch (error) {
      // Re-throw for the UI to handle
      throw error;
    } finally {
      setIsLoading(false);
    }
  }, []);

  const register = useCallback(async (data: RegisterData) => {
    try {
      setIsLoading(true);
      
      const response = await authApi.register(data);
      
      // Store tokens securely
      await secureStorage.setToken(response.token);
      if (response.refreshToken) {
        await secureStorage.setRefreshToken(response.refreshToken);
      }
      await secureStorage.setUser(response.user);
      
      // Update state
      setToken(response.token);
      setUser(response.user);
    } catch (error) {
      throw error;
    } finally {
      setIsLoading(false);
    }
  }, []);

  const logout = useCallback(async () => {
    try {
      setIsLoading(true);
      
      // Notify backend (optional, for token invalidation)
      if (token) {
        try {
          await authApi.logout(token);
        } catch {
          // Ignore errors, still clear local state
        }
      }
      
      // Clear storage
      await secureStorage.clearAll();
      
      // Clear state
      setToken(null);
      setUser(null);
    } finally {
      setIsLoading(false);
    }
  }, [token]);

  const refreshSession = useCallback(async (): Promise<boolean> => {
    try {
      const refreshToken = await secureStorage.getRefreshToken();
      
      if (!refreshToken) {
        return false;
      }

      const response = await authApi.refreshToken(refreshToken);
      
      // Update stored tokens
      await secureStorage.setToken(response.token);
      if (response.refreshToken) {
        await secureStorage.setRefreshToken(response.refreshToken);
      }
      
      // Update state
      setToken(response.token);
      
      return true;
    } catch {
      return false;
    }
  }, []);

  const value: AuthContextType = {
    user,
    token,
    isAuthenticated,
    isLoading,
    login,
    register,
    logout,
    refreshSession,
  };

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

// Custom hook for using auth context
export function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  
  return context;
}

Auth API Service

// services/auth.ts
import type { User, LoginCredentials, RegisterData } from '@/types/auth';

const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.example.com';

interface AuthResponse {
  user: User;
  token: string;
  refreshToken?: string;
}

export const authApi = {
  async login(credentials: LoginCredentials): Promise<AuthResponse> {
    const response = await fetch(`${API_URL}/auth/login`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(credentials),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Login failed');
    }

    return response.json();
  },

  async register(data: RegisterData): Promise<AuthResponse> {
    const response = await fetch(`${API_URL}/auth/register`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Registration failed');
    }

    return response.json();
  },

  async logout(token: string): Promise<void> {
    await fetch(`${API_URL}/auth/logout`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
      },
    });
  },

  async validateToken(token: string): Promise<User> {
    const response = await fetch(`${API_URL}/auth/me`, {
      headers: {
        'Authorization': `Bearer ${token}`,
      },
    });

    if (!response.ok) {
      throw new Error('Invalid token');
    }

    return response.json();
  },

  async refreshToken(refreshToken: string): Promise<AuthResponse> {
    const response = await fetch(`${API_URL}/auth/refresh`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ refreshToken }),
    });

    if (!response.ok) {
      throw new Error('Token refresh failed');
    }

    return response.json();
  },

  async forgotPassword(email: string): Promise<void> {
    const response = await fetch(`${API_URL}/auth/forgot-password`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Request failed');
    }
  },
};

Protected Routes

Protected routes ensure that only authenticated users can access certain parts of your app. Unauthenticated users are automatically redirected to the login screen.

Root Layout with Auth Provider

// app/_layout.tsx
import { useEffect } from 'react';
import { Slot, useRouter, useSegments } from 'expo-router';
import { AuthProvider, useAuth } from '@/context/AuthContext';
import { View, ActivityIndicator, StyleSheet } from 'react-native';

// Auth state listener component
function AuthStateListener({ children }: { children: React.ReactNode }) {
  const { isAuthenticated, isLoading } = useAuth();
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    if (isLoading) return;

    const inAuthGroup = segments[0] === '(auth)';

    if (!isAuthenticated && !inAuthGroup) {
      // User is not authenticated and trying to access protected route
      router.replace('/(auth)/login');
    } else if (isAuthenticated && inAuthGroup) {
      // User is authenticated but still in auth screens
      router.replace('/(app)');
    }
  }, [isAuthenticated, isLoading, segments]);

  // Show loading screen while checking auth
  if (isLoading) {
    return (
      <View style={styles.loadingContainer}>
        <ActivityIndicator size="large" color="#6366f1" />
      </View>
    );
  }

  return <{children}>;
}

export default function RootLayout() {
  return (
    <AuthProvider>
      <AuthStateListener>
        <Slot />
      </AuthStateListener>
    </AuthProvider>
  );
}

const styles = StyleSheet.create({
  loadingContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#fff',
  },
});
Protected Routes Flow isAuthenticated? false true (auth) Group login.tsx register.tsx (app) Group (tabs)/ Protected screens redirect if authed redirect if not authed

Auth Group Layout

// app/(auth)/_layout.tsx
import { Stack } from 'expo-router';

export default function AuthLayout() {
  return (
    <Stack
      screenOptions={{
        headerShown: false,
        animation: 'fade',
      }}
    >
      <Stack.Screen name="login" />
      <Stack.Screen name="register" />
      <Stack.Screen 
        name="forgot-password" 
        options={{
          presentation: 'modal',
          headerShown: true,
          title: 'Reset Password',
        }}
      />
    </Stack>
  );
}

Protected App Layout

// app/(app)/_layout.tsx
import { Stack } from 'expo-router';
import { useAuth } from '@/context/AuthContext';
import { Redirect } from 'expo-router';

export default function AppLayout() {
  const { isAuthenticated, isLoading } = useAuth();

  // Extra protection: redirect if somehow accessed without auth
  if (!isLoading && !isAuthenticated) {
    return <Redirect href="/(auth)/login" />;
  }

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="(tabs)" />
      <Stack.Screen 
        name="settings" 
        options={{
          headerShown: true,
          title: 'Settings',
          presentation: 'modal',
        }}
      />
    </Stack>
  );
}

Alternative: useProtectedRoute Hook

// hooks/useProtectedRoute.ts
import { useEffect } from 'react';
import { useRouter, useSegments } from 'expo-router';
import { useAuth } from '@/context/AuthContext';

export function useProtectedRoute() {
  const { isAuthenticated, isLoading } = useAuth();
  const segments = useSegments();
  const router = useRouter();

  useEffect(() => {
    if (isLoading) return;

    const inAuthGroup = segments[0] === '(auth)';
    const inProtectedGroup = segments[0] === '(app)';

    if (!isAuthenticated && inProtectedGroup) {
      // Redirect to login
      router.replace('/(auth)/login');
    } else if (isAuthenticated && inAuthGroup) {
      // Redirect to app
      router.replace('/(app)');
    }
  }, [isAuthenticated, isLoading, segments]);

  return { isLoading, isAuthenticated };
}

// Usage in any screen that needs protection
export default function ProtectedScreen() {
  const { isLoading } = useProtectedRoute();

  if (isLoading) {
    return <LoadingScreen />;
  }

  return (/* ... */);
}

Authentication Screens

Well-designed authentication screens are crucial for user experience. They should be clean, accessible, and handle errors gracefully.

Login Screen

// app/(auth)/login.tsx
import { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  Pressable,
  StyleSheet,
  KeyboardAvoidingView,
  Platform,
  Alert,
  ActivityIndicator,
} from 'react-native';
import { Link, useRouter } from 'expo-router';
import { useAuth } from '@/context/AuthContext';
import { Ionicons } from '@expo/vector-icons';

export default function LoginScreen() {
  const router = useRouter();
  const { login, isLoading } = useAuth();
  
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [showPassword, setShowPassword] = useState(false);
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const validate = (): boolean => {
    const newErrors: typeof errors = {};
    
    if (!email) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = 'Please enter a valid email';
    }
    
    if (!password) {
      newErrors.password = 'Password is required';
    } else if (password.length < 6) {
      newErrors.password = 'Password must be at least 6 characters';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleLogin = async () => {
    if (!validate()) return;
    
    try {
      await login({ email, password });
      // Navigation handled by auth state listener
    } catch (error) {
      Alert.alert(
        'Login Failed',
        error instanceof Error ? error.message : 'Please check your credentials'
      );
    }
  };

  return (
    <KeyboardAvoidingView
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      style={styles.container}
    >
      <View style={styles.content}>
        {/* Header */}
        <View style={styles.header}>
          <Text style={styles.title}>Welcome Back</Text>
          <Text style={styles.subtitle}>Sign in to continue</Text>
        </View>

        {/* Form */}
        <View style={styles.form}>
          {/* Email Input */}
          <View style={styles.inputGroup}>
            <Text style={styles.label}>Email</Text>
            <View style={[styles.inputContainer, errors.email && styles.inputError]}>
              <Ionicons name="mail-outline" size={20} color="#9ca3af" style={styles.inputIcon} />
              <TextInput
                style={styles.input}
                placeholder="Enter your email"
                placeholderTextColor="#9ca3af"
                value={email}
                onChangeText={(text) => {
                  setEmail(text);
                  if (errors.email) setErrors(prev => ({ ...prev, email: undefined }));
                }}
                keyboardType="email-address"
                autoCapitalize="none"
                autoComplete="email"
                editable={!isLoading}
              />
            </View>
            {errors.email && <Text style={styles.errorText}>{errors.email}</Text>}
          </View>

          {/* Password Input */}
          <View style={styles.inputGroup}>
            <Text style={styles.label}>Password</Text>
            <View style={[styles.inputContainer, errors.password && styles.inputError]}>
              <Ionicons name="lock-closed-outline" size={20} color="#9ca3af" style={styles.inputIcon} />
              <TextInput
                style={styles.input}
                placeholder="Enter your password"
                placeholderTextColor="#9ca3af"
                value={password}
                onChangeText={(text) => {
                  setPassword(text);
                  if (errors.password) setErrors(prev => ({ ...prev, password: undefined }));
                }}
                secureTextEntry={!showPassword}
                autoComplete="password"
                editable={!isLoading}
              />
              <Pressable onPress={() => setShowPassword(!showPassword)} style={styles.eyeButton}>
                <Ionicons
                  name={showPassword ? 'eye-outline' : 'eye-off-outline'}
                  size={20}
                  color="#9ca3af"
                />
              </Pressable>
            </View>
            {errors.password && <Text style={styles.errorText}>{errors.password}</Text>}
          </View>

          {/* Forgot Password */}
          <Link href="/(auth)/forgot-password" asChild>
            <Pressable style={styles.forgotPassword}>
              <Text style={styles.forgotPasswordText}>Forgot Password?</Text>
            </Pressable>
          </Link>

          {/* Login Button */}
          <Pressable
            style={[styles.button, isLoading && styles.buttonDisabled]}
            onPress={handleLogin}
            disabled={isLoading}
          >
            {isLoading ? (
              <ActivityIndicator color="#fff" />
            ) : (
              <Text style={styles.buttonText}>Sign In</Text>
            )}
          </Pressable>
        </View>

        {/* Register Link */}
        <View style={styles.footer}>
          <Text style={styles.footerText}>Don't have an account? </Text>
          <Link href="/(auth)/register" asChild>
            <Pressable>
              <Text style={styles.linkText}>Sign Up</Text>
            </Pressable>
          </Link>
        </View>
      </View>
    </KeyboardAvoidingView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
  content: {
    flex: 1,
    padding: 24,
    justifyContent: 'center',
  },
  header: {
    marginBottom: 40,
  },
  title: {
    fontSize: 32,
    fontWeight: 'bold',
    color: '#111',
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 16,
    color: '#6b7280',
  },
  form: {
    marginBottom: 24,
  },
  inputGroup: {
    marginBottom: 20,
  },
  label: {
    fontSize: 14,
    fontWeight: '500',
    color: '#374151',
    marginBottom: 8,
  },
  inputContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    borderWidth: 1,
    borderColor: '#e5e7eb',
    borderRadius: 12,
    backgroundColor: '#f9fafb',
  },
  inputError: {
    borderColor: '#ef4444',
  },
  inputIcon: {
    marginLeft: 16,
  },
  input: {
    flex: 1,
    paddingVertical: 16,
    paddingHorizontal: 12,
    fontSize: 16,
    color: '#111',
  },
  eyeButton: {
    padding: 16,
  },
  errorText: {
    color: '#ef4444',
    fontSize: 12,
    marginTop: 4,
  },
  forgotPassword: {
    alignSelf: 'flex-end',
    marginBottom: 24,
  },
  forgotPasswordText: {
    color: '#6366f1',
    fontSize: 14,
    fontWeight: '500',
  },
  button: {
    backgroundColor: '#6366f1',
    paddingVertical: 16,
    borderRadius: 12,
    alignItems: 'center',
  },
  buttonDisabled: {
    backgroundColor: '#a5b4fc',
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
  footer: {
    flexDirection: 'row',
    justifyContent: 'center',
  },
  footerText: {
    color: '#6b7280',
    fontSize: 14,
  },
  linkText: {
    color: '#6366f1',
    fontSize: 14,
    fontWeight: '600',
  },
});

Registration Screen

// app/(auth)/register.tsx
import { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  Pressable,
  StyleSheet,
  ScrollView,
  KeyboardAvoidingView,
  Platform,
  Alert,
  ActivityIndicator,
} from 'react-native';
import { Link } from 'expo-router';
import { useAuth } from '@/context/AuthContext';
import { Ionicons } from '@expo/vector-icons';

export default function RegisterScreen() {
  const { register, isLoading } = useAuth();
  
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [showPassword, setShowPassword] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});

  const validate = (): boolean => {
    const newErrors: Record<string, string> = {};
    
    if (!name.trim()) {
      newErrors.name = 'Name is required';
    }
    
    if (!email) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = 'Please enter a valid email';
    }
    
    if (!password) {
      newErrors.password = 'Password is required';
    } else if (password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters';
    } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) {
      newErrors.password = 'Password must include uppercase, lowercase, and number';
    }
    
    if (password !== confirmPassword) {
      newErrors.confirmPassword = 'Passwords do not match';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleRegister = async () => {
    if (!validate()) return;
    
    try {
      await register({ name: name.trim(), email, password });
      // Navigation handled by auth state listener
    } catch (error) {
      Alert.alert(
        'Registration Failed',
        error instanceof Error ? error.message : 'Please try again'
      );
    }
  };

  return (
    <KeyboardAvoidingView
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      style={styles.container}
    >
      <ScrollView 
        contentContainerStyle={styles.scrollContent}
        keyboardShouldPersistTaps="handled"
      >
        {/* Header */}
        <View style={styles.header}>
          <Text style={styles.title}>Create Account</Text>
          <Text style={styles.subtitle}>Sign up to get started</Text>
        </View>

        {/* Form */}
        <View style={styles.form}>
          {/* Name Input */}
          <View style={styles.inputGroup}>
            <Text style={styles.label}>Full Name</Text>
            <View style={[styles.inputContainer, errors.name && styles.inputError]}>
              <Ionicons name="person-outline" size={20} color="#9ca3af" style={styles.inputIcon} />
              <TextInput
                style={styles.input}
                placeholder="Enter your name"
                placeholderTextColor="#9ca3af"
                value={name}
                onChangeText={setName}
                autoComplete="name"
                editable={!isLoading}
              />
            </View>
            {errors.name && <Text style={styles.errorText}>{errors.name}</Text>}
          </View>

          {/* Email Input */}
          <View style={styles.inputGroup}>
            <Text style={styles.label}>Email</Text>
            <View style={[styles.inputContainer, errors.email && styles.inputError]}>
              <Ionicons name="mail-outline" size={20} color="#9ca3af" style={styles.inputIcon} />
              <TextInput
                style={styles.input}
                placeholder="Enter your email"
                placeholderTextColor="#9ca3af"
                value={email}
                onChangeText={setEmail}
                keyboardType="email-address"
                autoCapitalize="none"
                autoComplete="email"
                editable={!isLoading}
              />
            </View>
            {errors.email && <Text style={styles.errorText}>{errors.email}</Text>}
          </View>

          {/* Password Input */}
          <View style={styles.inputGroup}>
            <Text style={styles.label}>Password</Text>
            <View style={[styles.inputContainer, errors.password && styles.inputError]}>
              <Ionicons name="lock-closed-outline" size={20} color="#9ca3af" style={styles.inputIcon} />
              <TextInput
                style={styles.input}
                placeholder="Create a password"
                placeholderTextColor="#9ca3af"
                value={password}
                onChangeText={setPassword}
                secureTextEntry={!showPassword}
                autoComplete="new-password"
                editable={!isLoading}
              />
              <Pressable onPress={() => setShowPassword(!showPassword)} style={styles.eyeButton}>
                <Ionicons
                  name={showPassword ? 'eye-outline' : 'eye-off-outline'}
                  size={20}
                  color="#9ca3af"
                />
              </Pressable>
            </View>
            {errors.password && <Text style={styles.errorText}>{errors.password}</Text>}
          </View>

          {/* Confirm Password */}
          <View style={styles.inputGroup}>
            <Text style={styles.label}>Confirm Password</Text>
            <View style={[styles.inputContainer, errors.confirmPassword && styles.inputError]}>
              <Ionicons name="lock-closed-outline" size={20} color="#9ca3af" style={styles.inputIcon} />
              <TextInput
                style={styles.input}
                placeholder="Confirm your password"
                placeholderTextColor="#9ca3af"
                value={confirmPassword}
                onChangeText={setConfirmPassword}
                secureTextEntry={!showPassword}
                editable={!isLoading}
              />
            </View>
            {errors.confirmPassword && <Text style={styles.errorText}>{errors.confirmPassword}</Text>}
          </View>

          {/* Register Button */}
          <Pressable
            style={[styles.button, isLoading && styles.buttonDisabled]}
            onPress={handleRegister}
            disabled={isLoading}
          >
            {isLoading ? (
              <ActivityIndicator color="#fff" />
            ) : (
              <Text style={styles.buttonText}>Create Account</Text>
            )}
          </Pressable>
        </View>

        {/* Login Link */}
        <View style={styles.footer}>
          <Text style={styles.footerText}>Already have an account? </Text>
          <Link href="/(auth)/login" asChild>
            <Pressable>
              <Text style={styles.linkText}>Sign In</Text>
            </Pressable>
          </Link>
        </View>
      </ScrollView>
    </KeyboardAvoidingView>
  );
}

// Styles similar to login screen...

Forgot Password Screen

// app/(auth)/forgot-password.tsx
import { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  Pressable,
  StyleSheet,
  Alert,
  ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { authApi } from '@/services/auth';
import { Ionicons } from '@expo/vector-icons';

export default function ForgotPasswordScreen() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [isSent, setIsSent] = useState(false);

  const handleSubmit = async () => {
    if (!email || !/\S+@\S+\.\S+/.test(email)) {
      Alert.alert('Invalid Email', 'Please enter a valid email address');
      return;
    }

    try {
      setIsLoading(true);
      await authApi.forgotPassword(email);
      setIsSent(true);
    } catch (error) {
      Alert.alert(
        'Request Failed',
        error instanceof Error ? error.message : 'Please try again'
      );
    } finally {
      setIsLoading(false);
    }
  };

  if (isSent) {
    return (
      <View style={styles.container}>
        <View style={styles.successContainer}>
          <View style={styles.iconCircle}>
            <Ionicons name="mail" size={40} color="#6366f1" />
          </View>
          <Text style={styles.successTitle}>Check Your Email</Text>
          <Text style={styles.successText}>
            We've sent password reset instructions to {email}
          </Text>
          <Pressable style={styles.button} onPress={() => router.back()}>
            <Text style={styles.buttonText}>Back to Login</Text>
          </Pressable>
        </View>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <View style={styles.content}>
        <Text style={styles.title}>Reset Password</Text>
        <Text style={styles.description}>
          Enter your email address and we'll send you instructions to reset your password.
        </Text>

        <View style={styles.inputContainer}>
          <Ionicons name="mail-outline" size={20} color="#9ca3af" style={styles.inputIcon} />
          <TextInput
            style={styles.input}
            placeholder="Enter your email"
            placeholderTextColor="#9ca3af"
            value={email}
            onChangeText={setEmail}
            keyboardType="email-address"
            autoCapitalize="none"
            autoComplete="email"
            editable={!isLoading}
          />
        </View>

        <Pressable
          style={[styles.button, isLoading && styles.buttonDisabled]}
          onPress={handleSubmit}
          disabled={isLoading}
        >
          {isLoading ? (
            <ActivityIndicator color="#fff" />
          ) : (
            <Text style={styles.buttonText}>Send Reset Link</Text>
          )}
        </Pressable>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
  content: {
    padding: 24,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#111',
    marginBottom: 8,
  },
  description: {
    fontSize: 14,
    color: '#6b7280',
    marginBottom: 24,
    lineHeight: 20,
  },
  inputContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    borderWidth: 1,
    borderColor: '#e5e7eb',
    borderRadius: 12,
    backgroundColor: '#f9fafb',
    marginBottom: 24,
  },
  inputIcon: {
    marginLeft: 16,
  },
  input: {
    flex: 1,
    paddingVertical: 16,
    paddingHorizontal: 12,
    fontSize: 16,
    color: '#111',
  },
  button: {
    backgroundColor: '#6366f1',
    paddingVertical: 16,
    borderRadius: 12,
    alignItems: 'center',
  },
  buttonDisabled: {
    backgroundColor: '#a5b4fc',
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
  successContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 24,
  },
  iconCircle: {
    width: 80,
    height: 80,
    borderRadius: 40,
    backgroundColor: '#e0e7ff',
    justifyContent: 'center',
    alignItems: 'center',
    marginBottom: 24,
  },
  successTitle: {
    fontSize: 24,
    fontWeight: 'bold',
    color: '#111',
    marginBottom: 8,
  },
  successText: {
    fontSize: 14,
    color: '#6b7280',
    textAlign: 'center',
    marginBottom: 32,
    lineHeight: 20,
  },
});

Session Management

Proper session management ensures security and a smooth user experience. This includes handling token expiration, background refresh, and logout scenarios.

Token Refresh Strategy

// services/api.ts
import { secureStorage } from './secureStorage';
import { authApi } from './auth';

const API_URL = process.env.EXPO_PUBLIC_API_URL;

let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];

function subscribeToTokenRefresh(callback: (token: string) => void) {
  refreshSubscribers.push(callback);
}

function onTokenRefreshed(token: string) {
  refreshSubscribers.forEach(callback => callback(token));
  refreshSubscribers = [];
}

export async function apiRequest<T>(
  endpoint: string,
  options: RequestInit = {}
): Promise<T> {
  const token = await secureStorage.getToken();

  const response = await fetch(`${API_URL}${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }),
      ...options.headers,
    },
  });

  // Handle 401 Unauthorized - token expired
  if (response.status === 401) {
    if (!isRefreshing) {
      isRefreshing = true;
      
      try {
        const refreshToken = await secureStorage.getRefreshToken();
        if (refreshToken) {
          const { token: newToken, refreshToken: newRefreshToken } = 
            await authApi.refreshToken(refreshToken);
          
          await secureStorage.setToken(newToken);
          if (newRefreshToken) {
            await secureStorage.setRefreshToken(newRefreshToken);
          }
          
          onTokenRefreshed(newToken);
          isRefreshing = false;
          
          // Retry original request with new token
          return apiRequest(endpoint, options);
        }
      } catch (error) {
        isRefreshing = false;
        // Refresh failed - logout user
        await secureStorage.clearAll();
        throw new Error('Session expired. Please login again.');
      }
    } else {
      // Wait for token refresh
      return new Promise((resolve, reject) => {
        subscribeToTokenRefresh(async (newToken) => {
          try {
            const result = await apiRequest<T>(endpoint, options);
            resolve(result);
          } catch (error) {
            reject(error);
          }
        });
      });
    }
  }

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.message || `Request failed: ${response.status}`);
  }

  return response.json();
}

Background Token Validation

// hooks/useSessionCheck.ts
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { useAuth } from '@/context/AuthContext';

export function useSessionCheck() {
  const { refreshSession, logout, isAuthenticated } = useAuth();
  const appState = useRef(AppState.currentState);

  useEffect(() => {
    if (!isAuthenticated) return;

    const subscription = AppState.addEventListener('change', handleAppStateChange);

    return () => {
      subscription.remove();
    };
  }, [isAuthenticated]);

  const handleAppStateChange = async (nextAppState: AppStateStatus) => {
    // App came to foreground
    if (
      appState.current.match(/inactive|background/) &&
      nextAppState === 'active'
    ) {
      // Validate session when app becomes active
      const refreshed = await refreshSession();
      if (!refreshed) {
        // Session invalid, logout
        await logout();
      }
    }
    
    appState.current = nextAppState;
  };
}

// Usage in root layout
export default function RootLayout() {
  useSessionCheck();
  
  return (/* ... */);
}

Logout Handler

// Complete logout implementation
import { useCallback } from 'react';
import { Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { useAuth } from '@/context/AuthContext';

export function useLogout() {
  const router = useRouter();
  const { logout } = useAuth();

  const handleLogout = useCallback(async () => {
    Alert.alert(
      'Logout',
      'Are you sure you want to logout?',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Logout',
          style: 'destructive',
          onPress: async () => {
            await logout();
            // Auth state listener will handle navigation
          },
        },
      ]
    );
  }, [logout]);

  const forceLogout = useCallback(async () => {
    await logout();
    Alert.alert('Session Expired', 'Please login again.');
  }, [logout]);

  return { handleLogout, forceLogout };
}
sequenceDiagram
    participant App
    participant AuthContext
    participant SecureStore
    participant API
    
    Note over App: App comes to foreground
    App->>AuthContext: refreshSession()
    AuthContext->>SecureStore: getRefreshToken()
    SecureStore-->>AuthContext: refreshToken
    AuthContext->>API: POST /auth/refresh
    
    alt Token refresh success
        API-->>AuthContext: { token, refreshToken }
        AuthContext->>SecureStore: setToken(newToken)
        AuthContext-->>App: true
        Note over App: Continue with new session
    else Token refresh failed
        API-->>AuthContext: 401 Unauthorized
        AuthContext->>SecureStore: clearAll()
        AuthContext-->>App: false
        App->>App: Navigate to login
    end
                

Hands-On Exercises

Exercise 1: Complete Auth Implementation

Build a complete authentication system with:

  • Login screen with email/password validation
  • Registration screen with password strength requirements
  • Protected routes that redirect to login
  • Persistent login across app restarts
πŸ’‘ Hint

Start with the AuthContext, then create the secure storage utilities. Set up your route groups (auth)/ and (app)/, then implement the auth state listener in the root layout. Finally, build the login and register screens using the context methods.

Exercise 2: Social Authentication

Add social login buttons to your auth screens:

  • Google Sign-In button
  • Apple Sign-In button (iOS)
  • Handle OAuth flow and token storage
βœ… Solution Approach
// Install dependencies
// npx expo install expo-auth-session expo-crypto expo-web-browser

// Example Google Sign-In
import * as Google from 'expo-auth-session/providers/google';
import * as WebBrowser from 'expo-web-browser';

WebBrowser.maybeCompleteAuthSession();

function GoogleSignInButton() {
  const [request, response, promptAsync] = Google.useAuthRequest({
    clientId: 'YOUR_WEB_CLIENT_ID',
    iosClientId: 'YOUR_IOS_CLIENT_ID',
    androidClientId: 'YOUR_ANDROID_CLIENT_ID',
  });

  useEffect(() => {
    if (response?.type === 'success') {
      const { authentication } = response;
      // Send token to your backend
      handleGoogleLogin(authentication.accessToken);
    }
  }, [response]);

  return (
    <Pressable 
      onPress={() => promptAsync()} 
      disabled={!request}
      style={styles.socialButton}
    >
      <Ionicons name="logo-google" size={20} />
      <Text>Continue with Google</Text>
    </Pressable>
  );
}

Exercise 3: Biometric Authentication

Add biometric login for returning users:

  • Check if biometrics are available
  • Enable "Login with Face ID/Fingerprint" option
  • Store encrypted credentials for biometric access
βœ… Solution Approach
// npx expo install expo-local-authentication

import * as LocalAuthentication from 'expo-local-authentication';

async function checkBiometricSupport() {
  const compatible = await LocalAuthentication.hasHardwareAsync();
  const enrolled = await LocalAuthentication.isEnrolledAsync();
  return compatible && enrolled;
}

async function authenticateWithBiometrics(): Promise<boolean> {
  const result = await LocalAuthentication.authenticateAsync({
    promptMessage: 'Login with biometrics',
    cancelLabel: 'Use password',
    fallbackLabel: 'Use password',
  });
  
  return result.success;
}

// In login screen
const [biometricsAvailable, setBiometricsAvailable] = useState(false);

useEffect(() => {
  checkBiometricSupport().then(setBiometricsAvailable);
}, []);

const handleBiometricLogin = async () => {
  const success = await authenticateWithBiometrics();
  if (success) {
    // Retrieve stored credentials and login
    const savedEmail = await secureStorage.get('biometric_email');
    const savedToken = await secureStorage.getToken();
    // Validate token or use saved credentials
  }
};

Exercise 4: Session Timeout

Implement automatic logout after inactivity:

  • Track user activity (taps, navigation)
  • Show warning before timeout
  • Logout after 15 minutes of inactivity
  • Allow user to extend session
πŸ’‘ Hint

Create an ActivityTracker context that resets a timer on any user interaction. Use PanResponder or wrap your app with a touch handler. Show a modal when 30 seconds remain, and call logout when the timer expires.

Summary

You've built a complete authentication system with secure storage, protected routes, and proper session management. Your app now handles login, registration, and logout flows professionally.

🎯 Key Takeaways

  • Secure Storage: Always use SecureStore for tokens, never plain AsyncStorage
  • Auth Context: Centralize auth state and methods for global access
  • Protected Routes: Use route groups and auth state listener for automatic redirects
  • Session Persistence: Load stored tokens on app start for seamless UX
  • Token Refresh: Handle 401 responses with automatic token refresh
  • Complete Logout: Clear all stored data and reset navigation state
  • Error Handling: Show user-friendly error messages for auth failures

πŸ” Auth Security Checklist

βœ“ Tokens stored in SecureStore (not AsyncStorage)
βœ“ HTTPS only for all API calls
βœ“ Passwords never stored locally
βœ“ Token validation on app start
βœ“ Automatic token refresh before expiration
βœ“ Complete cleanup on logout
βœ“ Session timeout for sensitive apps
βœ“ Input validation on all forms
βœ“ Error messages don't leak sensitive info

What's Next?

In the next lesson, we'll implement Deep Linkingβ€”allowing users to navigate directly to specific screens via URLs, push notifications, or external links.