expo icon indicating copy to clipboard operation
expo copied to clipboard

Protected routes not working

Open Joozty opened this issue 10 months ago • 4 comments

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!

Joozty avatar Jun 08 '25 20:06 Joozty

The same for me, on my side the code from guide works incorrectly.

zhulduz avatar Jun 09 '25 11:06 zhulduz

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>
  );
}

zhulduz avatar Jun 09 '25 11:06 zhulduz

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.

risingus avatar Jun 10 '25 12:06 risingus

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.

risingus avatar Jun 10 '25 12:06 risingus

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

fermmm avatar Jun 19 '25 22:06 fermmm

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.

mmounirf avatar Jul 13 '25 19:07 mmounirf

I encountered the same issue, and also had to use a check w/ <Redirect>.

rhino88 avatar Jul 15 '25 21:07 rhino88

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 />;
}

lucasnovelo avatar Jul 17 '25 19:07 lucasnovelo

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

rhino88 avatar Jul 17 '25 20:07 rhino88

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?

axeloehrli avatar Jul 18 '25 02:07 axeloehrli

Image Image

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

rindev0901 avatar Jul 20 '25 15:07 rindev0901

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 🤔

pawicao avatar Aug 16 '25 12:08 pawicao

Still having this problem. Any ideas?

s0h311 avatar Sep 06 '25 09:09 s0h311

Any updates on this with expo 54?

rhino88 avatar Sep 15 '25 12:09 rhino88

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" />;
}

TmLev avatar Sep 15 '25 15:09 TmLev

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>
...

Cnordbo avatar Sep 15 '25 20:09 Cnordbo

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/>
  );
}

jtvargas avatar Oct 03 '25 21:10 jtvargas

Same here too

GabrielLeandroBS avatar Nov 13 '25 13:11 GabrielLeandroBS