Mobile Apps with React Native and Expo · State, Data, Offline and Sign-In

Sign-In and Secure Token Storage

Sign users in against your API, keep tokens in the device's secure keychain (not AsyncStorage), refresh expired access tokens once even when many requests fail together, and protect screens that need a signed-in user.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

Email-and-password sign-in that keeps the user signed in across app restarts, silently renews expired sessions, signs out cleanly, and shows the sign-in screen only when needed.

The idea

A common pattern with your own API:

  • On sign-in the server returns a short-lived access token (minutes) and a long-lived refresh token (days or weeks).
  • Store both in expo-secure-store, which uses the iOS Keychain and Android Keystore-backed encryption. Never store tokens in AsyncStorage: it isn't encrypted.
  • Every API call sends the access token. When the server answers 401, use the refresh token to get a new access token and retry the request.
  • If several requests fail with 401 at the same moment, refresh once and let them all wait for that single refresh ("single-flight"). Otherwise several refreshes race, and with rotating refresh tokens most of them fail and sign the user out.
  • If the refresh itself fails, sign out and show the sign-in screen.

For "Sign in with Apple/Google", use the providers' SDKs or expo-auth-session; the token handling afterwards is the same.

With Expo Router, protect screens by checking the session in a layout and redirecting to /sign-in when there's none.

The code

import * as SecureStore from "expo-secure-store";

const ACCESS = "session.access";
const REFRESH = "session.refresh";
let refreshing: Promise<string | null> | null = null;

export async function saveSession(access: string, refresh: string) {
  await SecureStore.setItemAsync(ACCESS, access);
  await SecureStore.setItemAsync(REFRESH, refresh);
}

export async function signOut() {
  await SecureStore.deleteItemAsync(ACCESS);
  await SecureStore.deleteItemAsync(REFRESH);
}

async function refreshAccessToken(): Promise<string | null> {
  refreshing ??= (async () => {                            // single flight: everyone awaits the same promise
    try {
      const refresh = await SecureStore.getItemAsync(REFRESH);
      if (!refresh) return null;
      const res = await fetch("https://api.example.com/auth/refresh", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ refresh_token: refresh }),
      });
      if (!res.ok) { await signOut(); return null; }
      const { access_token, refresh_token } = await res.json();
      await saveSession(access_token, refresh_token);
      return access_token as string;
    } finally {
      refreshing = null;
    }
  })();
  return refreshing;
}

export async function api(path: string, init: RequestInit = {}): Promise<Response> {
  const call = async (token: string | null) =>
    fetch(`https://api.example.com${path}`, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}` } });
  let res = await call(await SecureStore.getItemAsync(ACCESS));
  if (res.status === 401) {
    const fresh = await refreshAccessToken();
    if (fresh) res = await call(fresh);
  }
  return res;
}
// app/(app)/_layout.tsx: every screen in (app) requires a session
import { Redirect, Stack } from "expo-router";
import { useSession } from "../../session";        // your context that loads tokens at startup

export default function AppLayout() {
  const { isLoading, signedIn } = useSession();
  if (isLoading) return null;                        // or a splash screen
  if (!signedIn) return <Redirect href="/sign-in" />;
  return <Stack />;
}

Try the logic

Five requests fail with 401 at the same time because the access token expired. Without single-flight, each starts its own refresh. With it, one refresh serves them all.

Five requests, one token refreshTry it yourself
class AuthServer {
  refreshCalls = 0;
  private version = 1;
  access = "access-v1";
  refreshToken = "refresh-v1";

  expireAccess() { this.access = "expired"; }

  async refresh(token: string): Promise<{ access: string; refresh: string } | null> {
    this.refreshCalls++;
    await new Promise((r) => setTimeout(r, 20));        // network time
    if (token !== this.refreshToken) return null;       // rotation: an old refresh token is rejected
    this.version++;
    this.access = `access-v${this.version}`;
    this.refreshToken = `refresh-v${this.version}`;
    return { access: this.access, refresh: this.refreshToken };
  }
}

function makeClient(server: AuthServer, singleFlight: boolean) {
  let stored = { access: "old", refresh: server.refreshToken };
  let inFlight: Promise<boolean> | null = null;

  const doRefresh = async () => {
    const result = await server.refresh(stored.refresh);
    if (result) stored = result;
    return result !== null;
  };
  const refresh = () => {
    if (!singleFlight) return doRefresh();
    inFlight ??= doRefresh().finally(() => { inFlight = null; });
    return inFlight;
  };
  return async (path: string) => {
    if (stored.access === server.access) return `${path}: 200`;
    const ok = await refresh();                         // got a 401
    return ok ? `${path}: 200 after refresh` : `${path}: signed out`;
  };
}

for (const singleFlight of [false, true]) {
  const server = new AuthServer();
  server.expireAccess();
  const request = makeClient(server, singleFlight);
  const results = await Promise.all(["/me", "/orders", "/cart", "/notes", "/settings"].map(request));
  console.log(singleFlight ? "single-flight:" : "naive:");
  console.log("  refresh calls:", server.refreshCalls);
  console.log("  " + results.join(", "));
}

Output

naive:
  refresh calls: 5
  /me: 200 after refresh, /orders: signed out, /cart: signed out, /notes: signed out, /settings: signed out
single-flight:
  refresh calls: 1
  /me: 200 after refresh, /orders: 200 after refresh, /cart: 200 after refresh, /notes: 200 after refresh, /settings: 200 after refresh

Common mistakes

  • Tokens in AsyncStorage. Anyone with access to the device's files can read them; use SecureStore.
  • Long-lived access tokens to avoid the refresh logic. A stolen token then works for weeks.
  • Refreshing in parallel. With rotating refresh tokens, racing refreshes invalidate each other and sign the user out.
  • Forgetting sign-out cleanup. Delete tokens, clear cached user data (query cache, local database) and unregister push tokens.
  • Checking auth only on the client. The server must authorise every request; client checks are just navigation.

Key points

  • Short-lived access token + refresh token, both in expo-secure-store.
  • On 401: refresh once (single-flight), retry, or sign out if refresh fails.
  • Protect route groups with a layout that redirects when signed out.
  • Clear tokens and cached data on sign-out.

Further reading: Expo SecureStore, Authentication in Expo Router.

Practice questions

Where should a React Native app store auth tokens, and why not AsyncStorage?

Answer

In secure storage such as expo-secure-store, which is backed by the iOS Keychain and Android Keystore encryption. AsyncStorage stores plain, unencrypted data on disk.

What is 'single-flight' token refresh and why does it matter?

Answer

When several requests get 401 at once, only the first starts a refresh and the others wait for the same promise. Without it, parallel refreshes race; with rotating refresh tokens, all but one fail and the user gets signed out.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?