Back to Tutorials

Expo Router Firebase Auth: Protected Routes, Tabs, and Login Flows

Firebase Auth tutorials usually stop at signInWithEmailAndPassword. The part that actually breaks in production is navigation: private screens flashing on cold start, sign-out crashing a nested screen, and deep links quietly dropping users on the home tab. This wires Firebase Auth into Expo Router properly with Stack.Protected, protected tabs, modal login for optional auth, and deep links that survive the trip through sign-in.

September 11, 2026 13 min read Paddy B
ExpoExpo RouterReact NativeFirebase AuthNavigation

The short version

Model auth as three states — loading, signed in, signed out — and hold the splash screen through the loading one. Declare your route groups once in the root layout and wrap them in Stack.Protected guard={...} instead of scattering redirects through nested layouts. Never navigate manually after sign-in or sign-out; flipping the guard does it for you. And because a blocked navigation fails silently, capture the incoming deep link yourself if you want to replay it afterwards.

This assumes you already have Firebase Auth initialized with React Native persistence. If not, start with Firebase Auth in Expo React Native and come back — everything below builds on that auth instance.

1. Decide the route groups before writing any guard code

Guards are declarative, so the file layout does most of the work. Put every signed-in screen in one group and every signed-out screen in another, so a single boolean can swap between them:

app/ _layout.tsx # session provider + the guards (auth)/ _layout.tsx sign-in.tsx sign-up.tsx reset-password.tsx (app)/ _layout.tsx # tabs live here (tabs)/ index.tsx orders.tsx admin.tsx # extra guard: custom claim order/[id].tsx # deep link target modal/ sign-in.tsx # optional-auth prompt +not-found.tsx

The parenthesised folders are route groups: they organise files without adding a URL segment, so (app)/order/[id].tsx is still /order/123. That matters for deep links — your marketing emails and universal links should never contain (app).

One screen, one group

A screen can only exist in one active route group at a time. Declaring sign-in inside both a guarded and an unguarded group is an error, not a fallback — which is why the optional-auth prompt above lives at modal/sign-in.tsx rather than being reused from (auth).

2. Expose auth as three states, not two

Almost every "it flashes the login screen for half a second" bug comes from treating user === null as "signed out". On a cold start it means "Firebase has not finished reading persisted credentials yet". Track that third state explicitly:

import { onAuthStateChanged, type User } from "firebase/auth"; import { createContext, useContext, useEffect, useState } from "react"; import { auth } from "../firebase"; type Session = { user: User | null; isLoading: boolean }; const AuthContext = createContext<Session>({ user: null, isLoading: true }); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => onAuthStateChanged(auth, (nextUser) => { setUser(nextUser); setIsLoading(false); // first callback = restore finished, either way }), []); return <AuthContext.Provider value={{ user, isLoading }}>{children}</AuthContext.Provider>; } export const useSession = () => useContext(AuthContext);

onAuthStateChanged returns its unsubscribe function, so returning it straight from the effect is all the cleanup you need. The listener also fires on token refresh failures and on a disabled or deleted account, which is what makes mid-session expiry work later on.

3. Guard the root layout, not every nested one

Protected routes landed in Expo Router with SDK 53 and replaced the old pattern of calling <Redirect> from inside each group layout. Declare both groups in the root navigator and let the guard decide which one is reachable:

import { Stack } from "expo-router"; import { AuthProvider, useSession } from "../lib/auth"; import { SplashScreenController } from "../lib/splash"; export default function RootLayout() { return ( <AuthProvider> <SplashScreenController /> <RootNavigator /> </AuthProvider> ); } function RootNavigator() { const { user } = useSession(); return ( <Stack screenOptions={{ headerShown: false }}> <Stack.Protected guard={!!user}> <Stack.Screen name="(app)" /> <Stack.Screen name="modal/sign-in" options={{ presentation: "modal" }} /> </Stack.Protected> <Stack.Protected guard={!user}> <Stack.Screen name="(auth)" /> </Stack.Protected> </Stack> ); }

Two behaviours are worth committing to memory. Navigating to a screen whose guard is false fails silently — no error, no redirect, nothing in the logs. And when a guard flips from true to false while the user is on that screen, all of its history entries are removed and the router opens the next available screen, falling back to the anchor route or the first reachable screen in the stack.

That second behaviour is why the ordering above matters: a signed-out user who somehow reaches the app group lands on (auth) because it is the first screen still reachable. It is also why the root layout keeps rendering a navigator at all times — returning null from a root layout while auth resolves is the classic route to "Attempted to navigate before mounting the Root Layout".

4. Hold the splash screen instead of rendering a loading screen

During that first tick, user is null, so the signed-out group is the reachable one and the router mounts (auth)/sign-in underneath. Keep the splash screen up until the first auth callback and nobody ever sees it:

import { SplashScreen } from "expo-router"; import { useSession } from "./auth"; SplashScreen.preventAutoHideAsync(); export function SplashScreenController() { const { isLoading } = useSession(); if (!isLoading) { SplashScreen.hideAsync(); } return null; }

A dedicated loading route is the tempting alternative and the wrong one: it is another screen in the stack that guards have to reason about, it shows up in the web history, and it gives you a visible flash on every cold start instead of a seamless splash handoff.

Known rough edges

On iOS a protected screen can briefly appear before the guard moves you off it. On web, redirects can leave the browser address bar on the blocked path even though a different screen rendered. Both are worth a manual check on each platform before release rather than a surprise in a store review.

5. Protect individual tabs with custom claims

Guards nest, and Tabs and Drawer support the same API. A tab that should only exist for admins is a guard inside the already-authenticated group:

import { Tabs } from "expo-router"; import { useSession } from "../../lib/auth"; export default function TabsLayout() { const { claims } = useSession(); return ( <Tabs> <Tabs.Screen name="index" options={{ title: "Home" }} /> <Tabs.Screen name="orders" options={{ title: "Orders" }} /> <Tabs.Protected guard={claims?.admin === true}> <Tabs.Screen name="admin" options={{ title: "Admin" }} /> </Tabs.Protected> </Tabs> ); }

Read the claims from the ID token result in the same provider that owns the user, and force a refresh when a claim may have changed server-side:

const result = await nextUser.getIdTokenResult(); setClaims(result.claims); // after a Cloud Function grants a role, the cached token is stale: await auth.currentUser?.getIdTokenResult(true);

Hiding the tab is a UX decision, not a security one. Protected screens are evaluated on the client only and are explicitly not a replacement for server-side access control — the same account can still call Firestore directly. Enforce the claim in your Firestore rules and in any Cloud Function that acts on it, and treat the missing tab as a convenience.

6. Modal login for apps where auth is optional

Plenty of apps are mostly public and only need an account for a few actions — saving a favourite, leaving a review, starting a checkout. Guarding the whole group is wrong here. Push a modal at the point of friction and carry the reason with it:

const { user } = useSession(); const router = useRouter(); const pathname = usePathname(); const pendingAction = useRef<(() => void) | null>(null); function requireAuth(action: () => void) { if (user) return action(); pendingAction.current = action; router.push({ pathname: "/modal/sign-in", params: { returnTo: pathname } }); } useEffect(() => { if (user && pendingAction.current) { pendingAction.current(); pendingAction.current = null; } }, [user]);

The modal signs in and calls router.back(). The screen underneath re-renders with a user, the effect fires, and the action the user originally tapped completes — instead of dumping them back on a screen with no memory of what they were doing.

Note that modal/sign-in is declared inside the authenticated group in the root layout above. That is deliberate: once the user signs in, the guard on the modal itself flips and its history entry is dropped, so the modal dismisses without you writing dismissal logic.

7. Keep the deep link a signed-out user opened

This is the one guards do not solve for you. A push notification linking to /order/123 opens the app, the guard on (app) is still false, the navigation fails silently, and the intended route is gone by the time sign-in completes. Capture it before the router gets a chance to discard it:

import * as Linking from "expo-linking"; // inside AuthProvider const [pendingHref, setPendingHref] = useState<string | null>(null); useEffect(() => { Linking.getInitialURL().then((url) => { if (!url) return; const { path } = Linking.parse(url); if (path) setPendingHref("/" + path); }); }, []);

Then replay it once, after a successful sign-in, and clear it so a later sign-out does not send the user somewhere unexpected:

async function onSignIn(email: string, password: string) { await signInWithEmailAndPassword(auth, email, password); // the guard has already moved us into (app); this refines where. if (pendingHref) { const href = pendingHref; setPendingHref(null); router.replace(href); } }

Use replace, not push: the user should not be able to swipe back into the sign-in screen they just left. If you would rather keep the destination in the URL, the same idea works as a returnTo search param on the sign-in route, which has the advantage of surviving a web refresh.

8. Sign out by flipping the guard, not by navigating

The whole sign-out handler is one line:

import { signOut } from "firebase/auth"; await signOut(auth);

No router.replace("/sign-in"). onAuthStateChanged fires with null, the guard on (app) goes false, its history is dropped, and the router opens (auth). Adding a manual navigation on top races the guard and is a reliable way to produce a stuck screen or a double transition.

What you do need to clean up is anything that outlives the component tree. The (app) screens unmount, but module-level state does not:

  • Detach Firestore onSnapshot listeners in effect cleanup, or they keep running against rules that now deny them and log permission errors.
  • Clear any query cache keyed by uid, so the next account does not briefly see the previous one's data.
  • Remove the device's Expo push token for that user — see Firebase Functions for Expo push notifications if you store them per user.
  • Reset in-memory drafts and any locally cached profile image.

9. The flows that actually break

Guard logic looks fine in a warm simulator session. These are the cases worth walking manually before every release:

  1. Cold start, already signed in. Kill the app, relaunch. You should go splash → tabs, with no sign-in screen visible at any point.
  2. Cold start, signed out. Splash → sign-in, with no flash of a tab bar.
  3. Deep link while signed out. Open /order/123 from a link. Sign in. You should land on the order, not the home tab.
  4. Session revoked mid-use. Disable the account in the Firebase console while the app is open on a nested screen. The listener fires, the guard flips, and the user lands on sign-in rather than on a screen throwing permission errors.
  5. Sign out from three levels deep. Navigate into a detail screen inside a tab, sign out, then try the back gesture. You should stay on sign-in.
  6. Web refresh on a protected URL. Reload the page directly on a private path in both states and check where the address bar ends up.

Run these against the Firebase emulator so you can disable and re-enable accounts freely — the emulator setup in Firestore rules and offline data makes case four a two-second test instead of a console round trip.

10. Production checklist

  • Auth state has three values and the splash screen covers the loading one.
  • All guards are declared in the root layout; no nested layout calls <Redirect> for auth. Competing redirects are what produce infinite bounce loops between / and /sign-in.
  • No manual navigation in the sign-in or sign-out handlers, beyond an explicit deep-link replay.
  • Every guarded screen has a matching Firestore rule or server-side check behind it.
  • Deep link targets use public paths, with no route-group names in any URL you publish.
  • Custom claims are refreshed with getIdTokenResult(true) after any server-side role change.
  • iOS, Android, and web each checked for the brief-flash and address-bar issues above.

Related tutorials

Need this built for you? I take on contract Expo and React Native work — see how I can help.

Support tutorials