Mobile Auth Basics
10 examples to get you started with mobile authentication - 7 basic and 3 intermediate. Covers session models, where tokens live on device, and what logout everywhere means when users switch accounts or revoke access.
Search across all documentation pages
10 examples to get you started with mobile authentication - 7 basic and 3 intermediate. Covers session models, where tokens live on device, and what logout everywhere means when users switch accounts or revoke access.
Scaffold an Expo app with SDK 57 and install the auth storage primitives you will use throughout this section:
npx create-expo-app@latest AuthBasics --template blank-typescript
cd AuthBasics
npx expo install expo-secure-store expo-auth-session expo-cryptoConfirm the SDK pin:
{
"dependencies": {
"expo": "~57.0.4",
"react": "19.2.3",
"react-native": "0.86.0"
}
}Tooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
Mobile apps typically use one of three session shapes. Pick based on your backend - do not invent a fourth unless compliance demands it.
┌─────────────────────────────────────────────────────────────────┐
│ Model │ Access token │ Refresh │ Server session │
├────────────────────┼──────────────┼─────────┼──────────────────┤
│ JWT + refresh │ Short JWT │ Rotating│ Optional sid │
│ Opaque bearer │ Opaque token │ Rotating│ Required │
│ Cookie (rare RN) │ HttpOnly* │ HttpOnly│ Required │
└─────────────────────────────────────────────────────────────────┘
* HttpOnly cookies are awkward in RN - prefer Authorization header + secure store.// src/features/auth/model/session.ts
export type Session = {
accessToken: string;
refreshToken: string;
expiresAt: number; // epoch ms - client-side hint only; server validates
userId: string;
};expiresAt as a client hint for proactive refresh - the server clock is authoritativeRelated: Refresh Token Rotation - silent refresh and forced re-login | ../networking-api/networking-basics/networking-basics.md - attaching tokens to API calls
Access tokens authorize API calls. Refresh tokens obtain new access tokens - they are higher value and must be stored more carefully.
// src/shared/api/createApiClient.ts
type ApiClientOptions = {
getAccessToken: () => Promise<string | null>;
};
export function createApiClient({ getAccessToken }: ApiClientOptions) {
return async function apiFetch(path: string, init: RequestInit = {}) {
const token = await getAccessToken();
const headers = new Headers(init.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return fetch(`${process.env.EXPO_PUBLIC_API_URL}${path}`, {
...init,
headers,
});
};
}Authorization: Bearer/refresh endpointAuthorization in crash reporters (see Security Rules for Mobile)Related: SecureStore & Keychain/Keystore - where refresh tokens live on device
Mobile storage tiers have different threat models. Match sensitivity to the store.
┌──────────────────┬────────────────────────────────────────────────┐
│ Store │ Appropriate for │
├──────────────────┼────────────────────────────────────────────────┤
│ React state │ In-memory access token during active session │
│ expo-secure-store│ Refresh tokens, long-lived secrets │
│ AsyncStorage │ Theme, onboarding flags, non-sensitive prefs │
│ MMKV (encrypted) │ Large caches - not a substitute for Keychain │
│ expo-file-system │ Files, media - never raw tokens │
└──────────────────┴────────────────────────────────────────────────┘import * as SecureStore from "expo-secure-store";
const REFRESH_KEY = "auth.refresh_token";
export async function saveRefreshToken(token: string) {
await SecureStore.setItemAsync(REFRESH_KEY, token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
}
export async function loadRefreshToken() {
return SecureStore.getItemAsync(REFRESH_KEY);
}hasSeenOnboarding, fatal for refresh tokensAsyncStorage.setItem with token-like key namesRelated: SecureStore & Keychain/Keystore - keychain accessibility options and size limits
On launch, read the refresh token, exchange it for a fresh access token, then hydrate app state.
// src/features/auth/AuthProvider.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import * as SecureStore from "expo-secure-store";
import type { Session } from "./model/session";
type AuthContextValue = {
session: Session | null;
bootstrapping: boolean;
};
const AuthContext = createContext<AuthContextValue>({ session: null, bootstrapping: true });
async function refreshFromStoredToken(): Promise<Session | null> {
const refreshToken = await SecureStore.getItemAsync("auth.refresh_token");
if (!refreshToken) return null;
const res = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) return null;
return res.json() as Promise<Session>;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [bootstrapping, setBootstrapping] = useState(true);
useEffect(() => {
refreshFromStoredToken()
.then(setSession)
.finally(() => setBootstrapping(false));
}, []);
return (
<AuthContext.Provider value={{ session, bootstrapping }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}bootstrapping is true - do not flash the login screen for signed-in usersRelated: Auth Session Lifecycle - foreground refresh when returning from background
After a successful credential exchange, write tokens to secure store and update context.
// src/features/auth/api/signInWithPassword.ts
import * as SecureStore from "expo-secure-store";
import type { Session } from "../model/session";
export async function signInWithPassword(email: string, password: string): Promise<Session> {
const res = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
throw new Error("Invalid credentials");
}
const session = (await res.json()) as Session;
await SecureStore.setItemAsync("auth.refresh_token", session.refreshToken, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
return session;
}WHEN_UNLOCKED_THIS_DEVICE_ONLY so backups and other devices do not export the keychain itemSession to the caller so UI can navigate immediately without a second readRelated: expo-auth-session & OAuth - when sign-in uses Google or Apple instead of passwords
Local logout clears device artifacts and in-memory state. It is the minimum every app must implement.
// src/features/auth/api/signOutLocal.ts
import * as SecureStore from "expo-secure-store";
const AUTH_KEYS = ["auth.refresh_token", "auth.device_binding_id"] as const;
export async function signOutLocal() {
await Promise.all(AUTH_KEYS.map((key) => SecureStore.deleteItemAsync(key)));
}// In your settings screen
import { useAuth } from "../AuthProvider";
import { signOutLocal } from "../api/signOutLocal";
async function handleSignOut() {
await signOutLocal();
setSession(null); // from AuthProvider state setter exposed via context
router.replace("/sign-in");
}queryClient.clear() when server-backed caches hold user PIIRelated: Multi-Account & Device Binding - cache purge when switching users
Logout everywhere revokes refresh tokens server-side so stolen devices cannot silently refresh.
// src/features/auth/api/signOutEverywhere.ts
import * as SecureStore from "expo-secure-store";
export async function signOutEverywhere(accessToken: string) {
await fetch(`${process.env.EXPO_PUBLIC_API_URL}/auth/revoke-all`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
await SecureStore.deleteItemAsync("auth.refresh_token");
}revoke-all invalidates every device for that userRelated: Refresh Token Rotation - how rotation detects token reuse after a breach
Model auth as a finite state so Expo Router layouts can gate routes without nested conditionals.
// src/features/auth/model/authState.ts
export type AuthState =
| { status: "bootstrapping" }
| { status: "unauthenticated" }
| { status: "authenticated"; session: Session }
| { status: "session_expired" };// app/_layout.tsx (Expo Router)
import { Redirect, Stack } from "expo-router";
import { useAuth } from "@/features/auth/AuthProvider";
export default function RootLayout() {
const { authState } = useAuth();
if (authState.status === "bootstrapping") {
return null; // or <SplashScreen />
}
if (authState.status === "unauthenticated" || authState.status === "session_expired") {
return <Redirect href="/sign-in" />;
}
return <Stack screenOptions={{ headerShown: false }} />;
}bootstrapping prevents the login flash during cold-start refreshsession_expired lets you show "Session expired - sign in again" instead of a generic errorsession_expired often follows a failed silent refresh - do not infinite-retryRelated: Auth Session Lifecycle - transitions between foreground and background refresh
Whether users sign in with email or Google, normalize to one Session type so the rest of the app stays provider-agnostic.
// src/features/auth/model/mapOAuthToSession.ts
import type { Session } from "./session";
type OAuthTokenResponse = {
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string };
};
export function mapOAuthToSession(payload: OAuthTokenResponse): Session {
return {
accessToken: payload.access_token,
refreshToken: payload.refresh_token,
expiresAt: Date.now() + payload.expires_in * 1000,
userId: payload.user.id,
};
}Session - features import one typeexpo-auth-session wiring - not in order lists or settingsRelated: expo-auth-session & OAuth - PKCE and redirect URI setup
Centralize token refresh so feature code never hand-rolls retry logic.
// src/shared/api/authenticatedFetch.ts
let refreshPromise: Promise<Session | null> | null = null;
export function createAuthenticatedFetch(
getSession: () => Session | null,
setSession: (s: Session | null) => void,
refreshSession: () => Promise<Session | null>,
) {
return async function authenticatedFetch(path: string, init?: RequestInit) {
const session = getSession();
const headers = new Headers(init?.headers);
if (session?.accessToken) {
headers.set("Authorization", `Bearer ${session.accessToken}`);
}
let response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}${path}`, {
...init,
headers,
});
if (response.status !== 401) return response;
refreshPromise ??= refreshSession().finally(() => {
refreshPromise = null;
});
const refreshed = await refreshPromise;
if (!refreshed) return response;
setSession(refreshed);
headers.set("Authorization", `Bearer ${refreshed.accessToken}`);
return fetch(`${process.env.EXPO_PUBLIC_API_URL}${path}`, { ...init, headers });
};
}refreshPromise - ten parallel 401s should trigger one refreshnull and routes to sign-inqueryFn for consistent behaviorRelated: ../networking-api/fetch-vs-axios/fetch-vs-axios.md - interceptor patterns | Refresh Token Rotation - rotation edge cases
expo-auth-session redirect config per environment.EXPO_PUBLIC_* only if designed for client exposure.Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 19, 2026