Crafting a Robust Authentication System in React Native with Clerk and Firebase
Table of contents
- Why This Combo? ๐ค
- Setting Up Our Project ๐ ๏ธ
- Project File Structure: Your Complete Guide ๐๏ธ
- Environment Setup: The Secure Way ๐
- Firebase Setup: Our Backend Foundation ๐๏ธ
- Our Auth Hook: The Bridge to Firebase ๐
- Secure Token Storage: Our Digital Vault ๐
- The Root Layout: Putting It All Together ๐๏ธ
- Auth Layout: The Gatekeeper ๐ช
- The Sign-In Screen:
- The Sign-Up Screen: Rolling Out the Welcome Mat ๐
- Let's Break Down What's Happening! ๐
Hey there! ๐ I'm excited to walk you through building a rock-solid authentication system for your React Native app. This is the exact setup I use in production, and I'll share all the little details that took me days to figure out. Let's dive in!
Why This Combo? ๐ค
Before we write any code, let me explain why we're using both Clerk and Firebase. It's like building a house - you need both a security system (Clerk) and a storage room (Firebase):
Clerk handles:
User authentication (like your front door's smart lock)
Password resets (when someone forgets their key)
Email verification (making sure people are who they say they are)
Social logins (multiple ways to get in)
Firebase provides:
User data storage (like your filing cabinet)
Real-time updates (instant notifications)
File storage (keeping user photos and files)
Scalable infrastructure (grows with your app)
Setting Up Our Project ๐ ๏ธ
First, let's create our project and install everything we need:
# Create a new project with TypeScript
npx create-expo-app@latest my-auth-app -t tabs
cd my-auth-app
# Install our essential packages
npm install @clerk/clerk-expo firebase expo-secure-store expo-web-browser
# Install dependancy using expo
npx expo install react-dom react-native-web @expo/metro-runtime
Pro tip: While this installs (it might take a minute), let me explain what each package does:
@clerk/clerk-expo
: Our main authentication systemfirebase
: Our backend servicesexpo-secure-store
: For securely storing tokensexpo-web-browser
: Handles OAuth flows for social logins
Project File Structure: Your Complete Guide ๐๏ธ
Let's map out exactly how our authentication project is organized. I'll break it down piece by piece, explaining what goes where and why!
my-auth-app/
โโโ app/ # Main app directory (Expo Router)
โ โโโ _layout.tsx # Root layout with Clerk setup
โ โโโ (auth)/ # Authentication group
โ โ โโโ _layout.tsx # Auth layout (redirect logic)
โ โ โโโ sign-in.tsx # Sign in screen
โ โ โโโ sign-up.tsx # Sign up screen with verification
โ โโโ (tabs)/ # Main app tabs (post-auth)
โ โโโ _layout.tsx # Tab navigation layout
โ
โโโ utils/ # Utility functions
โ โโโ firebase.ts # Firebase initialization & functions
โ โโโ firebase-auth.ts # Firebase authentication hook
โ โโโ useFirebaseUser.ts # Firebase user management
โ โโโ tokenCache.ts # Secure token storage logic
โ
โโโ types/ # TypeScript type definitions
โ โโโ user.ts # User-related types
โ
โโโ .env # Environment variables (git-ignored)
โโโ .env.example # Example env file (safe to commit)
Environment Setup: The Secure Way ๐
Create a .env
file in your project root. This is where we'll keep all our secret keys:
# Clerk configuration
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_key_here
# Firebase configuration
EXPO_PUBLIC_FIREBASE_API_KEY=your_firebase_key
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=your_domain
EXPO_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=your_bucket
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
EXPO_PUBLIC_FIREBASE_APP_ID=your_app_id
๐จ Important Security Steps:
Add
.env
to your.gitignore
Never commit this file to version control
Keep a
.env.example
with dummy values for your team
Firebase Setup: Our Backend Foundation ๐๏ธ
Let's set up Firebase. Create utils/firebase.ts
:
// Firebase initialization will go here
import { initializeApp, getApps } from "firebase/app";
import { getFirestore, doc, setDoc, getDoc } from "firebase/firestore";
const firebaseConfig = {
apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
};
type UserProfile = {
userId: string;
username: string;
email: string;
profileImage: string;
};
// Prevent multiple Firebase initializations
export const app = !getApps().length
? initializeApp(firebaseConfig)
: getApps()[0];
export const db = getFirestore(app);
// User profile management functions
export async function updateUserProfile(userProfile: UserProfile) {
const userRef = doc(db, "users", userProfile.userId);
await setDoc(userRef, userProfile, { merge: true });
}
export async function getUserProfile(
userId: string
): Promise<UserProfile | null> {
const userRef = doc(db, "users", userId);
const userDoc = await getDoc(userRef);
return userDoc.exists() ? (userDoc.data() as UserProfile) : null;
}
Let's break down what's happening here:
We're initializing Firebase (but only once!)
Setting up Firestore (our database)
Creating helper functions for user profiles
Using TypeScript for better type safety
Our Auth Hook: The Bridge to Firebase ๐
Create utils/firebase-auth.ts
:
import { getAuth, signInWithCustomToken } from 'firebase/auth';
import { app } from './firebase';
import { useAuth } from '@clerk/clerk-expo';
const auth = getAuth(app);
export const useFirebaseAuth = () => {
const { getToken } = useAuth();
const signInWithClerk = async () => {
try {
// Get a special token from Clerk that Firebase understands
const token = await getToken({ template: 'integration_firebase' });
if (!token) {
throw new Error('No Firebase token available');
}
// Use that token to sign in to Firebase
const userCredential = await signInWithCustomToken(auth, token);
return userCredential.user;
} catch (error) {
console.error('Firebase authentication error:', error);
throw error;
}
};
return {
auth,
signInWithClerk,
};
};
Here's what this hook does:
Gets a special token from Clerk
Uses that token to sign in to Firebase
Handles any errors that might occur
Returns the Firebase user object
create utils/useFirebaseUser.ts
:
import { useState, useEffect } from 'react';
import { User } from 'firebase/auth';
import { useFirebaseAuth } from './firebase-auth';
export const useFirebaseUser = () => {
const { auth, signInWithClerk } = useFirebaseAuth();
const [firebaseUser, setFirebaseUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
setFirebaseUser(user);
setLoading(false);
});
return () => unsubscribe();
}, [auth]);
const signIn = async () => {
try {
setLoading(true);
const user = await signInWithClerk();
return user;
} finally {
setLoading(false);
}
};
return {
firebaseUser,
loading,
signIn,
};
};
Secure Token Storage: Our Digital Vault ๐
First, let's create a secure place to store our authentication tokens. Think of this as a digital safe for your users' keys.
Create utils/tokenCache.ts
:
import * as SecureStore from 'expo-secure-store';
export const tokenCache = {
async getToken(key: string) {
try {
const item = await SecureStore.getItemAsync(key);
// A little logging to help with debugging
if (item) {
console.log(`${key} was used ๐`);
} else {
console.log('No values stored under key: ' + key);
}
return item;
} catch (error) {
console.error('SecureStore get item error: ', error);
// If something goes wrong, better clear that token
await SecureStore.deleteItemAsync(key);
return null;
}
},
async saveToken(key: string, value: string) {
try {
return SecureStore.setItemAsync(key, value);
} catch (err) {
// Silent fail but you might want to log this in production
return;
}
},
};
What's cool about this setup?
Uses
expo-secure-store
which encrypts data on deviceAutomatically handles token cleanup if something goes wrong
Includes helpful logging for debugging
Fails gracefully if something breaks
The Root Layout: Putting It All Together ๐๏ธ
Now, let's set up our root layout. This is where everything comes together.
Create app/_layout.tsx
:
import { ClerkProvider, ClerkLoaded } from '@clerk/clerk-expo';
import { Slot } from 'expo-router';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { tokenCache } from '../utils/tokenCache';
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!;
if (!publishableKey) {
throw new Error(
'Missing Publishable Key. Please set EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY in your .env'
);
}
export default function RootLayout() {
return (
<SafeAreaProvider>
<ClerkProvider tokenCache={tokenCache} publishableKey={publishableKey}>
<ClerkLoaded>
<Slot />
</ClerkLoaded>
</ClerkProvider>
</SafeAreaProvider>
);
}
The magic here is:
We wrap everything in
ClerkProvider
- it's like the security system for our whole appClerkLoaded
makes sure auth is ready before showing anythingSafeAreaProvider
keeps everything looking good on different devices
Auth Layout: The Gatekeeper ๐ช
Let's create our auth layout that handles routing based on authentication status.
Create app/(auth)/_layout.tsx
:
import { Redirect, Slot } from 'expo-router';
import { useAuth } from '@clerk/clerk-expo';
export default function AuthRoutesLayout() {
const { isSignedIn } = useAuth();
// If they're signed in, send them to the main app
if (isSignedIn) {
return <Redirect href="/(tabs)" />;
}
// Otherwise, show auth screens
return <Slot />;
}
The Sign-In Screen:
For the fun part - let's create our sign-in screen!
Create app/(auth)/sign-in.tsx
:
import { useState } from "react";
import {
View,
TextInput,
Text,
StyleSheet,
TouchableOpacity,
} from "react-native";
import { useRouter } from "expo-router";
import { useSignIn } from "@clerk/clerk-expo";
export default function SignInScreen() {
const router = useRouter();
const { signIn, setActive } = useSignIn();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleSignIn() {
if (!signIn) return;
try {
const result = await signIn.create({ identifier: email, password });
if (result.status === "complete" && setActive) {
await setActive({ session: result.createdSessionId });
router.replace("/(tabs)");
}
} catch (err: any) {
alert("Invalid email or password");
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome Back!</Text>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity style={styles.button} onPress={handleSignIn}>
<Text style={styles.buttonText}>Sign In</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push("/sign-up")}>
<Text style={styles.link}>Don't have an account? Sign up</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
justifyContent: "center",
},
title: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 24,
textAlign: "center",
color: "#1a1a1a",
},
input: {
borderWidth: 1,
borderColor: "#e1e1e1",
padding: 16,
marginBottom: 16,
borderRadius: 12,
backgroundColor: "#f5f5f5",
fontSize: 16,
},
button: {
backgroundColor: "#007AFF",
padding: 16,
borderRadius: 12,
alignItems: "center",
marginTop: 8,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
link: {
color: "#007AFF",
textAlign: "center",
marginTop: 16,
fontSize: 15,
},
});
Let's break down what's happening:
We use Clerk's
useSignIn
hook to handle authenticationLoading states show users when something's happening
Error handling gives friendly messages
Navigation redirects users after successful sign-in
The Sign-Up Screen: Rolling Out the Welcome Mat ๐
let's create our sign-up screen. This is where new users will start their journey:
Create app/(auth)/sign-up.tsx
:
import { useState } from "react";
import {
View,
TextInput,
Text,
StyleSheet,
TouchableOpacity,
} from "react-native";
import { useRouter } from "expo-router";
import { useSignUp } from "@clerk/clerk-expo";
import { updateUserProfile } from "@/utils/firebase";
import { useFirebaseUser } from "@/utils/useFirebaseUser";
export default function SignUpScreen() {
const { signUp, setActive } = useSignUp();
const { signIn: signInToFirebase } = useFirebaseUser();
const router = useRouter();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [pending, setPending] = useState(false);
async function handleSignUp() {
try {
await signUp?.create({ username, emailAddress: email, password });
await signUp?.prepareEmailAddressVerification({
strategy: "email_code",
});
setPending(true);
} catch (err) {
alert("Error creating account");
}
}
async function handleVerify() {
if (!signUp || !setActive) return;
try {
const result = await signUp.attemptEmailAddressVerification({
code,
});
if (result?.status === "complete") {
await signInToFirebase();
await updateUserProfile({
userId: result.createdUserId!,
username,
email,
profileImage: "",
});
await setActive({ session: result.createdSessionId });
router.replace("/(tabs)");
}
} catch (err) {
alert("Invalid verification code");
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>
{pending ? "Check your email" : "Create Account"}
</Text>
{!pending ? (
<>
<TextInput
style={styles.input}
placeholder="Username"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={styles.button}
onPress={handleSignUp}
>
<Text style={styles.buttonText}>Sign Up</Text>
</TouchableOpacity>
</>
) : (
<>
<TextInput
style={styles.input}
placeholder="Verification Code"
value={code}
onChangeText={setCode}
keyboardType="number-pad"
/>
<TouchableOpacity
style={styles.button}
onPress={handleVerify}
>
<Text style={styles.buttonText}>Verify Email</Text>
</TouchableOpacity>
</>
)}
<TouchableOpacity onPress={() => router.push("/sign-in")}>
<Text style={styles.link}>
Already have an account? Sign in
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
justifyContent: "center",
},
title: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 24,
textAlign: "center",
color: "#1a1a1a",
},
input: {
borderWidth: 1,
borderColor: "#e1e1e1",
padding: 16,
marginBottom: 16,
borderRadius: 12,
backgroundColor: "#f5f5f5",
fontSize: 16,
},
button: {
backgroundColor: "#007AFF",
padding: 16,
borderRadius: 12,
alignItems: "center",
marginTop: 8,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
link: {
color: "#007AFF",
textAlign: "center",
marginTop: 16,
fontSize: 15,
},
});
Change the app/(tabs)/_layout.tsx
to use auth.
import { Tabs } from "expo-router";
import { useAuth } from "@clerk/clerk-expo";
import { Redirect } from "expo-router";
export default function TabLayout() {
const { isSignedIn, isLoaded } = useAuth();
// Wait for authentication to load
if (!isLoaded) {
return null;
}
// If user is not signed in, redirect to sign-in page
if (!isSignedIn) {
return <Redirect href="/sign-in" />;
}
return (
<Tabs>
<Tabs.Screen
name="index"
options={{
title: "Home",
}}
/>
<Tabs.Screen
name="two"
options={{
title: "Tab Two",
}}
/>
</Tabs>
);
}
Let's Break Down What's Happening! ๐
Our sign-up flow has four main steps (think of it like getting a VIP club membership):
Initial Sign Up (filling out the application)
await signUp.create({ username, emailAddress, password, });
Collects user info
Creates account in Clerk
Triggers verification email
Email Verification (proving it's really you)
const completeSignUp = await signUp.attemptEmailAddressVerification({ code, });
User enters verification code.
Clerk verifies the code
Account gets activated
Firebase Setup (setting up their profile)
await signInToFirebase(); await updateUserProfile({ userId: completeSignUp.createdUserId!, username, email: emailAddress, // ... other profile info });
Signs into Firebase
Creates user profile
Stores additional user data
Activation (giving them access)
await setActive({ session: completeSignUp.createdSessionId }); router.replace('/(tabs)');
Activates their session
Redirects to main app
Common Issues & Solutions ๐ง:
Verification Code Not Arriving? Check spam folder, verify email, try resending the code.
Firebase Profile Not Creating? Ensure Firebase is initialized, check Firebase rules, verify user ID.
Navigation Not Working? Verify route names, check authentication state, use console.log for debugging.
Explore the complete code on the GitHub Repository. Authentication is just the start! With this solid foundation, you can build amazing features. Keep exploring, learning, and building! ๐ Happy coding! ๐ Check out the Sandbox Link.