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;
}, []);
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>
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
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
}
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>
);
}
That's it. Route protection, Firestore role fetching, token management — all handled.
The Full Feature Set
- 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>
);
}
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
}
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>
);
}
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 }),
});
};
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
});
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
}
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
});
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.', // ❌
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.', // ✅
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]);
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: "
GitHub:
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
SOCIAL SHARE CARD GENERATOR