DEV Community

Cover image for I Was Tired of Writing the Same Firebase Auth Boilerplate in Every React Native App — So I Built FireGuard
VINCENT PHIRI
VINCENT PHIRI

Posted on

I Was Tired of Writing the Same Firebase Auth Boilerplate in Every React Native App — So I Built FireGuard

How I went from copying and pasting 300 lines of auth code across projects to a single npm package that handles everything — protected routes, role-based access, and security best practices out of the box.

The Problem I Kept Running Into
Every time I started a new React Native project with Firebase, I found myself writing the same code.

// The same listener. Every. Single. Project.
useEffect(() => {
  const unsubscribe = onAuthStateChanged(auth, async (user) => {
    if (user) {
      const doc = await getDoc(doc(db, 'users', user.uid));
      setRole(doc.data()?.role ?? 'user');
      setUser(user);
    } else {
      setUser(null);
      setRole(null);
    }
    setIsLoaded(true);
  });
  return unsubscribe;
}, []);
Enter fullscreen mode Exit fullscreen mode

Then the route guard logic. Then the redirect logic. Then the "show this only for admins" logic. Then the error messages. Then realizing I'm storing the raw Firebase User object in state — which contains your ID token, refresh token, and all kinds of internals — and that React DevTools can expose it to anyone inspecting the app.
It was boilerplate on top of boilerplate, and I kept making the same security mistakes every time.
If you've used Clerk with Expo, you know how different it feels:

// Clerk makes this effortless
<SignedIn>
  <ProtectedScreen />
</SignedIn>

<SignedOut>
  <LoginScreen />
</SignedOut>
Enter fullscreen mode Exit fullscreen mode

That declarative, component-based approach is exactly what Firebase developers deserve too. So I built it.

Introducing FireGuard 🔥🛡️
FireGuard is a security-first authentication wrapper for Firebase + Expo React Native apps. It gives you the Clerk-like developer experience on top of Firebase Auth.

npm install react-native-fireguard
Enter fullscreen mode Exit fullscreen mode

364 downloads in its first 4 days on npm. Clearly, I'm not the only one who felt this pain.
Here's what it gives you in under 5 minutes of setup.

From This... To This
Before FireGuard
Here's what a typical Firebase auth setup looks like in a real Expo app:

// _layout.jsx — the typical Firebase boilerplate nightmare
import { useEffect, useState } from 'react';
import { useRouter, useSegments } from 'expo-router';
import { onAuthStateChanged } from 'firebase/auth';
import { getDoc, doc } from 'firebase/firestore';
import { auth, db } from './firebase';

export default function RootLayout() {
  const [user, setUser] = useState(null);
  const [role, setRole] = useState(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const router = useRouter();
  const segments = useSegments();

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
      if (firebaseUser) {
        try {
          const snap = await getDoc(doc(db, 'users', firebaseUser.uid));
          setRole(snap.exists() ? snap.data().role : 'user');
        } catch {
          setRole('user');
        }
        setUser(firebaseUser); // ⚠️ raw User object in state — security risk
      } else {
        setUser(null);
        setRole(null);
      }
      setIsLoaded(true);
    });
    return unsubscribe;
  }, []);

  useEffect(() => {
    if (!isLoaded) return;
    const inAuthGroup = segments[0] === '(auth)';
    if (!user && !inAuthGroup) router.replace('/(auth)/login');
    else if (user && inAuthGroup) router.replace('/(tabs)');
  }, [user, isLoaded, segments]);

  // Now pass user/role through context... more boilerplate
  // Then protect individual screens... more boilerplate
  // Then handle admin-only content... even more boilerplate
}
Enter fullscreen mode Exit fullscreen mode

That's just the root layout. You haven't even touched the individual screens yet.
After FireGuard

// _layout.jsx — the entire auth setup, done
import { Slot } from 'expo-router';
import { FirebaseAuthProvider, RouteGuard } from 'react-native-fireguard';

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,
};

export default function RootLayout() {
  return (
    <FirebaseAuthProvider config={firebaseConfig}>
      <RouteGuard loginRoute="/(auth)/login" homeRoute="/(tabs)">
        <Slot />
      </RouteGuard>
    </FirebaseAuthProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it. Route protection, Firestore role fetching, token management — all handled.

The Full Feature Set

  1. Declarative Auth Components Show and hide content based on auth state — no if statements scattered across your components:
import { SignedIn, SignedOut, useAuth } from 'react-native-fireguard';

export default function HomeScreen() {
  const { user, role, signOut } = useAuth();

  return (
    <View>
      <SignedIn>
        <Text>Welcome, {user?.email}!</Text>

        {/* Role-based access control */}
        <Protect role="admin">
          <AdminDashboard />
        </Protect>

        <Protect role={["admin", "moderator"]}>
          <ModerationTools />
        </Protect>

        <Button title="Sign Out" onPress={signOut} />
      </SignedIn>

      <SignedOut>
        <Text>Please sign in to continue</Text>
      </SignedOut>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

2. Pre-Built Auth Screens
Stop building login forms from scratch:

// app/(auth)/login.jsx
import { SignIn } from 'react-native-fireguard';

export default function LoginScreen() {
  return <SignIn />;  // Full form with validation, loading states, error handling
}
Enter fullscreen mode Exit fullscreen mode

3. Role-Based Tab Visibility
Hide entire tabs from users who don't have permission — not just the content inside them:

// app/(tabs)/_layout.jsx
import { Tabs } from 'expo-router';
import { useAuth } from 'react-native-fireguard';

export default function TabsLayout() {
  const { role } = useAuth();
  const isAdmin = role === 'admin';

  return (
    <Tabs>
      <Tabs.Screen name="home" options={{ title: 'Home' }} />
      <Tabs.Screen name="profile" options={{ title: 'Profile' }} />
      <Tabs.Screen
        name="admin"
        options={{
          title: 'Admin',
          href: isAdmin ? undefined : null, // completely invisible to non-admins
        }}
      />
    </Tabs>
  );
}
Enter fullscreen mode Exit fullscreen mode

4. Secure API Calls

const { getIdToken } = useAuth();

const fetchSensitiveData = async () => {
  // Force-refresh the token before a payment or sensitive operation
  const token = await getIdToken(true);

  const response = await fetch('https://api.yourapp.com/payment', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    body: JSON.stringify({ amount: 100 }),
  });
};
Enter fullscreen mode Exit fullscreen mode

The Security Decisions That Matter
This isn't just a convenience wrapper. FireGuard was built with specific security problems in mind that most Firebase implementations get wrong.

Problem 1: Raw Firebase User in React State
Most tutorials tell you to do this:

const [user, setUser] = useState(null);

onAuthStateChanged(auth, (firebaseUser) => {
  setUser(firebaseUser); // ❌ This puts your ID token in React state
});
Enter fullscreen mode Exit fullscreen mode

The Firebase User object contains stsTokenManager — your raw ID token and refresh token. When you put it in React state, it becomes visible in React DevTools to anyone with access to your development environment, and it can be accidentally logged or serialized.
FireGuard's fix: The raw User is stored in a useRef — completely invisible to React DevTools — and only a sanitized SafeUser object is exposed through context:

// What FireGuard exposes — no tokens, no internals
interface SafeUser {
  uid: string;
  email: string | null;
  displayName: string | null;
  emailVerified: boolean;
  isAnonymous: boolean;
  // ... safe properties only
}
Enter fullscreen mode Exit fullscreen mode

Problem 2: onAuthStateChanged Misses Token Revocations
If you disable a user in the Firebase Console or revoke their token via the Admin SDK, onAuthStateChanged never fires. The user stays signed in indefinitely on the client.

FireGuard's fix: Uses onIdTokenChanged instead, which fires on every token refresh cycle (~every hour) and catches server-side revocations immediately.

// FireGuard uses this instead of onAuthStateChanged
onIdTokenChanged(auth, (firebaseUser) => {
  // Fires on: sign in, sign out, token refresh, AND token revocation
});
Enter fullscreen mode Exit fullscreen mode

Problem 3: Email Enumeration
Most auth error handlers do this:

'auth/user-not-found': 'No account found with this email.',  // ❌
'auth/wrong-password': 'Incorrect password.',                 // ❌
Enter fullscreen mode Exit fullscreen mode

An attacker can use different error messages to test which emails are registered in your app. This is a well-documented attack called email enumeration — and it's trivially easy to automate.

FireGuard's fix: Both codes return the same message:

'auth/user-not-found': 'Invalid email or password.',  // ✅
'auth/wrong-password': 'Invalid email or password.',  // ✅
Enter fullscreen mode Exit fullscreen mode

Problem 4: Route Guard Race Conditions
Expo Router's navigation is async. If you call router.replace() before the navigator is mounted, it silently fails — leaving an unauthenticated user on a protected screen with no redirect.

FireGuard's fix: Checks useRootNavigationState before any redirect:

const navigationState = useRootNavigationState();
const isNavigatorReady = !!navigationState?.key;

useEffect(() => {
  if (!isLoaded || !isNavigatorReady) return; // wait for navigator
  if (!user) router.replace(loginRoute);
}, [isLoaded, isNavigatorReady, user]);
Enter fullscreen mode Exit fullscreen mode

Setting Up Role-Based Access Control
Roles are stored in Firestore. The structure is simple:
Collection: users
└── Document: {firebase_auth_uid} ← must be the user's UID
├── role: "admin"
└── email: "user@example.com"
To make a user an admin:

Go to Firebase Console **→ Authentication → Users
Copy the user's UID
Go to **Firestore → users collection
→ Add document
Set Document ID = the UID
Add field: role (string) = "admin"

That's it. Next time they sign in, FireGuard fetches their role and it's available everywhere via useAuth().
FireGuard defaults any user without a Firestore document to role: "user" — so your app works even before you set up roles.

Getting Started in 5 Minutes
Install:

npm install react-native-fireguard
npx expo install firebase @react-native-async-storage/async-storage expo-router
Enter fullscreen mode Exit fullscreen mode

Set up your .env:

EXPO_PUBLIC_FIREBASE_API_KEY=your_key
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
EXPO_PUBLIC_FIREBASE_PROJECT_ID=your_project_id
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
EXPO_PUBLIC_FIREBASE_APP_ID=your_app_id
Enter fullscreen mode Exit fullscreen mode

Root layout:

// app/_layout.jsx
import { Slot } from 'expo-router';
import { FirebaseAuthProvider, RouteGuard } from 'react-native-fireguard';

export default function RootLayout() {
  return (
    <FirebaseAuthProvider config={{
      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,
    }}>
      <RouteGuard loginRoute="/(auth)/login" homeRoute="/(tabs)">
        <Slot />
      </RouteGuard>
    </FirebaseAuthProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Login Screen

// app/(auth)/login.jsx
import { SignIn } from 'react-native-fireguard';
export default function LoginScreen() {
  return <SignIn />;
}
Enter fullscreen mode Exit fullscreen mode

Protected Screen

// app/(tabs)/home.jsx
import { SignedIn, Protect, useAuth } from 'react-native-fireguard';

export default function HomeScreen() {
  const { user, role, signOut } = useAuth();
  return (
    <SignedIn>
      <Text>Welcome {user?.email} — Role: {role}</Text>
      <Protect role="admin">
        <Text>🔐 Admin only</Text>
      </Protect>
    </SignedIn>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's the whole setup. Auth, routing, and RBAC — done.

API Quick Reference
Component / HookWhat it doesRoot provider — wraps your entire appAuto-redirects unauthenticated usersShows children only when signed inShows children only when signed outShows children only for specified roleShows children while auth is loadingPre-built sign in formPre-built sign up formuseAuth()Returns user, role, isLoaded, signOut, getIdToken

What's Next
FireGuard is actively maintained. Coming in future versions:

  • TypeScript types — full .d.ts declarations
  • Biometric auth support — Face ID / fingerprint integration
  • Session timeout — auto sign-out after inactivity
  • Social login helpers — Google, Apple, GitHub sign-in components
  • Firestore helper hooks — useDocument, useCollection with built-in auth

Links
npm:npmjs.com/package/react-native-fireguard
GitHub:npmjs.com/package/react-native-fireguard

Issues:github.com/Inspire00/react-native-fireguard/issues
If this saves you time on your next Firebase project, drop a ⭐ on GitHub and share it with your team. And if you run into any issues or have feature ideas — open an issue, I'm actively reading them.

Built by Vincent — a developer who got tired of writing the same Firebase boilerplate one too many times.

react-native firebase expo authentication open-source javascript mobile-development rbac

Top comments (0)