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,
auth.ts and hooks.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-storeandexpo-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 zodAn Expo Router app already has expo-router and react-native. 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:
import { } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/expo";
import { } from "@monarcode/session-kit/http";
import { } from "@monarcode/session-kit/react";
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 { , , } =
<typeof >();
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:
import { } from "@monarcode/session-kit/expo";
import { , } from "expo-router";
import { , , } from "../src/auth";
import { } from "../src/unavailable";
// Keeps the splash screen up until SessionGate hides it.
void .();
export default function () {
return (
< ={}>
< ={() => < {...} />}>
< />
</>
</>
);
}
function () {
// `status`, not `user`: a locked session keeps its user. See Locking.
const = (() => .);
const = === "authenticated" || === "refreshing";
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:
const [fontsLoaded] = useFonts(fonts);
<SessionGate
ready={fontsLoaded}
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. rememberDeepLink keeps it, and
SessionGate opens it after the sign-in. Expo Router calls
redirectSystemPath with every URL that opens the app, before routing:
import { } from "@monarcode/session-kit/expo";
import { } from "../src/auth";
export function ({
,
}: {
: string;
: boolean;
}) {
(, );
return ;
}Return path unchanged: the link still goes to Expo Router, which shows it
right away when the user is signed in.
4. Add the sign-in screen
The sign-in screen calls signInWithPassword from src/auth.ts:
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. The
group's layout is a plain stack:
import { } from "expo-router";
export default function () {
return < />;
}Its first screen shows the user's name, fetches their email, and signs out:
import { , } from "react";
import { , , } from "react-native";
import { , , } from "../../src/auth";
export default function () {
const = (() => .?.);
const = ();
const [, ] = ("");
(function () {
const = new ();
("auth/me", { : . })
.(() => .())
.((: { : string }) => (.))
.(() => {});
return function () {
.();
};
}, []);
return (
< ={{ : 1, : "center", : 24, : 12 }}>
< ="header">Welcome, {}</>
<>{}</>
{/* Stack.Protected then shows the sign-in screen. */}
<
="Sign out"
={() => void .().(() => {})}
/>
</>
);
}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:
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:
- Sign in. The home screen shows your name and email.
- Close the app and open it again. The splash screen stays up until the session is restored, then the home screen shows.
- Sign out. The sign-in screen shows.
Secure storage
secureStorage() saves the session with expo-secure-store: in the iOS
keychain, and encrypted with an Android Keystore key.
| Option | Default | Meaning |
|---|---|---|
clearOnReinstall | true | Remove a session an earlier install left in the iOS keychain before reading it. |
keychainAccessible | WHEN_UNLOCKED | When iOS lets the app read the session, passed to SecureStore unchanged. |
- Large sessions. Two JWTs and a user often exceed SecureStore's 2048-byte value limit, so longer values are split across keys. The new value replaces the old one only once every piece is written, so an app killed mid-write keeps the previous session whole.
- Synchronous reads. With a synchronous user schema, the session is restored before the first frame. Writes and removals are asynchronous, and applied in order.
- Reinstalls. iOS keeps keychain items after the app is deleted. A marker
file in the app's documents directory, which deleting the app removes, tells
a fresh install apart, so by default a reinstalled app starts signed out.
clearOnReinstall: falserestores the old session instead. Android removes the data with the app either way. - Locked keychain. SecureStore's default
keychainAccessibleisWHEN_UNLOCKED. While the keychain can't be read, the session isunavailable, never signed out. An app that refreshes in a background task before the device's first unlock needskeychainAccessible: SecureStore.AFTER_FIRST_UNLOCK. - Android backups. Data Android can't decrypt, as after Auto Backup
restores it onto another device, reads as signed out and is removed. The
expo-secure-storeconfig plugin keeps the session out of backups in the first place. - One context. An app has one JavaScript context, so there are no tabs to follow and no refresh lock: one refresh runs at a time in the client itself.
SessionGate
SessionGate adds what Stack.Protected lacks:
- While the session is restored, and until
readyistrue, it renders nothing, so the splash screen stays up, then hides the splash screen. CallSplashScreen.preventAutoHideAsync()at the top of the root layout, or the splash screen hides before the session is restored. The navigator mounts only then, so a cold-start deep link isn't sent to the sign-in screen first.readydefaults totrue, and holds the splash screen only the first time. Setting it tofalselater changes nothing on screen, but a deep link kept for after sign-in still waits for it. - While access is unavailable it renders
unavailable({ error, retry })instead of the navigator. - When the app returns to the foreground it re-checks the session, because timers don't run while iOS suspends the app. An access token that expired in the background is refreshed then.
- While locked it renders the navigator, so your guards can show an unlock screen. Neither unlocking nor a password sign-in from the lock screen remounts the navigator.
- When one account replaces another without a sign-out between them, it remounts the navigator, so no screen keeps the previous account's component state. The screens that were open stay open, as the URL does on the web. A sign-out already removes the guarded screens.
It takes the nearest AuthProvider's client, or a client prop, and mounts
it, so it also works without AuthProvider.
Deep links
rememberDeepLink(auth, path) keeps the link for later, and SessionGate
decides what to do with it:
- Signed in already: the link is dropped, and Expo Router opens it as usual.
- Signed out: once the user signs in, and
readyistrue,SessionGateopens it withrouter.replace. - Locked: the same, once the user unlocks or signs in with a password.
- A link to a screen anyone may see, such as the sign-in screen, is dropped once it is shown. If the app launched with it, it is dropped at the sign-in instead, unopened, since the screen was already showing.
Links are checked with safeReturnTo, so the root, the sign-in path, and
anything that could leave the app are ignored. Pass
rememberDeepLink(auth, path, { loginPath: "/sign-in" }) if your sign-in
screen isn't /login. A newer link replaces an older one.
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: restores, refreshes and why they ran, timers, storage operations, state
changes, and why a session ended:
export const auth = createAuth({
// ...
debug: __DEV__,
});SessionGate and deep links report through the same option: 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, or discarding data
Android can't decrypt.
Events leave out tokens, saved values, user fields, deep links' query strings,
and errors' causes. debug: { sensitive: true } puts them in, so keep it to
development builds. A function, debug: (event) => ... or debug: { log },
receives each event instead of the console, for example to add Sentry
breadcrumbs.
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. This example uses @noble/ciphers and
expo-crypto, your app's dependencies, not Session kit's:
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 needs biometrics: it must be read the way it was saved.
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. */
async function (): <string | null> {
const = await .();
if ( === null) return null;
return .(, ( === "true"));
}
/**
* 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 ()))
throw new ("Could not read the new session key");
= ;
}
/**
* 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 by 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:
function RootStack() {
const status = useAuth((state) => state.status);
const signedIn = status === "authenticated" || status === "refreshing";
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={signedIn}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={status === "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:
import { } from "react";
import { , , } from "react-native";
import { } from "expo-router";
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()andSessionGateare for iOS and Android. requireAuthentication.secureStorage()doesn't pass 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: see Locking.- Network status. Session kit doesn't watch connectivity. When your app
learns the device is back online, for example from
@react-native-community/netinfo, callauth.retry(), or theretrythatunavailablereceives.
Next steps
- Using the client: updating the user, signing out, and the client's other methods.
- Locking: how a locked session behaves.
- Effect and effetch: the same client as an Effect service.