Four of our expo-router screens sit outside the authenticated group, so the gate had to become a hook
Munchable's phone app has one authenticated area and a handful of modal screens that are not inside it. That is a deliberate navigation choice and it quietly undid our auth gate. The layout gate, and what it ac
Munchable's phone app has one authenticated area and a handful of modal screens that are not inside it. That is a deliberate navigation choice and it quietly undid our auth gate.
The layout gate, and what it actually covers
The authenticated area is a route group with a layout that redirects:
export default function AppLayout() {
const account = useAuth((s) => s.account);
const restored = useAuth((s) => s.restored);
if (REQUIRE_ACCOUNT && !restored) return null;
if (REQUIRE_ACCOUNT && !account) return <Redirect href="/(auth)/sign-in" />;
return <Stack screenOptions={{ headerShown: false, animation: 'slide_from_right' }}>
<Stack.Screen name="home" />
<Stack.Screen name="profile" />
<Stack.Screen name="conditions" />
...
</Stack>;
}
This is the documented pattern and it works. Every screen inside the group is a child of that layout, so none of them render until the layout decides they may.
The emphasis is on "inside the group". A layout gates its own subtree and nothing else.
The four screens that are not in the subtree
The root stack declares some screens of its own:
const SHEET = { presentation: 'modal', animation: 'slide_from_bottom' } as const;
<Stack.Screen name="index" />
<Stack.Screen name="onboarding" />
<Stack.Screen name="(app)" />
<Stack.Screen name="result" options={{ presentation: 'card' }} />
<Stack.Screen name="capture" options={SHEET} />
<Stack.Screen name="menu" options={SHEET} />
<Stack.Screen name="paywall" options={SHEET} />
<Stack.Screen name="(auth)/sign-in" options={{ presentation: 'card', gestureEnabled: false }} />
Capture, menu, paywall and result are siblings of (app), not children. There is a real reason for that: they are presented as sheets that slide up over whatever is behind them, and a sheet is not a push onto the hub's stack. Putting them inside the group to inherit its gate would give them the group's sideways push animation and make them behave like destinations instead of overlays.
So in normal use they are unreachable without an account, because the only way a user gets to them is from a screen that is already gated. "The only way a user gets there" is the part that is wrong.
The app has a URL scheme, munchable://, and the Expo web build serves the same routes as real paths. munchable://capture opens the camera screen directly. The router does not care that the only button pointing at it lives behind the gate; it resolves the route and mounts the screen. Nothing in the normal flow does this, which is exactly why it went unnoticed.
The gate as a hook
The fix is not to move the screens. It is to make the gate callable:
export function useAccountGate(): React.ReactElement | null {
const account = useAuth((s) => s.account);
const restored = useAuth((s) => s.restored);
if (!REQUIRE_ACCOUNT) return null;
if (!restored) return <></>;
if (!account) return <Redirect href="/(auth)/sign-in" />;
return null;
}
Used at the top of each of the four screens, after their other hooks:
const gate = useAccountGate();
if (gate) return gate;
The return type is the design. It returns an element to render instead of the screen when the user may not see it, and null when the screen may render. That inverts the usual shape, where a hook returns a boolean and each caller writes its own if (!allowed) return <Redirect ... />. Four call sites writing that themselves is four chances to redirect somewhere slightly different, or to forget the restore case.
The return <></> for the not-yet-restored state is the detail I would have got wrong on the first try. The session is read from secure storage asynchronously at launch, so for the first moments of a cold start account is null and restored is false, which is indistinguishable from signed out if you only check one of them. Redirecting on that state means a cold start into a deep link flashes the sign-in screen and bounces back. Rendering an empty fragment holds the frame for the few milliseconds it takes to know, and the user sees the screen they asked for or the sign-in screen, never both.
Note also that the hook is called after the screen's other hooks and the result is returned before any other work. Hook order has to stay stable across renders, so the gate cannot be an early return above them. The screens that do real work on mount keep that work in effects, which do not run when the component returns a redirect.
Failing closed
The flag the whole thing rests on:
export const REQUIRE_ACCOUNT =
isSupabaseConfigured || process.env.EXPO_PUBLIC_ENV === 'production';
Two conditions, and the second exists only for the way this can go wrong.
The first is the convenient one. If no backend is configured, there is nothing to sign in to, and a local dev build with no keys should let you navigate the UI. That is a genuinely useful affordance when you are working on a screen's layout.
But read it as a security property and it is backwards: "no credentials configured" means "no authentication required". A production build that shipped with a missing or misspelled environment variable would be an app with the gate silently switched off. Nothing would crash. Every screen would open. You would find out from a user.
So production forces it on regardless of configuration. A misbuilt production app fails closed to a sign-in screen it cannot complete, which is obviously broken in the first thirty seconds of testing, instead of being subtly open.
isSupabaseConfigured also refuses to be fooled by placeholder values:
export const isSupabaseConfigured =
Boolean(supabaseUrl) &&
Boolean(supabaseAnonKey) &&
!supabaseUrl.includes('your-project-ref') &&
!supabaseAnonKey.startsWith('your-');
The example env file ships with your-project-ref in it. A truthiness check on a string that was copied and never edited is a check that passes while being wrong, which is the least useful kind.
What transfers
Three things, none specific to Expo.
A layout-level or middleware-level gate covers a subtree. Enumerate what is outside it, deliberately, as a list, and gate each one. Mine was four routes and I could name them; if you cannot name yours, that is the finding.
Your deep link surface is part of your route surface. Any route the router can resolve is reachable, whatever your UI links to.
Write the "configuration missing" branch as the locked state, not the open one, and let the environment force it on where being wrong is expensive.
If you want to see where these screens lead, the scanning flow and what it does with a label are described on the homepage, the data policy the capture screen is bound by is on the privacy page, and the conditions index covers what the result screen is actually answering for.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.