Protected routes not working
Minimal reproducible example
https://github.com/Joozty/expo-protected-routes-bug
Steps to reproduce
I bootstrapped the app using npx create-expo-app@latest StickerSmash and then followed the authentication guide using Expo's protected routes. I basically just copied the code from the docs, but it doesn't seem to work. On iOS, it shows (app)/index.tsx by default, even though I’m not signed in, while on Android it shows sign-in.tsx. However, when I sign in and then sign out, the screen doesn't refresh.
Thanks for looking into it.
Environment
expo-env-info 1.3.3 environment info:
System:
OS: macOS 15.5
Shell: 5.9 - /bin/zsh
Binaries:
Node: 20.19.2 - ~/.nvm/versions/node/v20.19.2/bin/node
Yarn: 1.22.22 - /opt/homebrew/bin/yarn
npm: 10.8.2 - ~/.nvm/versions/node/v20.19.2/bin/npm
Watchman: 2025.05.26.00 - /opt/homebrew/bin/watchman
Managers:
CocoaPods: 1.15.2 - /usr/local/bin/pod
SDKs:
iOS SDK:
Platforms: DriverKit 24.5, iOS 18.5, macOS 15.5, tvOS 18.5, visionOS 2.5, watchOS 11.5
IDEs:
Android Studio: 2024.3 AI-243.26053.27.2432.13536105
Xcode: 16.4/16F6 - /usr/bin/xcodebuild
npmPackages:
expo: ~53.0.10 => 53.0.11
expo-router: ~5.0.7 => 5.0.7
react: 19.0.0 => 19.0.0
react-dom: 19.0.0 => 19.0.0
react-native: 0.79.3 => 0.79.3
react-native-web: ~0.20.0 => 0.20.0
Expo Workflow: managed
Expo Doctor Diagnostics
15/15 checks passed. No issues detected!
The same for me, on my side the code from guide works incorrectly.
Oh, I found the issue
function RootNavigator() {
const { session } = useSession();
return (
<Stack>
<Stack.Protected guard={session}>
<Stack.Screen name="(app)/index" /> /* <--- Set the path `(app)/index` */
</Stack.Protected>
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
);
}
Same problem here, got stuck in black screen while using protected routes. If the conditions stays the same when launching, or change after the app launches, it works without any problem. But when the conditions are changing while the app is launching, got stuck in black screen. Just removed the protected guard and everything works normally.
Oh, I found the issue
function RootNavigator() { const { session } = useSession();
return ( <Stack> <Stack.Protected guard={session}> <Stack.Screen name="(app)/index" /> /* <--- Set the path
(app)/index*/ </Stack.Protected><Stack.Protected guard={!session}> <Stack.Screen name="sign-in" /> </Stack.Protected> </Stack>); }
Tried that, didn't work for me.
Same experience here.
It's inconsistent how paths needs to be written in Stack.Screen component compared with router.replace():
- Stack.Screen component does not recognize index.tsx file if there is no _layout.tsx file, this does not happen with router.replace()
- Folders that only contains other folders (without tsx files) are also required to be in the path of the Stack.Screen component but not in router.replace(), this is what @zhulduz noticed, the workaround is to remove them
- Stack requires paths to not start with slash "/" and router.replace() requires the slash
- Sub-pages are not recognized anymore by Stack.Screen after the first level, it seems they want us to add Stack.Screen on every _layout.tsx but this is not documented
If you made any change in the folder structure, added or removed files or folders, remember to rebuild the project otherwise the issue will still be there.
Once I got all the paths right, I noticed the fallback page is not working, example:
import { Stack } from 'expo-router';
const isLoggedIn = false;
export function AppLayout() {
return (
<Stack>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="private" /> <---------- this works, when attempting to redirect to this path nothing happens
</Stack.Protected>
<Stack.Screen name="login" /> <--------- not working, never renders or redirect to this, no mater if guard is true or false (path is ok)
</Stack>
);
}
Neather this works:
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="login" />
</Stack.Protected>
At least can be used to block redirecting to private pages when is not logged in, is the only feature that actually works
Also the prop "name" in Stack should be "path" or something like that, looks like AI generated code if the prop name is not accurate
the only way I could make redirection from private route work was to explicitly return a <Redirect href="/login" /> conditionally, but I can confirm that the examples mentioned in the docs does not actually work as described
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.
I encountered the same issue, and also had to use a check w/ <Redirect>.
From the docs you can see a tree where there is a _layout for (app). However, they don't define that file like they did with the others. To fix the issue, simply create a _layout.tsx file inside (app):
import { Stack } from 'expo-router';
export default function AppLayout() {
return <Stack />;
}
From the docs you can see a tree where there is a _layout for (app). However, they don't define that file like they did with the others. To fix the issue, simply create a _layout.tsx file inside (app):
import { Stack } from 'expo-router'; export default function AppLayout() { return <Stack />; }
I hope this isn't a requirement for protected routes. The examples don't follow this pattern: https://github.com/kadikraman/expo-router-example/blob/main/6-protected-routes/src/app/_layout.tsx
i'm having a similar issue with nested protected routes:
i have a 'logged in' guard, and a nested 'profile complete' guard. but its not working as expected.
since i'm NOT logged in, i should NOT be redirected to complete profile screen.
` const isLoggedIn = false; const isProfileComplete = false;
<Stack>
<Stack.Protected guard={isLoggedIn === true}>
<Stack.Protected guard={isProfileComplete === false}>
<Stack.Screen name="complete-profile" /> <---------- i get redirected here, instead of login
</Stack.Protected>
</Stack.Protected>
<Stack.Protected guard={isLoggedIn === false}>
<Stack.Screen name="login" /> <--------- i dont get redirected here
</Stack.Protected>
</Stack>
`
however, if i modify guard, it works correctly:
(isProfileComplete === false) change this to (isLoggedIn === true && isProfileComplete === false)
but i should not have to do this since profile complete route is already nested within logged in guard.
is this a bug, or am i just misunderstanding this cool feature?
i don't know why but need _layout in private for it to work and have to define each single route. It worked for me
For me also the issue appears when I'm using nested protected routes. What helped me was to just spread them flat into separate protected routes without nesting.
// BEFORE
<Stack.Protected guard={!isAuthenticated}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={isAuthenticated}>
<Stack.Protected guard={userNeedsOnboarding}>
<Stack.Screen name="onboarding" />
</Stack.Protected>
<Stack.Protected guard={!userNeedsOnboarding}>
<Stack.Screen name="(app)" />
</Stack.Protected>
</Stack.Protected>
// AFTER
<Stack.Protected guard={!isAuthenticated}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={isAuthenticated && userNeedsOnboarding}>
<Stack.Screen name="onboarding" />
</Stack.Protected>
<Stack.Protected guard={isAuthenticated && !userNeedsOnboarding}>
<Stack.Screen name="(app)" />
</Stack.Protected>
To clarify, when nesting was in place the app just proceeded to render the scenario with (app) (which is also the entry point, as it contains the "root index.tsx") even though isAuthenticated was false
It's a pity since I love the composition possibilities with nesting!
I'm unsure if I'm doing something wrong or if this is a bug 🤔
Still having this problem. Any ideas?
Any updates on this with expo 54?
Right now, it feels much simpler and more reliable to do this:
// app/index.tsx
export default function IndexRoute() {
const { isInitialized, session } = useAuth();
if (!isInitialized) {
return <Loading />;
}
if (!session) {
return <SignInRoute />;
}
return <Redirect href="/protected-root" />;
}
Just tried to implement this after upgrading to SDK 53 (as firebase doesn't seem to support 54 yet)
When throwing in some logs, i can see that both memo and user is changed on renders - but it doesn't redirect the user to the secure section. If i reload the app, the user is taken to the secure section immediately (as expected) - but not during the auth flow.
- (secure)
-- index.tsx
-- sign-out.tsx
-- _layout.tsx
- (authenticate)
-- _layout.tsx
-- sign-in.tsx
-- register.tsx
-- register-email.tsx
-- forgot-password.tsx
- _layout.tsx
Note: _layout.tsx in
(authenticate/secure)was added after reading comments here, to no effect.
// app/_layout.tsx
const RootLayout = () => {
const [user, setUser] = useState<FirebaseAuthTypes.User | null>(
auth.currentUser
);
const isLoggedIn = useMemo(() => user !== null, [user]);
const onFirebaseUserChanged = (user: FirebaseAuthTypes.User | null) => {
setUser(user);
};
useEffect(() => {
return onAuthStateChanged(auth, onFirebaseUserChanged);
}, []);
return (
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="sign-in" />
<Stack.Screen name="register" />
<Stack.Screen name="register-email" />
<Stack.Screen name="forgot-password" />
</Stack.Protected>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="(secure)" />
</Stack.Protected>
</Stack>
);
};
export default RootLayout;
Edit:
I did get it to work by adding a new <Stack> navigator to the grouped folders, explicitly naming each route, and changing the RootLayout's routing to <Stack.Screen name="(authenticate)" /> instead of the explicit names there.
...
<Stack>
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="(authenticate)" />
</Stack.Protected>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="(secure)" />
</Stack.Protected>
</Stack>
...
This works for me:
- (tabs)
- _layout.tsx
- home.tsx
- account
- (welcome)
- _layout.tsx
- index.tsx
- signIn.tsx
- _layout.tsx
and in the app/_layout.tsx:
<Stack>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="(tabs)" />
</Stack.Protected>
<Stack.Protected guard={!isLoggedIn} >
<Stack.Screen name="(welcome)" options={{ headerShown: false }}/>
<Stack.Screen name="signIn" options={{ headerShown: false }}/>
</Stack.Protected>
</Stack>
NOTE: Make sure to add _layout.tsx in the (welcome) folder and have the stack defined, like this:
import { Stack } from 'expo-router';
import React from 'react';
export default function WelcomeLayout() {
return (
<Stack/>
);
}
Same here too