Most Expo apps get sign-in working on the first day and get auth guards wrong for the next six months. The login screen appears, the token lands in secure storage, and everything looks fine until a user reopens the app from a push notification, lands on a protected screen with an expired session, and sees a flash of someone else's layout — or worse, a blank screen with no way back.
Expo Router gives you the primitives to handle all of this: protected routes, layout-level redirects, and splash screen control that waits for async session restore. The official authentication guide covers the happy path well. This post covers the four production cases that break real apps: guarding route groups instead of individual screens, restoring sessions before first render, redirecting deep links through sign-in without losing the destination, and surviving the token-refresh race on cold start.
Put the guard on the layout, not the screen
The most common mistake is checking authentication inside individual screens. That means every new screen needs its own check, the unauthenticated UI flashes for a frame before the redirect fires, and one forgotten screen ships an unprotected route to production.
Expo Router's protected routes move the check to the layout. A layout wraps a whole group of routes, so a single guard covers every screen inside it. The project structure looks like this: a sign-in route that stays public, and an (app) group whose layout requires a session.
// app/(app)/_layout.tsx
import { Stack } from 'expo-router';
import { useSession } from '@/ctx';
export default function AppLayout() {
const { session, isLoading } = useSession();
// Expo Router redirects away from this whole group
// when the guard returns false.
return (
<Stack
screenOptions={{
// Guard: only authenticated users stay inside (app).
// Unauthenticated visitors fall back to the anchor route.
}}
/>
);
}
With the Protected component from expo-router, the guard becomes declarative. You wrap the group, pass the condition, and the router handles redirecting to the anchor route when the condition fails — including when a screen becomes protected while it is already on screen, which happens when a session expires mid-use.
This matters because session expiry is not an edge case. Tokens expire, refresh tokens get revoked, and users get signed out on another device. A guard that only runs on navigation misses all of that. A layout-level guard re-evaluates whenever the session value changes.
Restore the session before the first paint
Session restore is asynchronous. The token lives in expo-secure-store on native and localStorage on web, and reading it takes time. If your navigator renders before the read completes, the guard sees null, concludes the user is signed out, and redirects to sign-in — then the restore finishes, the session appears, and the user bounces back. That double navigation is the flicker users complain about.
The fix has two parts. First, track a loading state in your session provider that stays true until the storage read resolves. Second, keep the splash screen visible until loading completes.
// app/_layout.tsx
import { Slot, SplashScreen } from 'expo-router';
import { SessionProvider, useSession } from '@/ctx';
SplashScreen.preventAutoHideAsync();
function SplashScreenController() {
const { isLoading } = useSession();
useEffect(() => {
if (!isLoading) {
SplashScreen.hideAsync();
}
}, [isLoading]);
return null;
}
export default function RootLayout() {
return (
<SessionProvider>
<SplashScreenController />
<Slot />
</SessionProvider>
);
}
The pattern is simple but easy to skip: preventAutoHideAsync at module scope, hide only when isLoading flips to false. Until then the user sees the splash screen, not a wrong redirect.
Pair this with a storage hook that defaults to loading. The official guide's useStorageState starts as [true, null] — loading true, session null — and only clears loading after the platform read resolves. Never initialize the session as definitively signed-out. Unknown and signed-out are different states, and conflating them is the root cause of the redirect flicker.
Carry deep links through sign-in
Push notifications and universal links routinely land users on protected routes: an order confirmation, a shared document, a chat thread. If the user is signed out — or the session has not finished restoring — the guard redirects to sign-in and the original destination is lost. The user signs in and lands on the home screen, confused.
Solve this by capturing the incoming path before redirecting and replaying it after authentication. Expo Router's useSegments and useRouter give you both pieces.
// app/sign-in.tsx
import { useRouter, useLocalSearchParams } from 'expo-router';
import { useSession } from '@/ctx';
export default function SignIn() {
const router = useRouter();
const { signIn } = useSession();
// The guard stashes the intended destination as a param.
const { redirect } = useLocalSearchParams<{ redirect?: string }>();
async function handleSignIn() {
await signIn();
// Replay the deep link destination, fall back to the app home.
router.replace(typeof redirect === 'string' ? redirect : '/(app)');
}
// ... sign-in form calls handleSignIn on success
}
On the guard side, when redirecting an unauthenticated visitor, append the current pathname as the redirect param. On web this composes naturally with URL query strings; on native it works because Expo Router treats params uniformly across platforms.
Test this flow explicitly on both platforms. Deep-link behavior diverges between iOS universal links, Android app links, and web URLs, and the redirect param must survive all three. Add it to your release checklist alongside the rest of your navigation verification.
Win the refresh race on cold start
Here is the nastiest production failure: the stored session exists but the access token expired while the app was closed. Restore reads a token from storage, the guard sees a non-null session and lets the user into the protected group, then the first API call returns 401, the refresh flow kicks in, and the user watches content load, vanish, and reload — or gets dumped to sign-in despite having a valid refresh token.
The guard must distinguish "token present" from "session valid." The solid pattern is a three-state session: loading, signed-out, and signed-in-with-validation-pending. On restore, if a stored session exists, validate or refresh it before clearing the loading flag.
// ctx.tsx (session restore with validation)
export function SessionProvider({ children }: PropsWithChildren) {
const [[isLoading, session], setSession] = useStorageState('session');
useEffect(() => {
async function validate() {
if (session) {
try {
// Exchange or verify the stored token before exposing it.
const valid = await refreshSessionIfNeeded(session);
if (!valid) {
setSession(null);
}
} catch {
// Network down on cold start: keep the stored session
// and let the mutation queue retry, rather than
// signing the user out for having no signal.
console.warn('Session validation deferred: no network');
}
}
}
if (!isLoading) {
validate();
}
}, [isLoading]);
// ... provider value as usual
}
Note the catch block. Signing users out because the network was unreachable on cold start is a classic self-inflicted wound — the user opens the app in a tunnel and gets logged out. If validation fails due to network error, keep the stored session and let normal request retry handle it. Only clear the session on a definitive rejection from the auth server, like an invalid grant or revoked refresh token.
If you use Supabase, this maps directly onto getSession versus onAuthStateChange: restore from storage first for speed, then let the auth state listener correct you. The session wiring that keeps this reliable is worth getting right once, because every guard in the app depends on it.
Sign out without stranding the navigator
Sign-out has a mirror-image problem. Clearing the token flips the session to null, the layout guard fires, and the router ejects the user from the protected group. If the navigation stack still holds protected screens, the back button can navigate back into them — briefly rendering authenticated UI with no session before the guard redirects again.
After clearing the session, reset the navigation state to the public group explicitly rather than relying on the guard redirect alone.
async function handleSignOut() {
await signOut(); // clears storage + auth server session
router.replace('/sign-in');
}
router.replace drops the protected screens from the stack instead of pushing sign-in on top of them. There is nothing gated left to go back to. Also revoke or clear any cached user data — profile objects, cached queries — at the same moment, so a subsequent user on a shared device never sees the previous account's content flash during the next sign-in.
A checklist before you ship
Auth guards feel done when the happy path works in development. They are done when all five of these hold: guards live on layouts covering whole route groups, first render waits for session restore behind the splash screen, deep links survive a sign-in round trip on iOS and Android, cold start validates rather than trusts the stored token, and sign-out resets the stack instead of layering on top of it.
Get these right and authentication disappears as a category of bug report. Users stay signed in across restarts, links land where they should, and expired sessions resolve into a clean sign-in screen instead of a broken half-rendered app.
Sources
- Primary source (verified live): Authentication in Expo Router — Expo official docs covering protected routes, session context, storage, and splash screen control.
- Related reading on this blog:
Top comments (0)