Skip to Content
Setups

Expo Router

Save the session in the device's secure storage, hold the splash screen while it restores, and keep deep links across sign-in.

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 from scratch. It signs in against DummyJSON's auth API, so it runs as written. Its src/auth.ts takes the place of Getting started's backend.ts and auth.ts. Using the client, Refresh and 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 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:

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:

{
  "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:

src/auth.ts
import {  } from "@monarcode/session-kit";
import {  } from "@monarcode/session-kit/expo";
import {  } from "@monarcode/session-kit/http";
import {  } from "zod";

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

export const  = .({
  : .(),
  : .(),
  : .(),
  : .(),
});

const  = .({
  : .(),
  : .(),
});
const  = .(.);

export const  = ({
  : "my-app",
  : ,
  : (),
  : async ({ ,  }) => {
    const  = await (`${}/auth/refresh`, {
      : "POST",
      ,
      : { "Content-Type": "application/json" },
      : .({  }),
    });
    // DummyJSON answers 401 or 403 when the refresh token is no good.
    if (. === 401 || . === 403) return null;
    if (!.) throw new (`Refresh failed (${.})`);
    return .(await .());
  },
});

export const  = (, { : `${}/` });

export async function (: string, : string) {
  const  = await (`${}/auth/login`, {
    : "POST",
    : { "Content-Type": "application/json" },
    : .({ ,  }),
  });
  if (!.) throw new (`Sign-in failed (${.})`);
  const { , , ... } = .(
    await .(),
  );
  await .({ , ,  });
}

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:

app/_layout.tsx
import {  } from "@monarcode/session-kit";
import {  } from "@monarcode/session-kit/expo";
import { ,  } from "@monarcode/session-kit/react";
import { ,  } from "expo-router";

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

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

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

export default function () {
  return (
    < ={}>
      < ={() => < {...} />}>
        < />
      </>
    </>
  );
}

function () {
  // Not signed in while locked, though a locked session keeps its user.
  const  = ();
  return (
    < ={{ : false }}>
      <. ={}>
        <. ="(app)" />
      </.>
      <. ={!}>
        <. ="login" />
      </.>
    </>
  );
}

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:

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

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

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:

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

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

export const  = ();

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:

app/login.tsx
import {  } from "react";
import { , , ,  } from "react-native";

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

export default function () {
  // DummyJSON's documented test user.
  const [, ] = ("emilys");
  const [, ] = ("emilyspass");
  const [, ] = ("");

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

  return (
    < ={{ : 1, : "center", : 24, : 12 }}>
      <
        ="Username"
        ={}
        ={}
        ="none"
      />
      <
        ="Password"
        ={}
        ={}
        
      />
      { ? < ="alert">{}</> : null}
      < ="Sign in" ={() => void ()} />
    </>
  );
}

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. The group's layout is a plain stack:

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

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

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

app/(app)/index.tsx
import { , ,  } from "react-native";

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

export default function () {
  const  = (() => .?.);
  const  = ();
  return (
    < ={{ : 1, : "center", : 24, : 12 }}>
      < ="header">Welcome, {}</>
      < ="/profile">Profile</>
      {/* No navigation: Stack.Protected then shows the sign-in screen. */}
      <
        ="Sign out"
        ={() => void .().(() => {})}
      />
    </>
  );
}

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

app/(app)/profile.tsx
import { ,  } from "react";
import { ,  } from "react-native";

import {  } from "zod";

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

const  = .({
  : .(),
  : .(),
});

export default function () {
  const [, ] = <.<typeof >>();
  const [, ] = ("");

  (function () {
    const  = new ();
    ("auth/me", { : . })
      .(async () => {
        if (!.)
          throw new (`Profile failed (${.})`);
        (.(await .()));
      })
      .((: unknown) => {
        if (..) return;
        ( instanceof  ? . : "Could not load");
      });
    return function () {
      .();
    };
  }, []);

  if () return < ="alert">{}</>;
  if (!) return <>Loading…</>;
  return (
    < ={{ : 24, : 8 }}>
      < ="header">
        {.} {.}
      </>
      <>{.}</>
      <>{.}</>
    </>
  );
}

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:

src/unavailable.tsx
import { , ,  } from "react-native";

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

import {  } from "./auth";

export function ({ ,  }: ) {
  return (
    < ={{ : 1, : "center", : 24, : 12 }}>
      < ="header">Can't reach your account</>
      <>{?. ?? "Check your connection and try again."}</>
      < ="Try again" ={} />
      {/* Works offline: it clears the session on this device. */}
      <
        ="Sign out"
        ={() => void .().(() => {})}
      />
    </>
  );
}

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

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 keeps the session out of Auto Backup.

SessionGate

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 covers ready and unavailable.

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:

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 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 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 adds the Face ID permission. This example uses @noble/ciphers and expo-crypto, your app's dependencies, not Session kit's:

src/session-key.ts
import {  } from "react-native";

import type {  } from "@monarcode/session-kit";
import {  } from "@noble/ciphers/aes.js";
import {
  ,
  ,
  ,
  ,
} from "@noble/ciphers/utils.js";
import {  } from "expo-crypto";
import * as  from "expo-secure-store";

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

function (: boolean): . {
  return 
    ? {
        : true,
        : "Unlock your account",
      }
    : {};
}

// In memory from an unlock or a password sign-in until the app locks.
let :  | 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 (: ): <void> {
  const  = .();
  await .(
    ,
    (),
    (),
  );
  await .(, ());
}

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

/**
 * 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 (): <void> {
  const  = await ();
  if () {
     = ();
    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  = (32);
  await ();
  if (. === "ios") await ();
   = ;
}

/**
 * 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 <>(: () => <>): <> {
  const  = ;
  const  = (32);
   = ;
  let : ;
  try {
     = await ();
  } catch () {
    if ( === )  = ;
    throw ;
  }
  try {
    await ();
  } catch () {
    // The session works until the app closes; then the password unlocks it.
    .("Could not save the session key", );
  }
  return ;
}

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

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

/** AES-256-GCM: a random 12-byte nonce, then the ciphertext, as hex. */
export const :  = {
  () {
    const  = (12);
    const  = ((), ).(());
    return () + ();
  },
  () {
    const  = ();
    const  = .(0, 12);
    return (((), ).(.(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:

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:

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:

app/welcome-back.tsx
import {  } from "react";
import { , ,  } from "react-native";

import {  } from "expo-router";

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

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

export default function () {
  const  = (() => .?.);
  const [, ] = (false);

  async function () {
    (false);
    try {
      await ();
      await .();
      // 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 (..(). === "locked") (true);
    }
  }

  return (
    < ={{ : 1, : "center", : 24, : 12 }}>
      <>Welcome back{ ? `, ${}` : ""}</>
      < ="Unlock" ={() => void ()} />
      { ? < ="alert">Could not unlock</> : null}
      < ="/login">Use your password</>
    </>
  );
}

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

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

On this page