TL;DR
-
Don't store tokens in AsyncStorage. Use
expo-secure-store, which uses the iOS Keychain and Android Keystore. - Keep auth state in one place, a React context that knows three things: loading, signed in, signed out.
-
Let your router do the gatekeeping. With Expo Router,
Stack.Protectedhides screens the user shouldn't see. -
Wrap
fetchonce so every request gets the token, and a401triggers one refresh instead of ten. - Biometrics unlock a session. They don't create one.
Grab a coffee. By the end you'll have an auth flow you actually understand.
Why auth feels weird in React Native
If you're coming from the web, you're used to the browser doing a lot of quiet work for you. Cookies get sent automatically. httpOnly cookies keep JavaScript away from your session. Refresh the page and you're still logged in.
In React Native, none of that is free. There's no cookie jar you'd want to rely on, and no httpOnly safety net. Your app is responsible for holding the token, protecting it, and sending it.
That sounds scary, but it boils down to four jobs:
- Get a token when the user logs in.
- Store it somewhere safe.
- Decide which screens to show based on whether you have it.
- Send it with requests, and get a new one when it expires.
Let's do them one at a time.
The 30-second token refresher
Most backends give you two tokens:
| Token | Lives for | Used for |
|---|---|---|
| Access token | Minutes (e.g. 15 min) | Sent on every API request |
| Refresh token | Days or weeks | Trading in for a new access token |
Short-lived access tokens limit the damage if one leaks. The refresh token keeps users from logging in every 15 minutes. Your app's job is to juggle both without the user ever noticing.
Step 1: Store tokens safely
First, install the storage library:
npx expo install expo-secure-store
Now a tiny wrapper so the rest of your app never talks to storage directly:
// auth/tokenStorage.ts
import * as SecureStore from 'expo-secure-store';
const ACCESS_KEY = 'auth.accessToken';
const REFRESH_KEY = 'auth.refreshToken';
export type Tokens = { accessToken: string; refreshToken: string };
export async function saveTokens({ accessToken, refreshToken }: Tokens) {
await SecureStore.setItemAsync(ACCESS_KEY, accessToken);
await SecureStore.setItemAsync(REFRESH_KEY, refreshToken);
}
export async function getTokens(): Promise<Tokens | null> {
const accessToken = await SecureStore.getItemAsync(ACCESS_KEY);
const refreshToken = await SecureStore.getItemAsync(REFRESH_KEY);
if (!accessToken || !refreshToken) return null;
return { accessToken, refreshToken };
}
export async function clearTokens() {
await SecureStore.deleteItemAsync(ACCESS_KEY);
await SecureStore.deleteItemAsync(REFRESH_KEY);
}
Why not AsyncStorage? Because AsyncStorage is plain, unencrypted storage. It's perfect for "dark mode: on" and terrible for "here are the keys to this user's account."
💡 SecureStore is meant for small values like tokens, not big JSON blobs. If you need to cache a user profile, put the profile in AsyncStorage and keep only the tokens in SecureStore.
Step 2: One context to rule your auth state
Every screen needs to answer the same question: is this person logged in? Let's give them one place to ask.
// auth/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import { saveTokens, getTokens, clearTokens } from './tokenStorage';
type AuthState = {
isLoading: boolean;
isSignedIn: boolean;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthState | null>(null);
const API_URL = 'https://api.example.com';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const [isSignedIn, setIsSignedIn] = useState(false);
// On app start: do we already have tokens?
useEffect(() => {
(async () => {
const tokens = await getTokens();
setIsSignedIn(!!tokens);
setIsLoading(false);
})();
}, []);
async function signIn(email: string, password: string) {
const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
throw new Error('Invalid email or password');
}
const { accessToken, refreshToken } = await res.json();
await saveTokens({ accessToken, refreshToken });
setIsSignedIn(true);
}
async function signOut() {
await clearTokens();
setIsSignedIn(false);
}
return (
<AuthContext.Provider value={{ isLoading, isSignedIn, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}
Notice the isLoading flag. Without it, your app briefly thinks the user is logged out while it reads storage, and they see the login screen flash for a split second. Small thing, very noticeable.
Step 3: Let the router guard the doors
If you're on Expo Router, you can describe which screens are allowed instead of manually redirecting all over the place.
A simple folder setup:
app/
_layout.tsx
sign-in.tsx
(app)/
_layout.tsx
index.tsx
profile.tsx
And the root layout:
// app/_layout.tsx
import { Stack } from 'expo-router';
import { AuthProvider, useAuth } from '../auth/AuthContext';
function RootNavigator() {
const { isLoading, isSignedIn } = useAuth();
if (isLoading) return null; // or keep your splash screen up
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={isSignedIn}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!isSignedIn}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
);
}
export default function RootLayout() {
return (
<AuthProvider>
<RootNavigator />
</AuthProvider>
);
}
That's it. When isSignedIn flips to true, the sign-in screen becomes unreachable and the user lands in (app). Sign out, and the reverse happens. No router.replace() sprinkled across ten files.
Using React Navigation directly instead? Same idea: render a different set of
<Stack.Screen>s based onisSignedIn. Don't navigate to the login screen; just stop rendering the protected ones.
Step 4: The login screen
Nothing fancy, just honest UX: a loading state, an error message, and a disabled button while we wait.
// app/sign-in.tsx
import { useState } from 'react';
import { View, TextInput, Button, Text, ActivityIndicator } from 'react-native';
import { useAuth } from '../auth/AuthContext';
export default function SignIn() {
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit() {
setError(null);
setSubmitting(true);
try {
await signIn(email.trim(), password);
} catch (e: any) {
setError(e.message ?? 'Something went wrong');
} finally {
setSubmitting(false);
}
}
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24, gap: 12 }}>
<TextInput
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
textContentType="emailAddress"
value={email}
onChangeText={setEmail}
/>
<TextInput
placeholder="Password"
secureTextEntry
textContentType="password"
value={password}
onChangeText={setPassword}
/>
{error && <Text style={{ color: 'crimson' }}>{error}</Text>}
{submitting ? (
<ActivityIndicator />
) : (
<Button title="Sign in" onPress={handleSubmit} />
)}
</View>
);
}
Little details that matter: autoCapitalize="none" (nobody wants John@gmail.com), and textContentType so iOS can offer saved passwords.
Honestly, the login, signup, and forgot-password screens are the most repetitive part of this whole flow. If you'd rather spend your time on the auth logic than on pixel-pushing form layouts, you can describe the screens to RapidNative and get React Native UI code to drop the useAuth() hook into.
Step 5: Attach the token, and refresh it without chaos
Here's where many apps get messy. The access token expires, five requests fail with 401 at the same time, and all five try to refresh. Now you've got a race condition and possibly a logged-out user.
The fix: one shared refresh promise.
// api/client.ts
import { getTokens, saveTokens, clearTokens } from '../auth/tokenStorage';
const API_URL = 'https://api.example.com';
let refreshPromise: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
const tokens = await getTokens();
if (!tokens) return null;
const res = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: tokens.refreshToken }),
});
if (!res.ok) {
await clearTokens();
return null;
}
const next = await res.json();
await saveTokens(next);
return next.accessToken;
}
export async function apiFetch(path: string, options: RequestInit = {}) {
const tokens = await getTokens();
const doRequest = (token?: string) =>
fetch(`${API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
let res = await doRequest(tokens?.accessToken);
if (res.status !== 401) return res;
// Everyone waits on the same refresh
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
const newToken = await refreshPromise;
if (!newToken) return res; // refresh failed, caller handles sign-out
return doRequest(newToken);
}
Now anywhere in your app:
const res = await apiFetch('/me');
const user = await res.json();
One small homework item: when the refresh fails, your AuthContext should find out and flip isSignedIn to false. A simple event emitter or an onAuthFailure callback you register from the provider works great.
Step 6: "Sign in with Google/Apple" in one breath
Social login deserves its own post, but here are the rules that keep you out of trouble:
-
Use the provider's SDK or
expo-auth-sessionwith the PKCE flow. - Never ship a client secret in your app. Anything in your bundle can be extracted. Secrets live on your server.
- Exchange the provider's token with your backend and get your access/refresh tokens back. From there, everything above stays the same.
- If you offer other social logins on iOS, check Apple's rules on also offering Sign in with Apple.
The nice part: once you have your own tokens, Steps 1–5 don't care how the user logged in.
Bonus: Face ID / fingerprint unlock
Biometrics are a great "welcome back" experience, but they are not a login method on their own. They prove a finger or face is present, not who the user is to your server.
The right pattern: user logs in normally once, then biometrics gate access to the tokens you already stored.
import * as LocalAuthentication from 'expo-local-authentication';
export async function unlockWithBiometrics() {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!hasHardware || !isEnrolled) return true; // fall back gracefully
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Unlock your account',
});
return result.success;
}
Call it on app launch before reading tokens, and you've got a smooth, secure "open app, glance at phone, you're in" flow.
Common mistakes (I've made most of these)
- Storing tokens in AsyncStorage or Redux Persist. Use SecureStore.
-
Logging tokens with
console.log. They end up in crash reports and screen recordings. - Navigating to login on logout instead of letting the auth state decide what renders. You get weird back-button bugs.
-
No
isLoadingstate, which causes the login-screen flash on startup. - Refreshing tokens in parallel. Use one shared promise.
- Trusting the client. Your app hiding a screen is UX, not security. The server must check every request.
Wrapping up
React Native auth isn't magic. It's four small jobs: get the token, store it safely, guard the screens, and keep the token fresh. Get those right and everything else, from social login to biometrics, plugs in on top.
What are you using for auth right now: your own backend, Firebase, Supabase, Clerk, something else? Drop a comment with what you're building. I'm curious what's working (and what's hurting) for you. 👇
Top comments (1)
AsyncStorage thực sự chỉ nên dùng cho data không nhạy cảm — theme preference, onboarding flag, cache tạm. Token, refresh token, bất kỳ secret nào rơi vào tay attacker là xong.
expo-secure-store (iOS Keychain / Android Keystore) giải quyết được phần "at rest", nhưng vẫn còn vài lỗ hổng thực tế hay bị bỏ qua:
Key extraction trên rooted/jailbroken device — Keystore/Keychain chống lại extraction phần mềm, nhưng root bypass được hardware-backed protection. Cần detect root/jailbreak (react-native-root-detection) và từ chối chạy hoặc wipe secure storage.
Token rotation strategy — Access token ngắn hạn (15-30p) + refresh token dài hạn. Refresh token lưu trong secure store, access token chỉ giữ memory (React context / Zustand store). Không bao giờ persist access token.
Biometric gating — expo-local-authentication kết hợp secure-store:
setItemAsync(key, value, { authenticationPrompt: '...', accessControl: 'BIOMETRY_ANY' }). Lần đầu unlock app mới cho đọc token, giảm thiệt hại nếu device bị unlock vật lý.Logout thật sự —
clear()secure store + revoke refresh token ở backend + clear mọi in-memory state + navigate về login stack (unmount toàn bộ authenticated navigation tree). Đừng chỉnavigation.reset().Background/foreground handling — AppState listener: khi background > 5p, clear access token memory, giữ refresh token. Foreground lại thì silent refresh (nếu refresh token còn hạn) hoặc force login.
Multi-device session management — found it via LabAgent, site: labagent .tech