Authentication
How to implement authentication and protect routes with Expo Router.
For the previous version of this guide (SDK 52 and earlier), see Authentication (redirects).
With Expo Router, all routes are always defined and accessible.
- You can use runtime logic to redirect users away from specific screens depending on whether they are authenticated.
- There are two different techniques for authenticating users within routes.
- This guide provides an example that demonstrates the functionality of standard native apps.
Using Protected Routes
Protected routes allow you to prevent users from accessing certain routes using client-side navigation.
- If a user tries to navigate to a protected screen, or if a screen becomes protected while it is active, they will be redirected to the anchor route (usually the index screen) or the first available screen in the stack.
- Consider the following project structure that has a
/sign-inroute that is always accessible and a(app)group that requires authentication:
src
app
- _layout.tsxControls what is protected
- sign-in.tsxAlways accessible
(app)
- _layout.tsxRequires authorization
- index.tsxShould be protected by the (app)/_layout
To follow the above example, set up a React Context provider that can expose an authentication session to the entire app. You can implement your custom authentication session provider or use the one from the Example authentication context below.
Example authentication context
This provider uses a mock implementation. You can replace it with your own authentication provider.
import { use, createContext, type PropsWithChildren } from 'react';
import { useStorageState } from './useStorageState';
const AuthContext = createContext<{
signIn: () => void;
signOut: () => void;
session?: string | null;
isLoading: boolean;
} | null>(null);
// Use this hook to access the user info.
export function useSession() {
const value = use(AuthContext);
if (!value) {
throw new Error('useSession must be wrapped in a <SessionProvider />');
}
return value;
}
export function SessionProvider({ children }: PropsWithChildren) {
const [[isLoading, session], setSession] = useStorageState('session');
return (
<AuthContext.Provider
value={{
signIn: () => {
// Perform sign-in logic here
setSession('xxx');
},
signOut: () => {
setSession(null);
},
session,
isLoading,
}}>
{children}
</AuthContext.Provider>
);
}The following code snippet is a basic hook that persists tokens securely on native with expo-secure-store and in local storage on web.
import { useEffect, useCallback, useReducer } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];
function useAsyncState<T>(
initialValue: [boolean, T | null] = [true, null],
): UseStateHook<T> {
return useReducer(
(state: [boolean, T | null], action: T | null = null): [boolean, T | null] => [false, action],
initialValue
) as UseStateHook<T>;
}
export async function setStorageItemAsync(key: string, value: string | null) {
if (Platform.OS === 'web') {
try {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
} catch (e) {
console.error('Local storage is unavailable:', e);
}
} else {
if (value == null) {
await SecureStore.deleteItemAsync(key);
} else {
await SecureStore.setItemAsync(key, value);
}
}
}
export function useStorageState(key: string): UseStateHook<string> {
// Public
const [state, setState] = useAsyncState<string>();
// Get
useEffect(() => {
if (Platform.OS === 'web') {
try {
if (typeof localStorage !== 'undefined') {
setState(localStorage.getItem(key));
}
} catch (e) {
console.error('Local storage is unavailable:', e);
}
} else {
SecureStore.getItemAsync(key).then((value: string | null) => {
setState(value);
});
}
}, [key]);
// Set
const setValue = useCallback(
(value: string | null) => {
setState(value);
setStorageItemAsync(key, value);
},
[key]
);
return [state, setValue];
}Create a SplashScreenController to manage the splash screen. Authentication loading is asynchronous, so keep the splash screen visible until authentication loads.
import { SplashScreen } from 'expo-router';
import { useSession } from './ctx';
SplashScreen.preventAutoHideAsync();
export function SplashScreenController() {
const { isLoading } = useSession();
if (!isLoading) {
SplashScreen.hide();
}
return null;
}Add the SessionProvider to your root layout. This gives your entire app access to the authentication context. Ensure the SplashScreenController is inside the SessionProvider.
import { Stack } from 'expo-router';
import { SessionProvider } from '@/ctx';
import { SplashScreenController } from '@/splash';
export default function Root() {
// Set up the auth context and render your layout inside of it.
return (
<SessionProvider>
<SplashScreenController />
<RootNavigator />
</SessionProvider>
);
}
// Create a new component that can access the SessionProvider context later.
function RootNavigator() {
return <Stack />;
}Create the /sign-in screen.
- This screen toggles authentication using
signIn(). - Since this screen is outside the
(app)group, the group's layout and authentication check do not run when rendering this screen. - This lets logged-out users access this screen.
import { router } from 'expo-router';
import { Text, View } from 'react-native';
import { useSession } from '@/ctx';
export default function SignIn() {
const { signIn } = useSession();
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text
onPress={() => {
signIn();
// Navigate after signing in. You may want to tweak this to ensure sign-in is successful before navigating.
router.replace('/');
}}>
Sign In
</Text>
</View>
);
}Now modify the RootNavigator to protect routes based on your SessionProvider.
// All import statements remain the same except you need to import `useSession` from your `ctx.tsx` file.
import { SessionProvider, useSession } from '@/ctx';
// All of the above code remains unchanged. Update the `RootNavigator` to protect routes based on your `SessionProvider` below.
function RootNavigator() {
const { session } = useSession();
return (
<Stack>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
);
}Implement an authenticated screen that lets users sign out.
import { Text, View } from 'react-native';
import { useSession } from '@/ctx';
export default function Index() {
const { signOut } = useSession();
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text
onPress={() => {
// The guard in `RootNavigator` redirects back to the sign-in screen.
signOut();
}}>
Sign Out
</Text>
</View>
);
}Create the src/app/(app)/_layout.tsx:
import { Stack } from 'expo-router';
export default function AppLayout() {
// This renders the navigation stack for all authenticated app routes.
return <Stack />;
}You now have an app that will present the splash screen until the initial authentication state has loaded and will redirects to the sign-in screen if the user is not authenticated. If a user visits a deep link to any routes with the authentication check, they'll be redirected to the sign-in screen.
Modals and per-route authentication
Another common pattern is to render a sign-in modal over the top of the app.
- This enables you to dismiss and partially preserve deep links when the authentication is complete.
- However, this pattern requires routes to be rendered in the background as these routes require handling data loading without authentication.
src
app
- _layout.tsxDeclares global session context
(app)
- _layout.tsx
- sign-in.tsxModal presented over the root
(root)
- _layout.tsxProtects child routes
- index.tsxRequires authorization
import { Stack } from 'expo-router';
export const unstable_settings = {
initialRouteName: '(root)',
};
export default function AppLayout() {
return (
<Stack>
<Stack.Screen name="(root)" />
<Stack.Screen
name="sign-in"
options={{
presentation: 'modal',
}}
/>
</Stack>
);
}More information
For more information, read the Protected routes documentation to learn more about patterns.
How to use Protected Routes in Expo Router version 5 and later for smooth authentication — Learn how to use Protected Routes in Expo Router version 5 and later to create an authentication flow.
Middleware
Traditionally, websites may leverage some form of server-side redirection to protect routes.
- Expo Router on the web currently only supports build-time static generation and has no support for custom middleware or serving.
- This can be added in the future to provide a more optimal web experience.
- In the meantime, authentication can be implemented by using client-side redirects and a loading state.
Protected routes
Watch: Using protected routes — Restrict screen access based on authentication state using protected routes in Expo Router.
Overview
Protected screens allow you to prevent users from accessing certain routes using client-side navigation. If a user tries to navigate to a protected screen, or if a screen becomes protected while it is active, they will be redirected to the anchor route (usually the index screen) or the first available screen in the stack.
src
app
- _layout.tsx
- index.tsx
- about.tsx
- login.tsxShould only be available while not authenticated
private
- _layout.tsxShould only be available while authenticated
- index.tsx
- page.tsx
import { Stack } from 'expo-router';
const isLoggedIn = false;
export function AppLayout() {
return (
<Stack>
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="login" />
</Stack.Protected>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="private" />
</Stack.Protected>
{/* Expo Router includes all routes by default. Adding Stack.Protected creates exceptions for these screens. */}
</Stack>
);
}In this example, the /private route is inaccessible because the guard is false. When a user attempts to access /private, they are redirected to the anchor route, which is the index screen.
Additionally, if the user is on /private/page and the guard condition changes to false, they will be redirected automatically.
When a screen's guard is changed from true to false, all of its history entries will be removed from the navigation history.
Multiple protected screens
In Expo Router, a screen can only exist in one active route group at a time.
You should only declare a screen only once, in the most appropriate group or stack. If a screen's availability depends on logic, wrap it in a conditional group instead of duplicating the screen.
import { Stack } from 'expo-router';
const isLoggedIn = true;
const isAdmin = true;
export function AppLayout() {
return (
<Stack>
<Stack.Protected guard={true}>
<Stack.Screen name="profile" />
</Stack.Protected>
<Stack.Screen name="profile" /> // ❌ Not allowed: duplicate screen
</Stack>
);
}Nesting protected screens
Protected screens can be nested to define hierarchical access control logic.
import { Stack } from 'expo-router';
const isLoggedIn = true;
const isAdmin = true;
export function AppLayout() {
return (
<Stack>
<Stack.Protected guard={isLoggedIn}>
<Stack.Protected guard={isAdmin}>
<Stack.Screen name="private" />
</Stack.Protected>
<Stack.Screen name="about" />
</Stack.Protected>
</Stack>
);
}In this case:
/privateis only protected if the user is logged in and is an admin./aboutis protected to any logged-in user.
Falling back to a specific screen
You can configure the navigator to fall back to a specific screen if access is denied.
src
app
- _layout.tsx
- index.tsx
- about.tsx
- login.tsx
private
- _layout.tsx
- index.tsx
- page.tsx
import { Stack } from 'expo-router';
const isLoggedIn = false;
export function AppLayout() {
return (
<Stack>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="index" />
<Stack.Screen name="private" />
</Stack.Protected>
<Stack.Screen name="login" />
</Stack>
);
}In the above example, since the index screen is protected and the guard is false, the router redirects to the first available screen — login.
Tabs and Drawer
Protected routes are also available for Tabs and Drawer navigators.
import { Tabs } from 'expo-router';
const isLoggedIn = false;
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ tabBarLabel: 'Home' }} />
<Tabs.Protected guard={isLoggedIn}>
<Tabs.Screen name="private" options={{ tabBarLabel: 'Private' }} />
<Tabs.Screen name="profile" options={{ tabBarLabel: 'Profile' }} />
</Tabs.Protected>
<Tabs.Protected guard={!isLoggedIn}>
<Tabs.Screen name="login" options={{ tabBarLabel: 'Login' }} />
</Tabs.Protected>
</Tabs>
);
}Custom navigators
Protected is also available for custom navigators using the withLayoutContext hook.
Static rendering considerations
Protected screens are evaluated on the client side only.
- During static site generation, no HTML files are created for protected routes.
- However, if users know the URLs of these routes, they can still request the corresponding HTML or JavaScript files directly.
- Protected screens are not a replacement for server-side authentication or access control.
Authentication with redirects
SDK 53 introduced Protected routes, a more powerful method of handling authentication. Please follow this guide if you are using SDK 52 and earlier.
Building an Auth Flow with Expo Router — Learn how to implement an auth flow in your Expo Router project.
With Expo Router, all routes are always defined and accessible.
- You can use runtime logic to redirect users away from specific screens depending on whether they are authenticated.
- There are two different techniques for authenticating users within routes.
- This guide provides an example that demonstrates the functionality of standard native apps.
Using React Context and Route Groups
It's common to restrict specific routes to users who are not authenticated.
- This is achievable in an organized way by using React Context and Route Groups.
- Consider the following project structure that has a
/sign-inroute that is always accessible and a(app)group that requires authentication:
app
- _layout.tsx
- sign-in.tsxAlways accessible
(app)
- _layout.tsxProtects child routes
- index.tsxRequires authorization
To follow the above example, set up a React Context provider that can expose an authentication session to the entire app. You can implement your custom authentication session provider or use the one from the Example authentication context below.
Example authentication context
This provider uses a mock implementation. You can replace it with your own authentication provider.
import { useContext, createContext, type PropsWithChildren } from 'react';
import { useStorageState } from './useStorageState';
const AuthContext = createContext<{
signIn: () => void;
signOut: () => void;
session?: string | null;
isLoading: boolean;
}>({
signIn: () => null,
signOut: () => null,
session: null,
isLoading: false,
});
// This hook can be used to access the user info.
export function useSession() {
const value = useContext(AuthContext);
if (!value) {
throw new Error('useSession must be wrapped in a <SessionProvider />');
}
return value;
}
export function SessionProvider({ children }: PropsWithChildren) {
const [[isLoading, session], setSession] = useStorageState('session');
return (
<AuthContext.Provider
value={{
signIn: () => {
// Perform sign-in logic here
setSession('xxx');
},
signOut: () => {
setSession(null);
},
session,
isLoading,
}}>
{children}
</AuthContext.Provider>
);
}The following code snippet is a basic hook that persists tokens securely on native with expo-secure-store and in local storage on web.
import { useEffect, useCallback, useReducer } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
type UseStateHook<T> = [[boolean, T | null], (value: T | null) => void];
function useAsyncState<T>(
initialValue: [boolean, T | null] = [true, null],
): UseStateHook<T> {
return useReducer(
(state: [boolean, T | null], action: T | null = null): [boolean, T | null] => [false, action],
initialValue
) as UseStateHook<T>;
}
export async function setStorageItemAsync(key: string, value: string | null) {
if (process.env.EXPO_OS === 'web') {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
} else {
if (value == null) {
await SecureStore.deleteItemAsync(key);
} else {
await SecureStore.setItemAsync(key, value);
}
}
}
export function useStorageState(key: string): UseStateHook<string> {
// Public
const [state, setState] = useAsyncState<string>();
// Get
useEffect(() => {
if (Platform.OS === 'web') {
try {
if (typeof localStorage !== 'undefined') {
setState(localStorage.getItem(key));
}
} catch (e) {
console.error('Local storage is unavailable:', e);
}
} else {
SecureStore.getItemAsync(key).then((value: string | null) => {
setState(value);
});
}
}, [key]);
// Set
const setValue = useCallback(
(value: string | null) => {
setState(value);
setStorageItemAsync(key, value);
},
[key]
);
return [state, setValue];
}Use the SessionProvider in the root layout to provide the authentication context to the entire app. It's imperative that the <Slot /> is mounted before any navigation events are triggered. Otherwise, a runtime error will be thrown.
import { Slot } from 'expo-router';
import { SessionProvider } from '../ctx';
export default function Root() {
// Set up the auth context and render our layout inside of it.
return (
<SessionProvider>
<Slot />
</SessionProvider>
);
}Create a nested layout route that checks whether users are authenticated before rendering the child route components. This layout route redirects users to the sign-in screen if they are not authenticated.
import { Text } from 'react-native';
import { Redirect, Stack } from 'expo-router';
import { useSession } from '../../ctx';
export default function AppLayout() {
const { session, isLoading } = useSession();
// You can keep the splash screen open, or render a loading screen like we do here.
if (isLoading) {
return <Text>Loading...</Text>;
}
// Only require authentication within the (app) group's layout as users
// need to be able to access the (auth) group and sign in again.
if (!session) {
// On web, static rendering will stop here as the user is not authenticated
// in the headless Node process that the pages are rendered in.
return <Redirect href="/sign-in" />;
}
// This layout can be deferred because it's not the root layout.
return <Stack />;
}Create the /sign-in screen. It can toggle the authentication using signIn(). Since this screen is outside the (app) group, the group's layout and authentication check do not run when rendering this screen. This lets logged-out users see this screen.
import { router } from 'expo-router';
import { Text, View } from 'react-native';
import { useSession } from '../ctx';
export default function SignIn() {
const { signIn } = useSession();
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text
onPress={() => {
signIn();
// Navigate after signing in. You may want to tweak this to ensure sign-in is
// successful before navigating.
router.replace('/');
}}>
Sign In
</Text>
</View>
);
}Implement an authenticated screen that lets users sign out.
import { Text, View } from 'react-native';
import { useSession } from '../../ctx';
export default function Index() {
const { signOut } = useSession();
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text
onPress={() => {
// The `app/(app)/_layout.tsx` will redirect to the sign-in screen.
signOut();
}}>
Sign Out
</Text>
</View>
);
}You now have an app that can present a loading state while it checks the initial authentication state and redirects to the sign-in screen if the user is not authenticated. If a user visits a deep link to any routes with the authentication check, they'll be redirected to the sign-in screen.
Alternative loading states
With Expo Router, something must be rendered to the screen while loading the initial auth state.
- In the example above, the app layout renders a loading message.
- Alternatively, you can make the
indexroute a loading state and move the initial route to something such as/home, which is similar to how X works.
Modals and per-route authentication
Another common pattern is to render a sign-in modal over the top of the app.
- This enables you to dismiss and partially preserve deep links when the authentication is complete.
- However, this pattern requires routes to be rendered in the background as these routes require handling data loading without authentication.
app
- _layout.tsxDeclares global session context
(app)
- _layout.tsx
- sign-in.tsxModal presented over the root
(root)
- _layout.tsxProtects child routes
- index.tsxRequires authorization
import { Stack } from 'expo-router';
export const unstable_settings = {
anchor: '(root)',
};
export default function AppLayout() {
return (
<Stack>
<Stack.Screen name="(root)" />
<Stack.Screen
name="sign-in"
options={{
presentation: 'modal',
}}
/>
</Stack>
);
}Navigating without navigation
You may encounter the following error when the app attempts to perform navigation without a navigator mounted in the root layout.
Error: Attempted to navigate before mounting the Root Layout component. Ensure the Root Layout component is rendering a Slot, or other navigator on the first render.To fix this, add a group and move conditional logic down a level.
Before
app
- _layout.tsx
- about.tsx
export default function RootLayout() {
React.useEffect(() => {
// This navigation event will trigger the error above.
router.push('/about');
}, []);
// This conditional statement creates a problem since the root layout's
// content (the Slot) must be mounted before any navigation events occur.
if (isLoading) {
return <Text>Loading...</Text>;
}
return <Slot />;
}After
app
- _layout.tsx
(app)
- _layout.tsxMove conditional logic down a level
- about.tsx
export default function RootLayout() {
return <Slot />;
}export default function RootLayout() {
React.useEffect(() => {
router.push('/about');
}, []);
// It is OK to defer rendering this nested layout's content. We couldn't
// defer rendering the root layout's content since a navigation event (the
// redirect) would have been triggered before the root layout's content had
// been mounted.
if (isLoading) {
return <Text>Loading...</Text>;
}
return <Slot />;
}Middleware
Traditionally, websites may leverage some form of server-side redirection to protect routes.
- Expo Router on the web currently only supports build-time static generation and has no support for custom middleware or serving.
- This can be added in the future to provide a more optimal web experience.
- In the meantime, authentication can be implemented by using client-side redirects and a loading state.
Sources
The original Expo docs for this page:
- Authentication in Expo Router (
source-of-truth/router/advanced/authentication.md) - Protected routes (
source-of-truth/router/advanced/protected.md) - Authentication in Expo Router using redirects (
source-of-truth/router/advanced/authentication-rewrites.md)