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',
},
});
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.