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: "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
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
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>
);
}
Login Screen
// app/(auth)/login.jsx
import { SignIn } from 'react-native-fireguard';
export default function LoginScreen() {
return <SignIn />;
}
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>
);
}
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)