TL;DR
- Login is the easy 10%. The bugs live in session handling: refresh, offline, reinstall, cold start.
- Five requests hitting a 401 at once = five refresh calls = rotated tokens invalidating each other = random logouts. Fix: a single-flight refresh promise.
- A failed
fetch(no signal) is not a failed refresh. Never log the user out on a network error. - iOS Keychain survives app uninstall. Your "fresh install" can boot into someone's old session. Wipe SecureStore on first launch.
- Gate routes with
Stack.Protectedand keep the splash screen up until the session is resolved — no login-screen flash.
Every auth tutorial ends at the same place: user taps "Sign in", you get a token, you navigate to the home screen. Ship it.
Then the bug reports start. "App logged me out on the train." "I reinstalled and it showed my old account." "It flashes the login screen for a second every time I open it." None of these are login bugs. They're session bugs, and they only show up once real people use the app on real phones with real network conditions.
Here are the four I've hit most, with the code I now drop into every Expo project.
1. The 401 refresh storm
Your home screen mounts and fires five requests in parallel. The access token expired an hour ago. All five come back 401. Your interceptor — written the obvious way — calls /auth/refresh five times.
If your backend rotates refresh tokens (it should), call #1 succeeds and invalidates the old refresh token. Calls #2–#5 send the now-dead token, fail, and your error handler logs the user out. The user did nothing wrong. They opened the app.
The fix is boring: only ever have one refresh in flight, and make everyone else wait on it.
// lib/auth/refresh.ts
import * as SecureStore from 'expo-secure-store';
const REFRESH_KEY = 'auth.refresh';
const API = process.env.EXPO_PUBLIC_API_URL;
let accessToken: string | null = null; // memory only — see below
let inflight: Promise<string> | null = null;
export class SessionExpiredError extends Error {}
export function getAccessToken() {
return accessToken;
}
export function refreshAccessToken(): Promise<string> {
if (!inflight) {
inflight = doRefresh().finally(() => {
inflight = null;
});
}
return inflight;
}
async function doRefresh(): Promise<string> {
const refresh = await SecureStore.getItemAsync(REFRESH_KEY);
if (!refresh) throw new SessionExpiredError();
const res = await fetch(`${API}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: refresh }),
});
// The server *answered* and said no → the session is genuinely dead.
if (res.status === 401 || res.status === 403) {
await clearSession();
throw new SessionExpiredError();
}
if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
const body = await res.json();
accessToken = body.accessToken;
if (body.refreshToken) {
// rotation: persist the new one before anyone else needs it
await SecureStore.setItemAsync(REFRESH_KEY, body.refreshToken);
}
return body.accessToken;
}
export async function clearSession() {
accessToken = null;
await SecureStore.deleteItemAsync(REFRESH_KEY);
}
Then a fetch wrapper that retries once:
// lib/auth/authFetch.ts
import { getAccessToken, refreshAccessToken } from './refresh';
export async function authFetch(input: string, init: RequestInit = {}) {
const send = (token: string | null) =>
fetch(input, {
...init,
headers: {
...init.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
let res = await send(getAccessToken());
if (res.status !== 401) return res;
const fresh = await refreshAccessToken(); // all 5 callers share this
res = await send(fresh);
return res; // a second 401 is a real authorization error — don't loop
}
Five parallel 401s now produce exactly one refresh call. The other four await the same promise and retry with the new token.
Opinion: keep the access token in memory only. It's short-lived, and re-deriving it from the refresh token on cold start costs one request. That leaves exactly one secret on disk.
2. Logging people out because the train went into a tunnel
Look at doRefresh again. There are three outcomes, and most code treats two of them the same:
| What happened | What it means | What to do |
|---|---|---|
fetch throws |
No network. You know nothing. | Keep the session. Retry later. |
| Server returns 401/403 | Refresh token revoked or expired | Clear session, go to sign-in |
| Server returns 5xx | Your backend is having a day | Keep the session. Surface an error. |
The common bug is a try/catch around the refresh that logs out on any exception. That turns "I had one bar of signal" into "I have to find my password." Only an actual answer from the auth server should end a session — which is why the code above only calls clearSession() on 401/403 and lets everything else bubble up as a normal error.
Pair it with a foreground check so a stale token gets refreshed when the user comes back, not on their first tap:
// lib/auth/useForegroundRefresh.ts
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { refreshAccessToken } from './refresh';
export function useForegroundRefresh(enabled: boolean) {
useEffect(() => {
if (!enabled) return;
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
refreshAccessToken().catch(() => {
// network errors are fine here; authFetch will retry on demand
});
}
});
return () => sub.remove();
}, [enabled]);
}
3. The ghost session after reinstall
This one surprises people. On iOS, expo-secure-store is backed by the Keychain, and Keychain items are not removed when the app is uninstalled. Delete the app, reinstall it, and your bootstrap code happily finds the old refresh token and logs straight into the previous account.
On a personal phone that's mostly confusing. On a shared or resold device, it's someone else's account.
The fix: AsyncStorage is wiped on uninstall, so use it as a "have I launched before?" marker.
// lib/auth/firstLaunch.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { clearSession } from './refresh';
const INSTALL_FLAG = 'app.installed';
export async function wipeKeychainOnFreshInstall() {
const seen = await AsyncStorage.getItem(INSTALL_FLAG);
if (!seen) {
await clearSession(); // kill any leftover Keychain token
await AsyncStorage.setItem(INSTALL_FLAG, '1');
}
}
Run it before you read anything from SecureStore. While you're in there, two more SecureStore notes:
- It isn't available on web. If you ship a web build, you need a separate storage path.
- Values over ~2KB may be rejected. Store tokens, not user profiles.
4. The login-screen flash
Cold start, signed-in user, and for 300ms they see the sign-in screen before the app "realizes" they're logged in. Two causes: rendering routes before the session is resolved, and resolving the session with a network call.
Resolve it locally: if a refresh token exists, treat the user as signed in and let authFetch refresh lazily. Don't block the first frame on the network — remember section 2, the user might be offline.
// lib/auth/SessionProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import * as SecureStore from 'expo-secure-store';
import { wipeKeychainOnFreshInstall } from './firstLaunch';
type Status = 'loading' | 'signedIn' | 'signedOut';
const SessionContext = createContext<{
status: Status;
setStatus: (s: Status) => void;
}>({ status: 'loading', setStatus: () => {} });
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<Status>('loading');
useEffect(() => {
(async () => {
await wipeKeychainOnFreshInstall();
const refresh = await SecureStore.getItemAsync('auth.refresh');
setStatus(refresh ? 'signedIn' : 'signedOut');
})();
}, []);
return (
<SessionContext.Provider value={{ status, setStatus }}>
{children}
</SessionContext.Provider>
);
}
export const useSession = () => useContext(SessionContext);
Then gate routes with Expo Router's protected screens and hold the splash until status is known:
// app/_layout.tsx
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { SessionProvider, useSession } from '@/lib/auth/SessionProvider';
import { useForegroundRefresh } from '@/lib/auth/useForegroundRefresh';
SplashScreen.preventAutoHideAsync();
function RootNavigator() {
const { status } = useSession();
useForegroundRefresh(status === 'signedIn');
useEffect(() => {
if (status !== 'loading') SplashScreen.hideAsync();
}, [status]);
if (status === 'loading') return null; // splash is still showing
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={status === 'signedIn'}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={status === 'signedOut'}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
);
}
export default function RootLayout() {
return (
<SessionProvider>
<RootNavigator />
</SessionProvider>
);
}
Stack.Protected needs a recent Expo Router (SDK 53+). On older versions, the same idea works with a <Redirect /> in each group's _layout.tsx.
One last wire: when authFetch throws SessionExpiredError, call setStatus('signedOut'). The protected guard flips and Expo Router moves the user to sign-in on its own — no manual router.replace scattered around the codebase.
If you're spinning up a new app and would rather start from a project where the (app) / sign-in route groups and _layout.tsx are already in place, RapidNative generates React Native + Expo projects from a prompt, so you can spend your time on the session logic above instead of the scaffolding.
The checklist I run before shipping auth
- [ ] Parallel 401s trigger exactly one refresh call (test it: expire the token, open the busiest screen)
- [ ] Airplane mode on cold start → user stays signed in
- [ ] Refresh endpoint returning 500 → user stays signed in
- [ ] Refresh endpoint returning 401 → user lands on sign-in, no crash
- [ ] Uninstall + reinstall on iOS → user is signed out
- [ ] Cold start as a signed-in user → no sign-in screen flash
- [ ] Access token never written to disk
Most of these take five minutes to test and are invisible until a user files the bug for you.
What's the weirdest auth bug you've shipped to production? Drop it in the comments — I'm collecting these.
Top comments (0)