This guide adds Firebase Authentication to an Expo app with the Firebase JavaScript SDK: sign-up, sign-in, password reset, sign-out, and sessions that survive app restarts. It works in Expo Go, needs no native code, and fixes two problems that trip up almost everyone who tries this: sessions that disappear on restart, and TypeScript not finding the React Native persistence API.
What I tested: a fresh Expo SDK 58 app (React Native 0.88) with Firebase JS SDK 13.0 and AsyncStorage 2.2. It type-checks, and an Android production bundle built with npx expo export included Firebase Auth’s React Native build with getReactNativePersistence. The sign-up, sign-in, error-handling and password-reset actions passed three tests against the Firebase Auth emulator. I did not run it on a phone or simulator for this post.
The 2024 version used the retired global expo-cli and expo init, installed the React Native Firebase native packages alongside the JavaScript SDK (two different SDKs that should not be mixed), and loaded config with dotenv, which Expo now handles itself.
JavaScript SDK or React Native Firebase?
| Firebase JS SDK (this guide) | React Native Firebase | |
|---|---|---|
| Packages | firebase | @react-native-firebase/app, /auth, … |
| Runs in Expo Go | Yes | No; needs a development build |
| Native config files | Not needed | google-services.json and GoogleService-Info.plist |
| Good for | Auth, Firestore, Storage and most app needs | Native-only features such as Crashlytics, Cloud Messaging, Analytics |
Pick one per project. If you later need push notifications, see Firebase Cloud Messaging in React Native, which uses React Native Firebase.
Step 1: Create the Firebase Project
- In the Firebase console, create a project and open Authentication > Sign-in method.
- Enable Email/Password.
- In Project settings > Your apps, add a Web app (the JS SDK uses the web config, even in a mobile app) and copy its values.
Step 2: Create the Expo App
npx create-expo-app@latest auth-demo --template blank-typescript
cd auth-demo
npx expo install firebase @react-native-async-storage/async-storageExpo loads .env files automatically and inlines variables that start with EXPO_PUBLIC_. Put your config in .env and commit only this example:
EXPO_PUBLIC_FIREBASE_API_KEY=
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
EXPO_PUBLIC_FIREBASE_PROJECT_ID=your-project
EXPO_PUBLIC_FIREBASE_APP_ID=Step 3: Initialize Firebase With Persistent Sessions
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth, getReactNativePersistence, initializeAuth, type Auth } from 'firebase/auth';
import AsyncStorage from '@react-native-async-storage/async-storage';
// EXPO_PUBLIC_ variables are inlined into the app at build time. The Firebase web
// config is not secret; your data is protected by Security Rules.
const firstInit = getApps().length === 0;
const app = firstInit
? initializeApp({
apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
})
: getApp();
// initializeAuth with AsyncStorage keeps users signed in across app restarts.
// (getAuth alone would keep the session in memory only.) On fast refresh the
// app already exists, so reuse its Auth instance instead of initializing twice.
export const auth: Auth = firstInit
? initializeAuth(app, { persistence: getReactNativePersistence(AsyncStorage) })
: getAuth(app);This is the most important file. In React Native, getAuth() alone keeps the session in memory, so users are signed out every time the app restarts. initializeAuth with getReactNativePersistence(AsyncStorage) saves the session to device storage. The getApps() check prevents “already initialized” errors when Expo’s fast refresh re-runs the module.
Fix: “has no exported member getReactNativePersistence”
With the default Expo TypeScript config, I got this error even though the function exists at runtime:
src/firebase.ts(2,10): error TS2305: Module '"firebase/auth"' has no exported member 'getReactNativePersistence'.The cause is in the package’s exports map: @firebase/auth lists its general types entry before its react-native entry, so TypeScript picks the web typings. At runtime, Metro resolves the React Native build correctly. Point TypeScript at the React Native typings in tsconfig.json:
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@firebase/auth": ["./node_modules/@firebase/auth/dist/index.rn.d.ts"]
}
}
}With that, npx tsc --noEmit passed, and the exported Android bundle contained getReactNativePersistence, confirming Metro used the React Native build.
Step 4: Auth Actions and Friendly Errors
import {
createUserWithEmailAndPassword,
sendPasswordResetEmail,
signInWithEmailAndPassword,
signOut,
updateProfile,
type Auth,
} from 'firebase/auth';
export async function signUp(auth: Auth, name: string, email: string, password: string) {
const { user } = await createUserWithEmailAndPassword(auth, email.trim(), password);
await updateProfile(user, { displayName: name.trim() });
return user;
}
export const signIn = (auth: Auth, email: string, password: string) =>
signInWithEmailAndPassword(auth, email.trim(), password);
export const resetPassword = (auth: Auth, email: string) => sendPasswordResetEmail(auth, email.trim());
export const logOut = (auth: Auth) => signOut(auth);
export function authErrorMessage(err: unknown): string {
switch ((err as { code?: string })?.code) {
case 'auth/invalid-credential':
case 'auth/wrong-password':
case 'auth/user-not-found':
return 'Email or password is incorrect.';
case 'auth/email-already-in-use':
return 'An account with this email already exists.';
case 'auth/weak-password':
return 'Password must be at least 6 characters.';
case 'auth/invalid-email':
return 'Enter a valid email address.';
case 'auth/network-request-failed':
return 'No connection. Check your internet and try again.';
case 'auth/too-many-requests':
return 'Too many attempts. Try again later.';
default:
return 'Something went wrong. Please try again.';
}
}Projects created since September 2023 report wrong passwords and unknown emails as one auth/invalid-credential error to prevent account enumeration; the emulator and older projects still use the separate codes, so all three map to the same message.
Step 5: Share the Signed-In User
A context exposes the current user and an initializing flag that stays true until the saved session has been restored, so the app does not flash the sign-in screen for users who are already signed in:
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { onAuthStateChanged, type User } from 'firebase/auth';
import { auth } from './firebase';
type AuthState = { user: User | null; initializing: boolean };
const AuthContext = createContext<AuthState>({ user: null, initializing: true });
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({ user: null, initializing: true });
// Fires once the saved session has been restored from AsyncStorage, then on every change.
useEffect(() => onAuthStateChanged(auth, user => setState({ user, initializing: false })), []);
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}
export const useAuth = () => useContext(AuthContext);More on this pattern in React Context for global state.
Step 6: The Screens
import { useState } from 'react';
import { ActivityIndicator, Button, StyleSheet, Text, TextInput, View } from 'react-native';
import { auth } from './firebase';
import { authErrorMessage, resetPassword, signIn, signUp } from './actions';
export function AuthScreen() {
const [mode, setMode] = useState<'signIn' | 'signUp'>('signIn');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setMessage(null);
try {
if (mode === 'signIn') await signIn(auth, email, password);
else await signUp(auth, name, email, password);
// No navigation needed: AuthProvider sees the new user and the app switches screens.
} catch (err) {
setMessage(authErrorMessage(err));
} finally {
setBusy(false);
}
};
const forgot = async () => {
if (!email) return setMessage('Enter your email first.');
await resetPassword(auth, email).catch(() => {});
setMessage('If an account exists for that email, a reset link is on its way.');
};
return (
<View style={styles.container}>
<Text style={styles.title}>{mode === 'signIn' ? 'Sign in' : 'Create account'}</Text>
{mode === 'signUp' && (
<TextInput style={styles.input} placeholder="Name" value={name} onChangeText={setName} autoComplete="name" />
)}
<TextInput style={styles.input} placeholder="Email" value={email} onChangeText={setEmail}
autoCapitalize="none" keyboardType="email-address" autoComplete="email" textContentType="emailAddress" />
<TextInput style={styles.input} placeholder="Password" value={password} onChangeText={setPassword}
secureTextEntry autoComplete={mode === 'signIn' ? 'current-password' : 'new-password'} />
{busy ? <ActivityIndicator /> : <Button title={mode === 'signIn' ? 'Sign in' : 'Sign up'} onPress={submit} />}
{mode === 'signIn' && <Button title="Forgot password?" onPress={forgot} />}
<Button
title={mode === 'signIn' ? 'New here? Create an account' : 'Have an account? Sign in'}
onPress={() => setMode(mode === 'signIn' ? 'signUp' : 'signIn')}
/>
{message && <Text style={styles.message}>{message}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
title: { fontSize: 24, fontWeight: '600' },
input: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 12 },
message: { color: '#374151' },
});import { ActivityIndicator, Button, StyleSheet, Text, View } from 'react-native';
import { AuthProvider, useAuth } from './src/AuthProvider';
import { AuthScreen } from './src/AuthScreen';
import { auth } from './src/firebase';
import { logOut } from './src/actions';
function Root() {
const { user, initializing } = useAuth();
if (initializing) return <ActivityIndicator style={styles.center} />;
if (!user) return <AuthScreen />;
return (
<View style={styles.center}>
<Text style={styles.hello}>Hello, {user.displayName ?? user.email}</Text>
<Button title="Sign out" onPress={() => logOut(auth)} />
</View>
);
}
export default function App() {
return (
<AuthProvider>
<Root />
</AuthProvider>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
hello: { fontSize: 20 },
});There is no navigation call after sign-in: when Firebase reports a user, Root renders the signed-in screen. In a larger app with Expo Router or React Navigation, use the same useAuth() state to choose between the signed-in and signed-out route groups.
Step 7: Test Against the Auth Emulator
The actions are plain functions that take an Auth instance, so you can test them in Node against the Firebase Auth emulator without a phone:
// The app's auth actions against the Firebase Auth emulator (Node build of the SDK).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { initializeApp } from 'firebase/app';
import { connectAuthEmulator, getAuth } from 'firebase/auth';
import { authErrorMessage, logOut, resetPassword, signIn, signUp } from '../src/actions';
const auth = getAuth(initializeApp({ apiKey: 'demo-key', projectId: 'demo-expo' }));
connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
const email = `expo${Date.now()}@example.com`;
test('sign up trims input and sets the display name', async () => {
const user = await signUp(auth, ' Ada ', ` ${email} `, 'secret123');
assert.equal(user.displayName, 'Ada');
assert.equal(user.email, email);
await logOut(auth);
assert.equal(auth.currentUser, null);
});
test('sign in, wrong password, duplicate account, weak password', async () => {
assert.equal((await signIn(auth, email, 'secret123')).user.email, email);
await logOut(auth);
assert.equal(authErrorMessage(await signIn(auth, email, 'nope').catch(e => e)), 'Email or password is incorrect.');
assert.equal(authErrorMessage(await signUp(auth, 'A', email, 'secret123').catch(e => e)), 'An account with this email already exists.');
assert.equal(authErrorMessage(await signUp(auth, 'B', `b${Date.now()}@example.com`, '123').catch(e => e)), 'Password must be at least 6 characters.');
});
test('password reset email is sent', async () => {
await resetPassword(auth, email);
const { oobCodes } = await (await fetch('http://127.0.0.1:9099/emulator/v1/projects/demo-expo/oobCodes')).json();
assert.ok(oobCodes.some((c: any) => c.email === email && c.requestType === 'PASSWORD_RESET'));
});$ npx firebase emulators:exec --only auth --project demo-expo "npx tsx --test scripts/actions.test.ts"
✔ sign up trims input and sets the display name
✔ sign in, wrong password, duplicate account, weak password
✔ password reset email is sent
ℹ pass 3To point the app itself at the emulator, call connectAuthEmulator(auth, 'http://10.0.2.2:9099') on the Android emulator (or your computer’s LAN IP on a real device) in development builds only.
Adding Google or Apple Sign-In
Social sign-in on mobile is different from the web, where signInWithPopup works. In an Expo app, sign in with the provider’s native flow first (for example @react-native-google-signin/google-signin, which needs a development build, or expo-apple-authentication on iOS), then pass the resulting ID token to Firebase with signInWithCredential. Check Apple’s App Store guidelines too: apps that offer third-party sign-in generally need to offer an equivalent privacy-focused option such as Sign in with Apple.
Conclusion
Firebase Auth in Expo comes down to one important detail and a few small pieces: initialize Auth with React Native persistence (and fix the TypeScript path), keep auth calls in a small actions module with friendly errors, and drive the UI from an onAuthStateChanged context. It all runs in Expo Go, and the logic can be tested against the emulator. For the web version of the same setup, see Firebase Authentication with React.
FAQs
Why are users signed out every time my Expo app restarts?
getAuth() on React Native keeps the session in memory. Initialize Auth with initializeAuth(app, { persistence: getReactNativePersistence(AsyncStorage) }) so the session is saved to device storage.
Why does TypeScript say getReactNativePersistence is not exported?
TypeScript picks the web typings from @firebase/auth’s exports map. Add a paths entry in tsconfig.json mapping @firebase/auth to its index.rn.d.ts typings; Metro already uses the React Native build at runtime.
Can I use Firebase Auth in Expo Go?
Yes, with the Firebase JavaScript SDK. React Native Firebase, by contrast, needs a development build because it includes native code.
Should I hide the Firebase config in an Expo app?
The web config identifies your project and is meant to ship in the app. Protect data with Firestore and Storage Security Rules, and never put service-account keys or other real secrets in EXPO_PUBLIC_ variables.
How do I add Google sign-in to an Expo app with Firebase?
Use a native Google sign-in library in a development build to get an ID token, then call signInWithCredential with GoogleAuthProvider.credential(idToken).
How do I test Firebase Auth in an Expo project?
Run the Firebase Auth emulator and test your auth functions in Node with a project ID starting with demo-, or connect the app to the emulator in development.








