📱 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:


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.

bash
1npm install @react-native-firebase/app@^22.2.1 @react-native-firebase/auth@^22.2.1
2npm 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)
tsx
1// PhoneAuthContext.tsx
2import { 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";
20
21interface PhoneAuthContextType {
22 // State
23 isLoading: boolean;
24 error: string | null;
25 phoneNumber: string;
26
27 // Methods
28 sendOTP: (phoneNumber: string) => Promise<boolean>;
29 verifyOTP: (
30 otp: string
31 ) => Promise<{ verified: boolean; phoneNumber?: string }>;
32 resendOTP: () => Promise<boolean>;
33 clearOTPSession: () => void;
34 clearError: () => void;
35}
36
37const PhoneAuthContext = createContext<PhoneAuthContextType | undefined>(
38 undefined
39);
40
41interface PhoneAuthenticationProps {
42 children: ReactNode;
43}
44
45export const PhoneAuthenticationProvider: React.FC<
46 PhoneAuthenticationProps
47> = ({ children }) => {
48 const [isLoading, setIsLoading] = useState(false);
49 const [error, setError] = useState<string | null>(null);
50 const [phoneNumber, setPhoneNumber] = useState("");
51
52 // Handle common firebase auth error
53 const handleAuthError = (error: any) => {
54 let errorMessage = "";
55
56 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 }
80
81 showErrorToast(errorMessage);
82 setError(errorMessage);
83 };
84
85 const sendOTP = async (phoneNumber: string): Promise<boolean> => {
86 setIsLoading(true);
87 setError(null);
88
89 try {
90 // Accepts phoneNumber in any format; so must format to include country code (e.g., +84 for Vietnam)
91 const formattedPhone = formatPhoneNumber(phoneNumber);
92
93 const auth = getAuth();
94
95 // Ensure a clean session by signing out any current user
96 if (auth.currentUser) {
97 await signOut(auth);
98 }
99
100 // Clear any previous verification data
101 removeStorageItem(storageKeys.verificationId);
102 removeStorageItem(storageKeys.activeOTPSession);
103
104 // Send OTP using verifyPhoneNumber to avoid auto-retrieval and ensure manual code entry
105 const confirmationResult = await verifyPhoneNumber(
106 auth,
107 formattedPhone,
108 60
109 );
110
111 if (!confirmationResult) {
112 showErrorToast(strings.alertAuthInvalidPhoneNumber);
113 setIsLoading(false);
114 return false;
115 }
116
117 // Extract verification ID from confirmation result
118 const verificationId = confirmationResult.verificationId;
119
120 if (!verificationId) {
121 throw new Error(strings.errorSendOTP);
122 }
123
124 // Store verification data
125 setStorageItem(storageKeys.verificationId, verificationId);
126 setPhoneNumber(formattedPhone);
127 setStorageItem(storageKeys.activeOTPSession, "true");
128
129 setIsLoading(false);
130 showSuccessToast(strings.otpSent);
131 return true;
132 } catch (error: any) {
133 handleAuthError(error);
134 setIsLoading(false);
135 return false;
136 }
137 };
138
139 const verifyOTP = async (
140 otp: string
141 ): Promise<{ verified: boolean; phoneNumber?: string }> => {
142 // Get verificationId from storage and check if exits
143 const verificationId = await getStorageItem(storageKeys.verificationId);
144
145 if (!verificationId) {
146 showErrorToast(strings.noOtpSession);
147 return { verified: false };
148 }
149
150 setIsLoading(true);
151 setError(null);
152
153 try {
154 // Create phone credential using verification ID and OTP
155 const phoneCredential = PhoneAuthProvider.credential(verificationId, otp);
156
157 // Sign in with the credential to verify it
158 const auth = getAuth();
159 const result = await signInWithCredential(auth, phoneCredential);
160
161 if (result && result.user) {
162 // Store the verified phone number
163 const verifiedPhoneNumber = result.user.phoneNumber || phoneNumber;
164
165 // Immediately delete the user since we only want to verify the phone number
166 await deleteUser(result.user);
167
168 // Clear OTP session
169 removeStorageItem(storageKeys.verificationId);
170 removeStorageItem(storageKeys.activeOTPSession);
171
172 setIsLoading(false);
173 showSuccessToast(strings.otpVerified);
174
175 return {
176 verified: true,
177 phoneNumber: verifiedPhoneNumber,
178 };
179 }
180
181 setIsLoading(false);
182 return { verified: false };
183 } catch (error: any) {
184 handleAuthError(error);
185 setIsLoading(false);
186 return { verified: false };
187 }
188 };
189
190 const resendOTP = async (): Promise<boolean> => {
191 if (!phoneNumber) {
192 showErrorToast(strings.noPhoneNumber);
193 return false;
194 }
195
196 return await sendOTP(phoneNumber);
197 };
198
199 const clearOTPSession = () => {
200 removeStorageItem(storageKeys.verificationId);
201 removeStorageItem(storageKeys.activeOTPSession);
202 setPhoneNumber("");
203 setError(null);
204 };
205
206 const clearError = () => {
207 setError(null);
208 };
209
210 const value: PhoneAuthContextType = {
211 isLoading,
212 error,
213 phoneNumber,
214 sendOTP,
215 verifyOTP,
216 resendOTP,
217 clearOTPSession,
218 clearError,
219 };
220
221 return (
222 <PhoneAuthContext.Provider value={value}>
223 {children}
224 </PhoneAuthContext.Provider>
225 );
226};
227
228export 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.

tsx
1// app/(auth)/_layout.tsx
2import { PhoneAuthenticationProvider } from "@/contexts/PhoneAuthContext";
3import { Stack } from "expo-router";
4
5export default function AuthLayout() {
6 return (
7 <PhoneAuthenticationProvider>
8 <Stack
9 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)
tsx
1// PhoneInputScreen.tsx
2export default function PhoneInputScreen() {
3 const router = useRouter();
4 const { setPhoneNumber } = useAuthSession();
5 const [phone, setPhone] = useState("");
6 const { sendOTP, isLoading: loading, clearError } = usePhoneAuth();
7
8 useEffect(() => {
9 clearError();
10 }, []);
11
12 const handleContinue = async () => {
13 if (!phone.trim()) {
14 showErrorToast(strings.alertMissingPhoneNumber);
15 return;
16 }
17
18 if (!validatePhoneNumber(phone)) {
19 showErrorToast(strings.alertInvalidPhoneNumber);
20 return;
21 }
22
23 const success = await sendOTP(phone);
24 if (success) {
25 setPhoneNumber(phone);
26 router.push("/(auth)/otp-verify");
27 }
28 };
29
30 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>
40
41 <ThemedView className="mb-8">
42 <ThemedText type="defaultSemiBold" className="mb-2">
43 {strings.phoneInputLabel}
44 </ThemedText>
45 <ThemedTextInput
46 placeholder={strings.phoneInputPlaceholder}
47 value={phone}
48 onChangeText={setPhone}
49 keyboardType="phone-pad"
50 autoComplete="tel"
51 />
52 </ThemedView>
53
54 <ThemedView className="space-y-4">
55 <ThemedButton
56 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)
tsx
1// OTPScreen.tsx
2import 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";
15
16export 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();
29
30 useEffect(() => {
31 const timer = setInterval(() => {
32 setResendTimer((prev) => (prev > 0 ? prev - 1 : 0));
33 }, 1000);
34
35 return () => clearInterval(timer);
36 }, []);
37
38 useEffect(() => {
39 clearError();
40 }, []);
41
42 // Redirect to screen input phone number if no active OTP session
43 useEffect(() => {
44 async function checkOTPSession() {
45 const activeOTPSession = await getStorageItem(
46 storageKeys.activeOTPSession
47 );
48
49 const checkSession = activeOTPSession === "true";
50 if (!checkSession) {
51 router.push("/(auth)/phone-input");
52 }
53 }
54 checkOTPSession();
55 }, [router]);
56
57 const handleOtpChange = (value: string, index: number) => {
58 if (value.length > 1) return;
59
60 const newOtp = [...otp];
61 newOtp[index] = value;
62 setOtp(newOtp);
63
64 // Auto-focus next input
65 if (value && index < 5) {
66 inputRefs.current[index + 1]?.focus();
67 }
68 };
69
70 const handleKeyPress = (key: string, index: number) => {
71 if (key === "Backspace" && !otp[index] && index > 0) {
72 inputRefs.current[index - 1]?.focus();
73 }
74 };
75
76 const handleVerify = async () => {
77 if (isVerifying) return; // Prevent multiple simultaneous verifications
78
79 const otpString = otp.join("");
80 if (otpString.length !== 6) {
81 showErrorToast(strings.enterValidOtp);
82 return;
83 }
84
85 setIsVerifying(true);
86
87 try {
88 const { verified } = await verifyOTP(otpString);
89
90 if (verified) {
91 router.push("/(auth)/password");
92 }
93 } catch (error) {
94 showErrorToast(strings.genericError);
95 } finally {
96 setIsVerifying(false);
97 }
98 };
99
100 const handleResend = async () => {
101 if (resendTimer > 0) return;
102
103 const success = await resendOTP();
104 if (success) {
105 setResendTimer(60);
106 setOtp(["", "", "", "", "", ""]);
107 setIsVerifying(false);
108 inputRefs.current[0]?.focus();
109 }
110 };
111
112 const handlePaste = (pastedText: string) => {
113 const cleanedText = pastedText.replace(/\D/g, ""); // Remove non-digits
114 if (cleanedText.length === 6) {
115 const otpArray = cleanedText.split("");
116 setOtp(otpArray);
117 return true;
118 }
119 return false;
120 };
121
122 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>
133
134 <ThemedView className="mb-8">
135 <ThemedText type="defaultSemiBold" className="mb-4 text-center">
136 {strings.otpInputLabel}
137 </ThemedText>
138
139 <ThemedView className="flex-row justify-center space-x-3 gap-3">
140 {otp.map((digit, index) => (
141 <TextInput
142 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 selectTextOnFocus
162 />
163 ))}
164 </ThemedView>
165 </ThemedView>
166
167 <ThemedView className="gap-3">
168 <ThemedButton
169 title={strings.otpContinue}
170 onPress={handleVerify}
171 loading={loading || isVerifying}
172 size="lg"
173 />
174
175 <ThemedView className="flex-row justify-between items-center">
176 <ThemedText color="muted-foreground">
177 {strings.noReceivedOtp}
178 </ThemedText>
179 <ThemedButton
180 title={
181 resendTimer > 0
182 ? `${strings.otpResendIn} ${resendTimer}s`
183 : strings.otpResend
184 }
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

Option 2: Terminal

bash
1eas env:create --scope project --name GOOGLE_SERVICES_JSON --type file --value ./google-services.json

3.2 Pull Environment Variables

bash
1eas env:pull --environment development

3.3 Update app.jsonapp.config.js

Rename app.json to app.config.js and set Firebase plugins.

js
1// app.config.js
2export 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.

bash
1eas build -p android --profile production

✅ Summary

This guide covers the full implementation of Firebase Phone Authentication in an Expo-managed React Native project: