📱 Set Up Firebase Phone Verification in a React Native App with Expo (Without Firebase Auto Login)
This guide outlines the steps to implement Firebase phone authentication (OTP verification) in a React Native application using Expo. The process includes:
- Setting up Firebase dependencies
- Creating a phone authentication context
- Integrating it into the app's navigation
- Configuring environment variables for Firebase
1. Install Dependencies
Install the required Firebase and other dependencies: Since I navigate between two screens, I use AsyncStorage to persist the phone number and avoid losing data during navigation.
bash1npm install @react-native-firebase/app@^22.2.1 @react-native-firebase/auth@^22.2.12npm install @react-native-async-storage/async-storage@2.1.2
2. Implement Phone Authentication Logic
2.1 Create PhoneAuthContext
Create a context to manage phone authentication state and methods like sending/validating OTPs and error handling.
📄 PhoneAuthContext.tsx (click to expand)
tsx1// PhoneAuthContext.tsx2import { storageKeys } from "@/constants/storage";3import { strings } from "@/constants/strings";4import {5 getStorageItem,6 removeStorageItem,7 setStorageItem,8} from "@/services/storage";9import { formatPhoneNumber } from "@/utils/phone";10import { showErrorToast, showSuccessToast } from "@/utils/toast";11import {12 deleteUser,13 getAuth,14 PhoneAuthProvider,15 signInWithCredential,16 signOut,17 verifyPhoneNumber,18} from "@react-native-firebase/auth";19import React, { createContext, ReactNode, useContext, useState } from "react";2021interface PhoneAuthContextType {22 // State23 isLoading: boolean;24 error: string | null;25 phoneNumber: string;2627 // Methods28 sendOTP: (phoneNumber: string) => Promise<boolean>;29 verifyOTP: (30 otp: string31 ) => Promise<{ verified: boolean; phoneNumber?: string }>;32 resendOTP: () => Promise<boolean>;33 clearOTPSession: () => void;34 clearError: () => void;35}3637const PhoneAuthContext = createContext<PhoneAuthContextType | undefined>(38 undefined39);4041interface PhoneAuthenticationProps {42 children: ReactNode;43}4445export const PhoneAuthenticationProvider: React.FC<46 PhoneAuthenticationProps47> = ({ children }) => {48 const [isLoading, setIsLoading] = useState(false);49 const [error, setError] = useState<string | null>(null);50 const [phoneNumber, setPhoneNumber] = useState("");5152 // Handle common firebase auth error53 const handleAuthError = (error: any) => {54 let errorMessage = "";5556 switch (error.code) {57 case "auth/invalid-phone-number":58 errorMessage = strings.alertAuthInvalidPhoneNumber;59 break;60 case "auth/too-many-requests":61 errorMessage = strings.tooManyRequests;62 break;63 case "auth/invalid-verification-code":64 errorMessage = strings.invalidOtp;65 break;66 case "auth/code-expired":67 errorMessage = strings.otpExpired;68 removeStorageItem(storageKeys.activeOTPSession);69 break;70 case "auth/missing-verification-code":71 errorMessage = strings.enterValidOtp;72 break;73 case "auth/session-expired":74 errorMessage = strings.otpExpired;75 removeStorageItem(storageKeys.activeOTPSession);76 break;77 default:78 errorMessage = error.message || strings.genericError;79 }8081 showErrorToast(errorMessage);82 setError(errorMessage);83 };8485 const sendOTP = async (phoneNumber: string): Promise<boolean> => {86 setIsLoading(true);87 setError(null);8889 try {90 // Accepts phoneNumber in any format; so must format to include country code (e.g., +84 for Vietnam)91 const formattedPhone = formatPhoneNumber(phoneNumber);9293 const auth = getAuth();9495 // Ensure a clean session by signing out any current user96 if (auth.currentUser) {97 await signOut(auth);98 }99100 // Clear any previous verification data101 removeStorageItem(storageKeys.verificationId);102 removeStorageItem(storageKeys.activeOTPSession);103104 // Send OTP using verifyPhoneNumber to avoid auto-retrieval and ensure manual code entry105 const confirmationResult = await verifyPhoneNumber(106 auth,107 formattedPhone,108 60109 );110111 if (!confirmationResult) {112 showErrorToast(strings.alertAuthInvalidPhoneNumber);113 setIsLoading(false);114 return false;115 }116117 // Extract verification ID from confirmation result118 const verificationId = confirmationResult.verificationId;119120 if (!verificationId) {121 throw new Error(strings.errorSendOTP);122 }123124 // Store verification data125 setStorageItem(storageKeys.verificationId, verificationId);126 setPhoneNumber(formattedPhone);127 setStorageItem(storageKeys.activeOTPSession, "true");128129 setIsLoading(false);130 showSuccessToast(strings.otpSent);131 return true;132 } catch (error: any) {133 handleAuthError(error);134 setIsLoading(false);135 return false;136 }137 };138139 const verifyOTP = async (140 otp: string141 ): Promise<{ verified: boolean; phoneNumber?: string }> => {142 // Get verificationId from storage and check if exits143 const verificationId = await getStorageItem(storageKeys.verificationId);144145 if (!verificationId) {146 showErrorToast(strings.noOtpSession);147 return { verified: false };148 }149150 setIsLoading(true);151 setError(null);152153 try {154 // Create phone credential using verification ID and OTP155 const phoneCredential = PhoneAuthProvider.credential(verificationId, otp);156157 // Sign in with the credential to verify it158 const auth = getAuth();159 const result = await signInWithCredential(auth, phoneCredential);160161 if (result && result.user) {162 // Store the verified phone number163 const verifiedPhoneNumber = result.user.phoneNumber || phoneNumber;164165 // Immediately delete the user since we only want to verify the phone number166 await deleteUser(result.user);167168 // Clear OTP session169 removeStorageItem(storageKeys.verificationId);170 removeStorageItem(storageKeys.activeOTPSession);171172 setIsLoading(false);173 showSuccessToast(strings.otpVerified);174175 return {176 verified: true,177 phoneNumber: verifiedPhoneNumber,178 };179 }180181 setIsLoading(false);182 return { verified: false };183 } catch (error: any) {184 handleAuthError(error);185 setIsLoading(false);186 return { verified: false };187 }188 };189190 const resendOTP = async (): Promise<boolean> => {191 if (!phoneNumber) {192 showErrorToast(strings.noPhoneNumber);193 return false;194 }195196 return await sendOTP(phoneNumber);197 };198199 const clearOTPSession = () => {200 removeStorageItem(storageKeys.verificationId);201 removeStorageItem(storageKeys.activeOTPSession);202 setPhoneNumber("");203 setError(null);204 };205206 const clearError = () => {207 setError(null);208 };209210 const value: PhoneAuthContextType = {211 isLoading,212 error,213 phoneNumber,214 sendOTP,215 verifyOTP,216 resendOTP,217 clearOTPSession,218 clearError,219 };220221 return (222 <PhoneAuthContext.Provider value={value}>223 {children}224 </PhoneAuthContext.Provider>225 );226};227228export const usePhoneAuth = (): PhoneAuthContextType => {229 const context = useContext(PhoneAuthContext);230 if (!context) {231 throw new Error("usePhoneAuth must be used within a PhoneAuthProvider");232 }233 return context;234};
2.2 Wrap Layout with PhoneAuthenticationProvider
Wrap your app’s auth layout to provide phone authentication context to all child screens.
tsx1// app/(auth)/_layout.tsx2import { PhoneAuthenticationProvider } from "@/contexts/PhoneAuthContext";3import { Stack } from "expo-router";45export default function AuthLayout() {6 return (7 <PhoneAuthenticationProvider>8 <Stack9 screenOptions={{10 headerShown: false,11 }}12 >13 <Stack.Screen name="onboarding" />14 <Stack.Screen name="phone-input" />15 <Stack.Screen name="otp-verify" />16 <Stack.Screen name="password" />17 </Stack>18 </PhoneAuthenticationProvider>19 );20}
2.3 Create Screen Components
2.3.1 PhoneInputScreen
Handles phone input and initiates the OTP sending process.
📄 PhoneInputScreen.tsx (click to expand)
tsx1// PhoneInputScreen.tsx2export default function PhoneInputScreen() {3 const router = useRouter();4 const { setPhoneNumber } = useAuthSession();5 const [phone, setPhone] = useState("");6 const { sendOTP, isLoading: loading, clearError } = usePhoneAuth();78 useEffect(() => {9 clearError();10 }, []);1112 const handleContinue = async () => {13 if (!phone.trim()) {14 showErrorToast(strings.alertMissingPhoneNumber);15 return;16 }1718 if (!validatePhoneNumber(phone)) {19 showErrorToast(strings.alertInvalidPhoneNumber);20 return;21 }2223 const success = await sendOTP(phone);24 if (success) {25 setPhoneNumber(phone);26 router.push("/(auth)/otp-verify");27 }28 };2930 return (31 <ThemedView color="background" className="flex-1 px-6 justify-center">32 <ThemedView className="mb-8">33 <ThemedText type="title" className="text-center mb-4">34 {strings.phoneInputTitle}35 </ThemedText>36 <ThemedText color="muted-foreground" className="text-center">37 {strings.phoneInputSubtitle}38 </ThemedText>39 </ThemedView>4041 <ThemedView className="mb-8">42 <ThemedText type="defaultSemiBold" className="mb-2">43 {strings.phoneInputLabel}44 </ThemedText>45 <ThemedTextInput46 placeholder={strings.phoneInputPlaceholder}47 value={phone}48 onChangeText={setPhone}49 keyboardType="phone-pad"50 autoComplete="tel"51 />52 </ThemedView>5354 <ThemedView className="space-y-4">55 <ThemedButton56 title={strings.phoneInputButtonContinue}57 onPress={handleContinue}58 loading={loading}59 size="lg"60 />61 </ThemedView>62 </ThemedView>63 );64}
2.3.2 OTPScreen
Handles OTP input, auto-focus, verify, and resend OTP logic.
📄 OTPScreen.tsx (click to expand)
tsx1// OTPScreen.tsx2import GoBackButton from "@/components/base/GoBackButton";3import { ThemedButton } from "@/components/base/ThemedButton";4import { ThemedText } from "@/components/base/ThemedText";5import { ThemedView } from "@/components/base/ThemedView";6import { storageKeys } from "@/constants/storage";7import { strings } from "@/constants/strings";8import { usePhoneAuth } from "@/contexts/PhoneAuthContext";9import { getStorageItem } from "@/services/storage";10import { useAuthSession } from "@/stores/auth";11import { showErrorToast } from "@/utils/toast";12import { useRouter } from "expo-router";13import { useEffect, useRef, useState } from "react";14import { TextInput } from "react-native";1516export default function OTPScreen() {17 const router = useRouter();18 const { phoneNumber } = useAuthSession();19 const [otp, setOtp] = useState(["", "", "", "", "", ""]);20 const [resendTimer, setResendTimer] = useState(60);21 const [isVerifying, setIsVerifying] = useState(false);22 const inputRefs = useRef<(TextInput | null)[]>([]);23 const {24 verifyOTP,25 resendOTP,26 isLoading: loading,27 clearError,28 } = usePhoneAuth();2930 useEffect(() => {31 const timer = setInterval(() => {32 setResendTimer((prev) => (prev > 0 ? prev - 1 : 0));33 }, 1000);3435 return () => clearInterval(timer);36 }, []);3738 useEffect(() => {39 clearError();40 }, []);4142 // Redirect to screen input phone number if no active OTP session43 useEffect(() => {44 async function checkOTPSession() {45 const activeOTPSession = await getStorageItem(46 storageKeys.activeOTPSession47 );4849 const checkSession = activeOTPSession === "true";50 if (!checkSession) {51 router.push("/(auth)/phone-input");52 }53 }54 checkOTPSession();55 }, [router]);5657 const handleOtpChange = (value: string, index: number) => {58 if (value.length > 1) return;5960 const newOtp = [...otp];61 newOtp[index] = value;62 setOtp(newOtp);6364 // Auto-focus next input65 if (value && index < 5) {66 inputRefs.current[index + 1]?.focus();67 }68 };6970 const handleKeyPress = (key: string, index: number) => {71 if (key === "Backspace" && !otp[index] && index > 0) {72 inputRefs.current[index - 1]?.focus();73 }74 };7576 const handleVerify = async () => {77 if (isVerifying) return; // Prevent multiple simultaneous verifications7879 const otpString = otp.join("");80 if (otpString.length !== 6) {81 showErrorToast(strings.enterValidOtp);82 return;83 }8485 setIsVerifying(true);8687 try {88 const { verified } = await verifyOTP(otpString);8990 if (verified) {91 router.push("/(auth)/password");92 }93 } catch (error) {94 showErrorToast(strings.genericError);95 } finally {96 setIsVerifying(false);97 }98 };99100 const handleResend = async () => {101 if (resendTimer > 0) return;102103 const success = await resendOTP();104 if (success) {105 setResendTimer(60);106 setOtp(["", "", "", "", "", ""]);107 setIsVerifying(false);108 inputRefs.current[0]?.focus();109 }110 };111112 const handlePaste = (pastedText: string) => {113 const cleanedText = pastedText.replace(/\D/g, ""); // Remove non-digits114 if (cleanedText.length === 6) {115 const otpArray = cleanedText.split("");116 setOtp(otpArray);117 return true;118 }119 return false;120 };121122 return (123 <ThemedView color="background" className="flex-1 px-6 justify-center">124 <GoBackButton />125 <ThemedView className="mb-8">126 <ThemedText type="title" className="text-center mb-4">127 {strings.otpTitle}128 </ThemedText>129 <ThemedText color="muted-foreground" className="text-center">130 {strings.otpSubtitle} {phoneNumber}131 </ThemedText>132 </ThemedView>133134 <ThemedView className="mb-8">135 <ThemedText type="defaultSemiBold" className="mb-4 text-center">136 {strings.otpInputLabel}137 </ThemedText>138139 <ThemedView className="flex-row justify-center space-x-3 gap-3">140 {otp.map((digit, index) => (141 <TextInput142 key={index}143 ref={(ref) => {144 inputRefs.current[index] = ref;145 }}146 className="w-12 border border-border rounded-lg text-center text-lg font-semibold text-foreground"147 value={digit}148 onChangeText={(value) => {149 if (value.length > 1) {150 if (handlePaste(value)) {151 return;152 }153 }154 handleOtpChange(value, index);155 }}156 onKeyPress={({ nativeEvent }) =>157 handleKeyPress(nativeEvent.key, index)158 }159 keyboardType="numeric"160 maxLength={1}161 selectTextOnFocus162 />163 ))}164 </ThemedView>165 </ThemedView>166167 <ThemedView className="gap-3">168 <ThemedButton169 title={strings.otpContinue}170 onPress={handleVerify}171 loading={loading || isVerifying}172 size="lg"173 />174175 <ThemedView className="flex-row justify-between items-center">176 <ThemedText color="muted-foreground">177 {strings.noReceivedOtp}178 </ThemedText>179 <ThemedButton180 title={181 resendTimer > 0182 ? `${strings.otpResendIn} ${resendTimer}s`183 : strings.otpResend184 }185 variant="outline"186 onPress={handleResend}187 disabled={resendTimer > 0}188 className="p-0 bg-transparent border-0"189 />190 </ThemedView>191 </ThemedView>192 </ThemedView>193 );194}
3. Configure Environment Variables
3.1 Add GOOGLE_SERVICES_JSON
Option 1: Expo dashboard
-
Go to:
https://expo.dev/accounts/<your-username>/projects/<project-name>/environment-variables -
Add a variable:
- Name:
GOOGLE_SERVICES_JSON - Value: your
google-services.jsonfile - Type: File
- Scope: Project
- Visibility: Secret
- Environments: development, preview, production
- Name:
Option 2: Terminal
bash1eas env:create --scope project --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json
3.2 Pull Environment Variables
bash1eas env:pull --environment development
3.3 Update app.json → app.config.js
Rename app.json to app.config.js and set Firebase plugins.
js1// app.config.js2export default {3 expo: {4 android: {5 googleServicesFile: process.env.GOOGLE_SERVICES_JSON,6 },7 plugins: [8 "expo-router",9 "@react-native-firebase/app",10 "@react-native-firebase/auth",11 ],12 },13};
3.4 Build the App
Build Android app for production (or development):
If you're using EAS, make sure to rebuild the app whenever you install native dependencies (e.g., Firebase, React Native modules), as they require a new native build to take effect.
bash1eas build -p android --profile production
✅ Summary
This guide covers the full implementation of Firebase Phone Authentication in an Expo-managed React Native project:
- 🔧 Installed Firebase and state dependencies
- 📦 Set up
PhoneAuthContextwith OTP methods and error handling - 💡 Integrated context provider into navigation layout
- 📱 Created screens for phone input and OTP verification
- 🔐 Configured environment variables for secure Firebase auth
- 🚀 Built production-ready APK with
eas build