Storing a Session

Keeping a token in the iOS keychain and the Android keystore with expo-secure-store, and restoring the session at launch.

SyntaxSecureStore.setItemAsync(key: string, value: string, options?: SecureStoreOptions): Promise<void>

A session token is a bearer credential: whoever holds it is the user, as far as your API is concerned. It needs storage the operating system protects. On iOS that is the keychain; on Android it is an encrypted value whose key lives in the Android Keystore. expo-secure-store is one API over both.

Why not AsyncStorage

AsyncStorage is not encrypted. Its values sit in plain files inside the app's container, readable from a device backup, from a rooted or jailbroken phone, or from a desktop file browser. It is the right place for a theme choice or a dismissed banner, and the wrong place for anything that lets someone act as the user.

The API

Four calls cover most apps: setItemAsync, getItemAsync and deleteItemAsync, plus the blocking getItem when you need a value during the first render. Keys may contain letters, digits, ., - and _ only. The third options argument carries keychainAccessible (iOS, default WHEN_UNLOCKED) and requireAuthentication, which puts Face ID or a fingerprint in front of the read.

SecureStore.getItem(key) is the synchronous twin of await SecureStore.getItemAsync(key). Same value, same storage; the sync form blocks the JavaScript thread, so keep it for small reads at startup.

On launch

Read once at startup, before the first navigation decision, and expose the outcome as loading, signed in or signed out. If the access token is short-lived, that same step trades the refresh token for a new one, and deletes both if the trade is rejected.

Example

Example · tsx
// ctx.tsx
import * as SecureStore from "expo-secure-store";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";

const TOKEN_KEY = "access-token";

type Session = { token: string } | null;

type SessionValue = {
  session: Session;
  isLoading: boolean;
  signIn: (token: string) => Promise<void>;
  signOut: () => Promise<void>;
};

const SessionContext = createContext<SessionValue | undefined>(undefined);

export function SessionProvider({ children }: { children: ReactNode }) {
  const [session, setSession] = useState<Session>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // One read at launch. Until it settles the app knows nothing about the user.
    SecureStore.getItemAsync(TOKEN_KEY)
      .then((token) => setSession(token ? { token } : null))
      .finally(() => setIsLoading(false));
  }, []);

  async function signIn(token: string) {
    await SecureStore.setItemAsync(TOKEN_KEY, token);
    setSession({ token });
  }

  async function signOut() {
    await SecureStore.deleteItemAsync(TOKEN_KEY);
    setSession(null);
  }

  return (
    <SessionContext.Provider value={{ session, isLoading, signIn, signOut }}>
      {children}
    </SessionContext.Provider>
  );
}

export function useSession() {
  const value = useContext(SessionContext);
  if (!value) throw new Error("useSession must be used inside a SessionProvider");
  return value;
}

When to use it

  • Keep a signed-in user signed in across app restarts.
  • Trade a refresh token for a fresh access token during the splash screen.
  • Require Face ID before a stored payment session can be used.
  • Wipe the token on sign-out so the next launch starts at the sign-in screen.

More examples

Putting biometrics in front of the token

Reaching for this in a banking or health app, where an unlocked phone should still not be enough to use the session. The options object is shared deliberately: a value written with requireAuthentication must be read with it too. Note that requireAuthentication is not supported under Face ID in Expo Go, so test it in a development build, and always handle the cancel path rather than letting the rejection surface as a crash.

Example · tsx
import * as SecureStore from "expo-secure-store";

const REFRESH_KEY = "refresh-token";

const options: SecureStore.SecureStoreOptions = {
  // Never copied to another device by a backup, and unreadable while locked.
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  requireAuthentication: true,
  authenticationPrompt: "Unlock to continue",
};

export async function saveRefreshToken(token: string) {
  await SecureStore.setItemAsync(REFRESH_KEY, token, options);
}

export async function readRefreshToken(): Promise<string | null> {
  try {
    return await SecureStore.getItemAsync(REFRESH_KEY, options);
  } catch {
    // The prompt was cancelled, or no biometrics are enrolled on the device.
    return null;
  }
}

Refreshing on launch

Call this from the provider's launch effect instead of a bare read when your access tokens live for minutes rather than weeks. The 401 branch is the part people leave out: without it a revoked session loops, retrying a dead refresh token on every launch and never reaching the sign-in screen.

Example · tsx
import * as SecureStore from "expo-secure-store";

type Tokens = { accessToken: string; refreshToken: string };

export async function restoreSession(): Promise<Tokens | null> {
  const refreshToken = await SecureStore.getItemAsync("refresh-token");
  if (!refreshToken) return null;

  const res = await fetch("https://api.example.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken }),
  });

  // The refresh token itself has expired or been revoked: there is no session left.
  if (res.status === 401) {
    await SecureStore.deleteItemAsync("refresh-token");
    await SecureStore.deleteItemAsync("access-token");
    return null;
  }

  const tokens = (await res.json()) as Tokens;
  // Separate keys, so neither value approaches the platform size limit.
  await SecureStore.setItemAsync("access-token", tokens.accessToken);
  await SecureStore.setItemAsync("refresh-token", tokens.refreshToken);
  return tokens;
}

Deciding which store a value belongs in

The split in one file. The test is simple: if leaking the value would let someone act as the user, or expose something private about them, it goes in SecureStore; otherwise AsyncStorage is fine and faster. Note that SecureStore is unavailable on web, so a module shared with an Expo web build needs a platform branch rather than a direct import.

Example · tsx
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";

type SignInResult = { accessToken: string; displayName: string };

export async function persist({ accessToken, displayName }: SignInResult) {
  // A credential: keychain or keystore backed.
  await SecureStore.setItemAsync("access-token", accessToken);
  // Not a credential: plain storage, and cheaper to read in bulk.
  await AsyncStorage.setItem("profile-name", displayName);
}

export async function clear() {
  await SecureStore.deleteItemAsync("access-token");
  await AsyncStorage.removeItem("profile-name");
}

Discussion

  • Be the first to comment on this lesson.