Crafting a Robust Authentication System in React Native with Clerk and Firebase

ยท

10 min read

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 system

  • firebase: Our backend services

  • expo-secure-store: For securely storing tokens

  • expo-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:

  1. Add .env to your .gitignore

  2. Never commit this file to version control

  3. 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:

  1. We're initializing Firebase (but only once!)

  2. Setting up Firestore (our database)

  3. Creating helper functions for user profiles

  4. 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:

  1. Gets a special token from Clerk

  2. Uses that token to sign in to Firebase

  3. Handles any errors that might occur

  4. 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 device

  • Automatically 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:

  1. We wrap everything in ClerkProvider - it's like the security system for our whole app

  2. ClerkLoaded makes sure auth is ready before showing anything

  3. SafeAreaProvider 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:

  1. We use Clerk's useSignIn hook to handle authentication

  2. Loading states show users when something's happening

  3. Error handling gives friendly messages

  4. 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):

  1. Initial Sign Up (filling out the application)

     await signUp.create({
       username,
       emailAddress,
       password,
     });
    
    • Collects user info

    • Creates account in Clerk

    • Triggers verification email

  2. Email Verification (proving it's really you)

     const completeSignUp = await signUp.attemptEmailAddressVerification({
       code,
     });
    
    • User enters verification code.

    • Clerk verifies the code

    • Account gets activated

  3. 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

  4. 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.

ย