Skip to content

The 6-screen auth flow in Expo Router: login → 2FA → tenant selection → app

4/22/2026Mobile Development8 min read

The 6-screen auth flow in Expo Router: login → 2FA → tenant selection → app

Six screens, three layout groups, and a Zustand-gated redirect — how the full multi-tenant auth flow is wired in Expo Router.

Most boilerplates ship a login screen and call it auth. The real surface area is larger: session restore on cold start, conditional 2FA, a tenant selection step for multi-workspace SaaS, and the edge case where a user has no tenants yet and needs to create one before seeing any app content. Getting that wrong means either broken redirects, double renders, or token state leaking into the wrong layer.

Here is exactly how this boilerplate handles it — file by file, decision by decision.

What the user actually sees

From a fresh install, the sequence is:

  1. Splash screen hides after fonts load.
  2. If a stored accessToken exists, the app silently calls GET /api/system/auth/session. On success, the user lands at /select-tenant — never at /login.
  3. If no token or the session call fails, the user sees /login.
  4. After a successful login, the API returns a userSecurity object. If otpVerifyNeeded is true, the user is pushed to /2fa. Otherwise they go directly to /select-tenant.
  5. At /select-tenant, the app fetches the user's tenant memberships. If they have at least one, they pick it and land at /. If they have none, a "Create New Workspace" button routes them to /create-tenant.
  6. Once a membership is selected and stored, every subsequent cold start with a valid session skips straight to /.

Six screens. Each with a specific job. None of them decides routing on its own — routing is driven by state in the Zustand stores, not by screen-level logic.

The layout that gates everything

The file app/(auth)/_layout.tsx is six lines of real work:

// app/(auth)/_layout.tsx
import { Redirect, Stack } from "expo-router";
import { useAuthStore } from "@/stores/authStore";

export default function AuthLayout() {
  const isAuthenticated = useAuthStore((s) => s.isAuthenticated);

  if (isAuthenticated) {
    return <Redirect href="/" />;
  }

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="login" />
      <Stack.Screen name="register" />
      <Stack.Screen name="2fa" />
      <Stack.Screen name="select-tenant" />
      <Stack.Screen name="forgot-password" />
    </Stack>
  );
}

The isAuthenticated flag lives in authStore and is persisted to MMKV via zustandMMKVStorage. When the user opens the app with a previously authenticated session, the store hydrates from disk before this layout renders. If the flag is already true, they never see a login screen — they get a <Redirect href="/" /> immediately.

The critical constraint here: isAuthenticated is set to true only after setUser() is called in the root layout's session restore, not after the accessToken is confirmed to exist in SecureStore. The token is a precondition for the session call; isAuthenticated is a consequence of the session call succeeding. These are different events and they should be tracked separately.

Session restore in the root layout

The root layout (app/_layout.tsx) runs before any group layout. This is where cold-start session restore happens:

// app/_layout.tsx (load-bearing section)
const setUser = useAuthStore((s) => s.setUser);
const setAuthenticated = useAuthStore((s) => s.setAuthenticated);

useEffect(() => {
  async function restoreSession() {
    try {
      const token = await getToken("accessToken");
      if (!token) {
        setAuthenticated(false);
        return;
      }
      const user = await AuthClientService.getSession();
      setUser(user);
    } catch {
      logger.warn("Session restore failed");
      setAuthenticated(false);
    }
  }
  restoreSession();
}, [setUser, setAuthenticated]);

getToken("accessToken") reads from expo-secure-store. If the token is missing, isAuthenticated is set to false explicitly — not left as whatever was persisted in MMKV. This matters because MMKV state survives reinstalls on some Android configurations; you do not want a ghost isAuthenticated: true with no corresponding token.

If the token exists, the app calls AuthClientService.getSession(), which hits GET /api/system/auth/session and parses the response through SafeUserSchema. On success, setUser(user) sets both user and isAuthenticated: true atomically inside the store action. On failure (expired token, revoked session), the catch block calls setAuthenticated(false).

The login → 2FA branch

The login screen's submit handler shows the conditional OTP branch clearly:

// app/(auth)/login.tsx
async function handleLogin() {
  setLoading(true);
  try {
    const { user, userSecurity } = await AuthClientService.login({ email, password });
    if (userSecurity?.otpVerifyNeeded) {
      await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
      router.push("/2fa");
      return;
    }
    setUser(user);
    await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
    router.replace("/select-tenant");
  } catch (err: unknown) {
    await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
    toast.error(extractErrorMessage(err));
  } finally {
    setLoading(false);
  }
}

AuthClientService.login() parses the server response through LoginResponseSchema, which includes both user (a SafeUser) and userSecurity (a UserSecurity with the otpVerifyNeeded flag). If OTP is needed, setUser() is deliberately not called — the user is not yet authenticated, they are mid-flow. The 2FA screen completes the authentication:

// app/(auth)/2fa.tsx
async function handleVerify() {
  const user = await AuthClientService.verifyOTP(code, method);
  setUser(user);
  router.replace("/select-tenant");
}

verifyOTP calls POST /api/system/auth/otp/verify and parses the user from the response through SafeUserSchema.parse(res.data?.user). After setUser(), the (auth)/_layout.tsx will produce a <Redirect href="/" /> on its next render — but router.replace("/select-tenant") fires first. This is intentional: tenant selection is a required step before the main app.

The tenant selection step

Multi-tenant SaaS apps often skip this screen or hardcode the first tenant. This boilerplate treats it as a first-class screen:

// app/(auth)/select-tenant.tsx
useEffect(() => {
  async function load() {
    const { tenants } = await TenantClientService.getMyTenants();
    setMemberships(tenants);
    setMembershipsInStore(tenants);
  }
  load();
}, [setMembershipsInStore]);

function handleSelect(member: TenantMember) {
  selectMembership(member);
  router.replace("/");
}

selectMembership() stores the chosen TenantMember in tenantStore, which is also persisted via MMKV. On the next app open with a valid session, the tenant store hydrates with selectedTenantMembership already set — the app can skip the selection screen entirely in the root layout if the selection is still valid.

The ListFooterComponent at the bottom of the FlatList renders a "Create New Workspace" button that routes to /create-tenant. This is the empty-state escape hatch: a brand-new user who has not yet been added to any tenant still has a clear path forward without a dead end.

What the tenant store looks like

// stores/tenantStore.ts
export const useTenantStore = create<TenantState>()(
  persist(
    (set): TenantState => ({
      selectedTenantMembership: null,
      memberships: [],
      setMemberships: (memberships) => set({ memberships }),
      selectMembership: (membership) =>
        set({ selectedTenantMembership: membership }),
      flush: () =>
        set({ selectedTenantMembership: null, memberships: [] }),
    }),
    {
      name: "tenant-storage",
      storage: createJSONStorage(() => zustandMMKVStorage),
      partialize: (state) => ({
        selectedTenantMembership: state.selectedTenantMembership,
        memberships: state.memberships,
      }),
    },
  ),
);

flush() exists for logout: when the user signs out, both the auth store and the tenant store need to be cleared. Calling flush() on logout prevents the next user on the same device from inheriting a persisted tenant selection.

The thing that almost went differently

The original design put the session restore call inside (auth)/_layout.tsx rather than the root layout. That worked until the tabs group needed the user object on mount — the (tabs)/_layout.tsx rendered before the session call completed, producing a flash of unauthenticated state. Moving session restore to the root layout means it runs before any group renders. The root layout returns null while fonts are loading anyway, which gives the session call time to complete before the first meaningful render.

Trade-off

This flow requires one extra network call on cold start (the session restore). Users on slow connections will see the splash screen for slightly longer than they would with a purely local auth check. The alternative — trusting the persisted isAuthenticated flag without verifying the session — risks showing authenticated UI to users whose sessions were revoked server-side. The network call is the right trade-off for any app that supports session revocation.

Business impact

Multi-tenant auth is the gate to the product. When this flow breaks — or more commonly when it just feels slow and unreliable — users churn at the point of highest intent. A cold-start session restore that completes before the splash screen hides means the returning user never sees a login screen at all. That is a measurable retention signal, and it is cheap to get right from day one with this pattern. Getting it wrong after launch means every user migration to the new auth flow is a support event.

What to do next

If you are building a multi-tenant mobile app, the question worth checking is whether your current session restore runs before or after your first authenticated layout renders. Open your root layout file and look for the getSession or equivalent call. If it is inside a route-group layout rather than the root, you are likely producing a flash of unauthenticated state on cold start. Moving it up one level is usually a one-file change.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox