Skip to content

Authentication and Session Recipes

A React Native guide for coding agents. Also covers oauth, authentication code examples, secure token storage, token refresh, clerk auth.

Use these recipes when an Expo app needs sign-in, protected navigation, authenticated API calls, or token renewal. The examples are original patterns built from current platform APIs; they do not reproduce starter-repository source.

Model Session Loading Explicitly

Do not represent “still restoring credentials” and “signed out” with the same null. A discriminated union prevents a protected navigator from flashing the wrong screen.

export type SessionState =
  | { status: "loading" }
  | { status: "signed-out" }
  | { status: "signed-in"; userId: string };

export function canEnterApp(session: SessionState): boolean {
  return session.status === "signed-in";
}

Use the state directly in the root layout:

import { Stack } from "expo-router";
import { ActivityIndicator, View } from "react-native";

export function RootNavigator({ session }: { session: SessionState }) {
  if (session.status === "loading") {
    return (
      <View
        accessibilityRole="progressbar"
        accessibilityLabel="Restoring session"
        style={{ flex: 1, justifyContent: "center" }}
      >
        <ActivityIndicator />
      </View>
    );
  }

  const signedIn = session.status === "signed-in";

  return (
    <Stack>
      <Stack.Protected guard={!signedIn}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
      <Stack.Protected guard={signedIn}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
    </Stack>
  );
}

Stack.Protected controls client navigation. It is not authorization: every API route must still verify the session.

Store Native Refresh Credentials Securely

Keep the long-lived refresh token in SecureStore on native. Prefer an HttpOnly, Secure, same-site cookie on web so JavaScript cannot read the credential. A short-lived access token may live in memory.

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

const REFRESH_TOKEN_KEY = "session.refresh-token";

export const nativeRefreshTokenStore = {
  read: () => SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
  write: (token: string) => SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token),
  clear: () => SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
};

SecureStore values are device-local credentials, not application state. Never put an API provider secret, service-role key, or shared “API secret” in EXPO_PUBLIC_*; public variables are shipped to the client.

Refresh Once When Requests Race

When several requests receive 401 together, renew once and let every caller await the same promise. Validate the response before storing it.

import { z } from "zod";

const tokenPairSchema = z.object({
  accessToken: z.string().min(1),
  refreshToken: z.string().min(1),
});

type TokenPair = z.infer<typeof tokenPairSchema>;
type SessionControl = { signOut(): Promise<void> };

let accessToken: string | null = null;
let refreshInFlight: Promise<TokenPair> | null = null;

async function renewSession(): Promise<TokenPair> {
  if (!refreshInFlight) {
    refreshInFlight = (async () => {
      const refreshToken = await nativeRefreshTokenStore.read();
      if (!refreshToken) throw new Error("No refresh credential");

      const response = await fetch(`${process.env.EXPO_PUBLIC_API_ORIGIN}/auth/refresh`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ refreshToken }),
      });

      if (!response.ok) throw new Error("Session renewal failed");
      const tokens = tokenPairSchema.parse(await response.json());
      accessToken = tokens.accessToken;
      await nativeRefreshTokenStore.write(tokens.refreshToken);
      return tokens;
    })().finally(() => {
      refreshInFlight = null;
    });
  }

  return refreshInFlight;
}

export function createAuthenticatedFetch(session: SessionControl) {
  return async function authenticatedFetch(path: string, init: RequestInit = {}) {
    const headers = new Headers(init.headers);
    if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);

    const firstRequest = new Request(`${process.env.EXPO_PUBLIC_API_ORIGIN}${path}`, {
      ...init,
      headers,
    });
    const retryRequest = firstRequest.clone();

    const first = await fetch(firstRequest);
    if (first.status !== 401) return first;

    try {
      const renewed = await renewSession();
      const retryHeaders = new Headers(retryRequest.headers);
      retryHeaders.set("Authorization", `Bearer ${renewed.accessToken}`);
      return fetch(new Request(retryRequest, { headers: retryHeaders }));
    } catch {
      accessToken = null;
      await Promise.allSettled([
        nativeRefreshTokenStore.clear(),
        session.signOut(),
      ]);
      throw new Error("Session expired");
    }
  };
}

Create this client once with the application's session adapter. Cloning the request before the first fetch preserves one-shot bodies for the single retry. If renewal fails, both cleanup operations are attempted independently before sign-in is shown; do not let one rejection skip the other, and do not loop on 401.

Keep Provider SDKs Behind an Adapter

Screens should depend on your session interface, not Clerk, Supabase, Auth0, or another vendor directly.

export interface SessionAdapter {
  restore(): Promise<SessionState>;
  getAccessToken(): Promise<string | null>;
  signOut(): Promise<void>;
}

This keeps routing and data code stable if the identity provider changes, and gives tests a small deterministic boundary.

Security and UX Checks

  • Verify issuer, audience, expiry, and signature on the server.
  • Rotate refresh tokens and invalidate the previous token after use.
  • Bind refresh and purchase identities to the authenticated user, never a client-supplied user id alone.
  • Preserve the intended destination through sign-in when safe.
  • Give expired sessions one clear next action: sign in again.
  • Never log tokens or return provider error payloads to the UI.

Sources

API behavior is checked against Expo’s Protected routes, authentication guide, and SecureStore documentation. Pattern selection was informed by the connected OAuth and authenticated-app references, but all code here is independently written.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "auth-session-recipes" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems