# Expo Router (https://session-kit.pages.dev/docs/setups/expo)

In this guide, you build an Expo Router app for iOS and Android that keeps
the session in the device's secure storage, holds the splash screen while it
restores, and keeps deep links across sign-in. Expo Router's
`Stack.Protected` guards the screens, and `SessionGate` wraps the root
navigator. It holds the splash screen while the session is restored,
renders `unavailable` after a failed refresh, and checks the session again
when the app returns to the foreground.

This page builds the
[`expo-router` example app](https://github.com/monarcode/session-kit/tree/main/examples/expo-router)
from scratch. It signs in against
[DummyJSON's auth API](https://dummyjson.com/docs/auth), so it runs as
written. Its `src/auth.ts` takes the place of Getting started's `backend.ts`
and `auth.ts`. [Using the client](https://session-kit.pages.dev/docs/client),
[Refresh](https://session-kit.pages.dev/docs/configuration/refresh) and
[Errors](https://session-kit.pages.dev/docs/configuration/errors) explain how the client works. This page
covers what differs on Expo.

## Before you begin

- Expo SDK 53 or newer: Expo Router 5 or newer, and React Native 0.79 or
  newer.
- iOS and Android only. Expo web is not supported. On the web, use a
  [web router setup](https://session-kit.pages.dev/docs/installation#pick-your-setup) with `webStorage()`.
- Expo Go, which includes `expo-secure-store` and `expo-file-system`, or a
  development build with both installed.

## Installation

Install the package, its Expo peers, and a schema library. `npx expo install`
picks the peer versions that match your SDK:

```sh
npx expo install @monarcode/session-kit expo-secure-store expo-file-system zod
```

An Expo Router app already has `expo-router` and `react-native`. Holding the
splash screen needs `expo-splash-screen`, so install it too if your app
doesn't have it. Add the `expo-secure-store` config plugin to `app.json`, so
Android Auto Backup leaves the session out of backups:

```json
{
  "expo": {
    "scheme": "myapp",
    "plugins": ["expo-router", "expo-secure-store"]
  }
}
```

`scheme` is what custom-scheme deep links such as `myapp://profile` use.

## 1. Create the client

`secureStorage()` replaces `webStorage()`. The rest of the client is the same
as on the web:

```ts title="src/auth.ts"
import { createAuth } from "@monarcode/session-kit";
import { secureStorage } from "@monarcode/session-kit/expo";
import { createAuthFetch } from "@monarcode/session-kit/http";
import { z } from "zod";

const API = "https://dummyjson.com";

export const userSchema = z.object({
  id: z.number(),
  username: z.string(),
  email: z.string(),
  firstName: z.string(),
});

const tokensSchema = z.object({
  accessToken: z.string(),
  refreshToken: z.string(),
});
const loginResponseSchema = tokensSchema.extend(userSchema.shape);

export const auth = createAuth({
  name: "my-app",
  user: userSchema,
  storage: secureStorage(),
  refresh: async ({ refreshToken, signal }) => {
    const response = await fetch(`${API}/auth/refresh`, {
      method: "POST",
      signal,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refreshToken }),
    });
    // DummyJSON answers 401 or 403 when the refresh token is no good.
    if (response.status === 401 || response.status === 403) return null;
    if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
    return tokensSchema.parse(await response.json());
  },
});

export const authFetch = createAuthFetch(auth, { baseUrl: `${API}/` });

export async function signInWithPassword(username: string, password: string) {
  const response = await fetch(`${API}/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  if (!response.ok) throw new Error(`Sign-in failed (${response.status})`);
  const { accessToken, refreshToken, ...user } = loginResponseSchema.parse(
    await response.json(),
  );
  await auth.signIn({ accessToken, refreshToken, user });
}
```

## 2. Gate the root layout

`SessionGate` goes inside `AuthProvider`, around the root navigator. Guard the
screens with `Stack.Protected`. Signed-in users see the `(app)` group, and
everyone else sees `login`. Register the client's type here, so the hooks
from `@monarcode/session-kit/react` carry your user:

```tsx title="app/_layout.tsx"
import { isSignedIn } from "@monarcode/session-kit";
import { SessionGate } from "@monarcode/session-kit/expo";
import { AuthProvider, useAuth } from "@monarcode/session-kit/react";
import { SplashScreen, Stack } from "expo-router";

import { auth } from "../src/auth";
import { Unavailable } from "../src/unavailable";

// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
  interface Register {
    auth: typeof auth;
  }
}

// Keeps the splash screen up until SessionGate hides it.
void SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  return (
    <AuthProvider client={auth}>
      <SessionGate unavailable={(props) => <Unavailable {...props} />}>
        <RootStack />
      </SessionGate>
    </AuthProvider>
  );
}

function RootStack() {
  // Not signed in while locked, though a locked session keeps its user.
  const signedIn = useAuth(isSignedIn);
  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={signedIn}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
      <Stack.Protected guard={!signedIn}>
        <Stack.Screen name="login" />
      </Stack.Protected>
    </Stack>
  );
}
```

`RootStack` is its own component so that `useAuth` runs inside
`AuthProvider`. The navigator mounts only once the session is restored, so the
guards never see `initializing`.

If the first screen needs more than the session, such as fonts, pass `ready`.
The splash screen then stays up until both are done. Count a failed load as
done, or the splash screen never hides:

```tsx
const [fontsLoaded, fontError] = useFonts(fonts);

<SessionGate
  ready={fontsLoaded || fontError !== null}
  unavailable={(props) => <Unavailable {...props} />}
>
  <RootStack />
</SessionGate>;
```

## 3. Keep deep links

`Stack.Protected` sends a link to a guarded screen to the first screen the
user may see, so a signed-out user who opens `myapp://profile` lands on the
sign-in screen and the link is lost. `deepLinkHandler` keeps it, and
`SessionGate` opens it after the sign-in. Expo Router calls
`redirectSystemPath` with every URL that opens the app, before routing:

```tsx title="app/+native-intent.tsx"
import { deepLinkHandler } from "@monarcode/session-kit/expo";

import { auth } from "../src/auth";

export const redirectSystemPath = deepLinkHandler(auth);
```

It returns the path unchanged. The link still goes to Expo Router, which
shows it right away when the user is signed in. If your `redirectSystemPath`
does more, such as rewriting old links, call `rememberDeepLink(auth, path)`
in it instead.

## 4. Add the sign-in screen

The sign-in screen calls `signInWithPassword` from `src/auth.ts`:

```tsx title="app/login.tsx"
import { useState } from "react";
import { Button, Text, TextInput, View } from "react-native";

import { signInWithPassword } from "../src/auth";

export default function Login() {
  // DummyJSON's documented test user.
  const [username, setUsername] = useState("emilys");
  const [password, setPassword] = useState("emilyspass");
  const [error, setError] = useState("");

  async function submit() {
    setError("");
    try {
      await signInWithPassword(username, password);
      // No navigation: Stack.Protected leaves this screen, and
      // SessionGate opens the deep link the user was headed to.
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : "Could not sign in");
    }
  }

  return (
    <View style={{ flex: 1, justifyContent: "center", padding: 24, gap: 12 }}>
      <TextInput
        accessibilityLabel="Username"
        value={username}
        onChangeText={setUsername}
        autoCapitalize="none"
      />
      <TextInput
        accessibilityLabel="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      {error ? <Text accessibilityRole="alert">{error}</Text> : null}
      <Button title="Sign in" onPress={() => void submit()} />
    </View>
  );
}
```

Don't navigate after `signIn`. Once the user is signed in, `Stack.Protected`
removes the sign-in screen, and a `router.replace` of your own would race the
deep link `SessionGate` opens.

## 5. Add the signed-in screens

Screens inside the guarded group read the user with `useAuth`, and call the API
with `authFetch`, which attaches the access token and refreshes after a 401. To
use axios or another HTTP client instead, see
[Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). The group's layout is a
plain stack:

```tsx title="app/(app)/_layout.tsx"
import { Stack } from "expo-router";

export default function AppLayout() {
  return <Stack />;
}
```

Its first screen shows the user's name, links to the profile, and signs out:

```tsx title="app/(app)/index.tsx"
import { Button, Text, View } from "react-native";

import { useAuth, useAuthClient } from "@monarcode/session-kit/react";
import { Link } from "expo-router";

export default function Home() {
  const firstName = useAuth((state) => state.user?.firstName);
  const auth = useAuthClient();
  return (
    <View style={{ flex: 1, justifyContent: "center", padding: 24, gap: 12 }}>
      <Text accessibilityRole="header">Welcome, {firstName}</Text>
      <Link href="/profile">Profile</Link>
      {/* No navigation: Stack.Protected then shows the sign-in screen. */}
      <Button
        title="Sign out"
        onPress={() => void auth.signOut().catch(() => {})}
      />
    </View>
  );
}
```

The profile screen fetches the user from the API. It's also where a deep
link such as `myapp://profile` goes:

```tsx title="app/(app)/profile.tsx"
import { useEffect, useState } from "react";
import { Text, View } from "react-native";

import { z } from "zod";

import { authFetch, userSchema } from "../../src/auth";

const profileSchema = userSchema.extend({
  lastName: z.string(),
  gender: z.string(),
});

export default function Profile() {
  const [profile, setProfile] = useState<z.infer<typeof profileSchema>>();
  const [error, setError] = useState("");

  useEffect(function loadProfile() {
    const controller = new AbortController();
    authFetch("auth/me", { signal: controller.signal })
      .then(async (response) => {
        if (!response.ok)
          throw new Error(`Profile failed (${response.status})`);
        setProfile(profileSchema.parse(await response.json()));
      })
      .catch((cause: unknown) => {
        if (controller.signal.aborted) return;
        setError(cause instanceof Error ? cause.message : "Could not load");
      });
    return function cancelLoad() {
      controller.abort();
    };
  }, []);

  if (error) return <Text accessibilityRole="alert">{error}</Text>;
  if (!profile) return <Text>Loading…</Text>;
  return (
    <View style={{ padding: 24, gap: 8 }}>
      <Text accessibilityRole="header">
        {profile.firstName} {profile.lastName}
      </Text>
      <Text>{profile.email}</Text>
      <Text>{profile.gender}</Text>
    </View>
  );
}
```

## 6. Show unavailable access

Access is `unavailable` when the session exists but can't be used: a refresh
failed, often because the device is offline, or the keychain couldn't be
read. `SessionGate` then renders `unavailable` instead of the navigator:

```tsx title="src/unavailable.tsx"
import { Button, Text, View } from "react-native";

import type { UnavailableProps } from "@monarcode/session-kit/expo";

import { auth } from "./auth";

export function Unavailable({ error, retry }: UnavailableProps) {
  return (
    <View style={{ flex: 1, justifyContent: "center", padding: 24, gap: 12 }}>
      <Text accessibilityRole="header">Can't reach your account</Text>
      <Text>{error?.message ?? "Check your connection and try again."}</Text>
      <Button title="Try again" onPress={retry} />
      {/* Works offline: it clears the session on this device. */}
      <Button
        title="Sign out"
        onPress={() => void auth.signOut().catch(() => {})}
      />
    </View>
  );
}
```

Without `unavailable`, `SessionGate` hides the splash screen and throws the
error to the root layout's `ErrorBoundary`. The boundary's `retry` only
renders again, and a failed refresh stays failed until `auth.retry()`, so
have the boundary call both.

To check the app:

1. Sign in. The home screen shows your name. Open the profile to see your
   email.
2. Close the app and open it again. The splash screen stays up until the
   session is restored, then the home screen shows.
3. Sign out. The sign-in screen shows.
4. Signed out, open `myapp://profile`. On the iOS Simulator, run
   `xcrun simctl openurl booted myapp://profile`. In Expo Go, open
   `exp://<host>:8081/--/profile` instead. Sign in. The profile screen opens.

## Secure storage

[`secureStorage()`](https://session-kit.pages.dev/docs/reference/expo#securestorage) saves the session
with `expo-secure-store`, in the iOS keychain and in Keystore-encrypted
storage on Android. A session over 2048 bytes, which some iOS releases refuse
to store in one value, is split across several keys, up to 128 KB. By
default, a reinstalled app starts signed out. So does an app whose saved
session Android can't decrypt, as after a backup is restored onto another
device. Its options are in
[`SecureStorageOptions`](https://session-kit.pages.dev/docs/reference/expo#securestorageoptions).

Two things depend on your app:

- **Background refreshes.** By default, iOS lets the app read the keychain
  only while the device is unlocked. While the keychain can't be read, the
  session is `unavailable`, never signed out. An app that refreshes in a
  background task while the device is locked needs
  `secureStorage({ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK })`.
- **Android backups.** The `expo-secure-store` config plugin from
  [Installation](#installation) keeps the session out of Auto Backup.

## SessionGate

[`SessionGate`](https://session-kit.pages.dev/docs/reference/expo#sessiongate) adds what `Stack.Protected`
lacks. It holds the splash screen while the session is restored, shows
`unavailable` when access can't be used, checks the session again when the
app returns to the foreground, and opens deep links kept for after sign-in.
While the session is locked, it renders the navigator, so your guards can
show an unlock screen. When one account replaces another without a sign-out
between them, it remounts the navigator, so no screen keeps the previous
account's state. [`SessionGateProps`](https://session-kit.pages.dev/docs/reference/expo#sessiongateprops)
covers `ready` and `unavailable`.

## Deep links

[`deepLinkHandler`](https://session-kit.pages.dev/docs/reference/expo#deeplinkhandler) keeps one link at a
time, and `SessionGate` opens it after the next sign-in or unlock. A link to
the root or the sign-in screen is ignored, as is one that could leave the
app. A link that arrives while the user is signed in is dropped, since Expo
Router opens it as usual. A link to a screen anyone may see is dropped once
that screen shows. If the app launched with it, it's dropped at the sign-in
instead, unopened.

If your sign-in screen isn't `/login`, pass
`deepLinkHandler(auth, { loginPath: "/sign-in" })`.

Where `Stack.Protected` takes a `redirectTo`, as in Expo SDK 58, it only
chooses the screen a turned-away link goes to, such as `/login`. It doesn't
keep the link, so `deepLinkHandler` is still what opens it after the sign-in.

There is no `?redirectTo=` parameter on Expo. The link waits in memory, not
in the URL, and an app restart forgets it.

## Debugging

Turn on the client's `debug` option to see what Session kit does in Metro's
logs:

```ts
export const auth = createAuth({
  // ...
  debug: __DEV__,
});
```

`SessionGate` and deep links report through the same option. They log why
the splash screen is held (restoring, or waiting for `ready`), when it hides,
and what happened to a remembered link. `secureStorage()` adds what only it
can see, such as removing a session an earlier install left.
[Debugging](https://session-kit.pages.dev/docs/configuration/debugging) covers what events leave out, and
how to send them somewhere other than the console.

## Locking

A returning user can be asked to unlock before the app opens: a
"Welcome back" screen with Face ID, Touch ID, or a fingerprint, and the
password as a fallback. With `locking`, a saved session restores as `locked`,
with `user` and `sessionId` set and its tokens sealed by a key your app holds.
[Locking](https://session-kit.pages.dev/docs/client/locking) covers how it behaves. This section is the
Expo side.

Keep a random key in a SecureStore item that needs biometrics to read, and
seal the tokens with AES-GCM. SecureStore's biometric prompt needs a
development build on a real device. Expo Go doesn't support it, and
simulators don't ask for biometrics. On iOS, the `expo-secure-store` config
plugin from [Installation](#installation) adds the Face ID permission. This
example uses `@noble/ciphers` and `expo-crypto`, your app's dependencies, not
Session kit's:

```ts title="src/session-key.ts"
import { Platform } from "react-native";

import type { AuthLocking } from "@monarcode/session-kit";
import { gcm } from "@noble/ciphers/aes.js";
import {
  bytesToHex,
  bytesToUtf8,
  hexToBytes,
  utf8ToBytes,
} from "@noble/ciphers/utils.js";
import { getRandomBytes } from "expo-crypto";
import * as SecureStore from "expo-secure-store";

const KEY_ITEM = "session-key";
// Whether the key was saved behind biometrics, so it's read the same way.
const BIOMETRIC_ITEM = "session-key-biometric";

function keyOptions(biometric: boolean): SecureStore.SecureStoreOptions {
  return biometric
    ? {
        requireAuthentication: true,
        authenticationPrompt: "Unlock your account",
      }
    : {};
}

// In memory from an unlock or a password sign-in until the app locks.
let key: Uint8Array | undefined;

/**
 * Saves the key behind the device's biometrics, or, on a device with none
 * enrolled, without them. On Android, saving shows the prompt.
 */
async function saveKey(next: Uint8Array): Promise<void> {
  const biometric = SecureStore.canUseBiometricAuthentication();
  await SecureStore.setItemAsync(
    KEY_ITEM,
    bytesToHex(next),
    keyOptions(biometric),
  );
  await SecureStore.setItemAsync(BIOMETRIC_ITEM, String(biometric));
}

/**
 * Reads the key, which shows the prompt when it needs biometrics. Resolves
 * `null` when no key was saved yet.
 */
async function readKey(): Promise<string | null> {
  const biometric = await SecureStore.getItemAsync(BIOMETRIC_ITEM);
  if (biometric === null) return null;
  const saved = await SecureStore.getItemAsync(
    KEY_ITEM,
    keyOptions(biometric === "true"),
  );
  // SecureStore reads `null` for a key that a change to the device's
  // biometrics made unreadable.
  if (saved === null) throw new Error("The session key can't be read");
  return saved;
}

/**
 * Loads the key to unlock. Throws when the user cancels the prompt, or when
 * the device's biometrics changed and the key can't be read.
 */
export async function loadKey(): Promise<void> {
  const saved = await readKey();
  if (saved) {
    key = hexToBytes(saved);
    return;
  }
  // A session saved before locking was added has no key yet. Make one: on
  // iOS, saving doesn't prompt, so read it back to show the prompt.
  const next = getRandomBytes(32);
  await saveKey(next);
  if (Platform.OS === "ios") await readKey();
  key = next;
}

/**
 * Signs in with a new key, and saves it only once the sign-in succeeds: a
 * failed sign-in keeps the key that the saved session is sealed with.
 */
export async function withNewKey<T>(signIn: () => Promise<T>): Promise<T> {
  const previous = key;
  const next = getRandomBytes(32);
  key = next;
  let result: T;
  try {
    result = await signIn();
  } catch (error) {
    if (key === next) key = previous;
    throw error;
  }
  try {
    await saveKey(next);
  } catch (error) {
    // The session works until the app closes; then the password unlocks it.
    console.warn("Could not save the session key", error);
  }
  return result;
}

/** Forgets the key in memory, once `auth.lock()` has settled. */
export function dropKey(): void {
  key = undefined;
}

function loadedKey(): Uint8Array {
  if (!key) throw new Error("The session key isn't loaded");
  return key;
}

/** AES-256-GCM: a random 12-byte nonce, then the ciphertext, as hex. */
export const sessionLocking: AuthLocking = {
  seal(payload) {
    const nonce = getRandomBytes(12);
    const sealed = gcm(loadedKey(), nonce).encrypt(utf8ToBytes(payload));
    return bytesToHex(nonce) + bytesToHex(sealed);
  },
  open(sealed) {
    const bytes = hexToBytes(sealed);
    const nonce = bytes.subarray(0, 12);
    return bytesToUtf8(gcm(loadedKey(), nonce).decrypt(bytes.subarray(12)));
  },
};
```

A device with no biometrics enrolled can't save an item that needs them, so
there the key is saved without them, and unlocking asks for nothing beyond
the device's own lock screen. If your app needs more on those devices, sign
out instead of locking.

Pass it to the client, and sign in with a new key, so the new session is
sealed with it:

```ts
export const auth = createAuth({
  // ...
  locking: sessionLocking,
});

export async function signInWithPassword(username: string, password: string) {
  // ...
  await withNewKey(() => auth.signIn({ accessToken, refreshToken, user }));
}
```

Guard the screens with `isSignedIn` and `status`, since a locked session
keeps its user. The unlock screen comes before the sign-in screen, so a
locked app opens on it, and the sign-in screen stays open to a locked user
for the password path:

```tsx
import { isSignedIn } from "@monarcode/session-kit";

function RootStack() {
  const signedIn = useAuth(isSignedIn);
  const locked = useAuth((state) => state.status === "locked");
  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={signedIn}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
      <Stack.Protected guard={locked}>
        <Stack.Screen name="welcome-back" />
      </Stack.Protected>
      <Stack.Protected guard={!signedIn}>
        <Stack.Screen name="login" />
      </Stack.Protected>
    </Stack>
  );
}
```

The unlock screen reads the key, which shows the biometric prompt, then
unlocks:

```tsx title="app/welcome-back.tsx"
import { useState } from "react";
import { Button, Text, View } from "react-native";

import { Link } from "expo-router";

import { useAuth } from "@monarcode/session-kit/react";

import { auth } from "../src/auth";
import { loadKey } from "../src/session-key";

export default function WelcomeBack() {
  const name = useAuth((state) => state.user?.firstName);
  const [failed, setFailed] = useState(false);

  async function unlock() {
    setFailed(false);
    try {
      await loadKey();
      await auth.unlock();
      // No navigation: Stack.Protected leaves this screen, and
      // SessionGate opens the deep link the user was headed to.
    } catch {
      // Cancelled, or the biometrics changed: offer the password. A refresh
      // that failed after unlocking leaves the session `unavailable`, which
      // SessionGate shows instead.
      if (auth.state.get().status === "locked") setFailed(true);
    }
  }

  return (
    <View style={{ flex: 1, justifyContent: "center", padding: 24, gap: 12 }}>
      <Text>Welcome back{name ? `, ${name}` : ""}</Text>
      <Button title="Unlock" onPress={() => void unlock()} />
      {failed ? <Text accessibilityRole="alert">Could not unlock</Text> : null}
      <Link href="/login">Use your password</Link>
    </View>
  );
}
```

To lock again after some time in the background, lock when the app returns
to the foreground, then drop the key:

```ts
let backgroundedAt: number | undefined;
AppState.addEventListener("change", (next) => {
  if (next === "background") backgroundedAt = Date.now();
  else if (next === "active" && backgroundedAt !== undefined) {
    if (Date.now() - backgroundedAt > 5 * 60_000)
      void auth.lock().then(dropKey);
    backgroundedAt = undefined;
  }
});
```

Call `dropKey()` after `auth.signOut()` too. While locked, `SessionGate`
re-checks nothing on a return to the foreground, and requests through
`authFetch` reject with `LOCKED` until the user unlocks.

## Not supported

- **Expo web.** `secureStorage()` and `SessionGate` are for iOS and Android.
- **`requireAuthentication`.** `secureStorage()` doesn't use SecureStore's
  biometric prompt. A session that refreshes on its own can't ask for a face
  or fingerprint on every read and write. Keep a key behind the prompt
  instead, and seal the session with it, as [Locking](#locking) shows.
- **Network status.** Session kit doesn't watch connectivity. When your app
  learns the device is back online, for example from
  `@react-native-community/netinfo`, call `auth.retry()`, or the `retry` that
  `unavailable` receives.

## Next steps

- [Using the client](https://session-kit.pages.dev/docs/client): updating the user, signing out, and the
  client's other methods.
- [Locking](https://session-kit.pages.dev/docs/client/locking): how a locked session behaves.
- [Effect and effetch](https://session-kit.pages.dev/docs/effect): the same client as an Effect service.