Login and secure storage
Build a login flow in Expo: a login screen, a token saved in secure storage, routes only signed-in users can open, logout and session restore. Learn why tokens and secrets must never sit in plain storage.
You will add a login flow to an Expo app: a login screen, a token saved safely on the phone, protected screens that only logged-in users can open, and a logout button. The result: close the app, reopen it, and you are still signed in.
Prerequisites: an Expo app with Expo Router (see *Navigation with Expo Router*) and basic fetch knowledge (see *Calling APIs and storing data on the device*). This lesson uses Expo SDK 53 or newer, because it uses Stack.Protected. Check your version in package.json.
How mobile login works
Most apps use token-based login. The user sends an email and password to your server once. If they are correct, the server returns a token, a long random-looking string that proves "this phone is user 42". The app stores the token and sends it with every later request in the Authorization header. The server checks the token each time.
The app never stores the password. Only the token is stored, and the server can expire or cancel it. Building the server side (hashing passwords, creating JWTs) is covered in *Authentication and authorization* in Backend Development.
Where to store the token
Choosing storage
| Storage | Encrypted? | Use for |
|---|---|---|
AsyncStorage | No, plain storage | Theme, language, onboarding done, cached lists |
expo-secure-store | Yes (iOS Keychain, Android Keystore) | Tokens, refresh tokens, small secrets |
| Hard-coded in your code | No, anyone can unzip the app | Never put secrets here |
Secrets do not belong in the app
Never keep a token in AsyncStorage, and never put API secrets (a Gemini key, a payment secret) inside the app. An app file can be unpacked and read in minutes. Anything inside the app is public. Values in EXPO_PUBLIC_ environment variables are also baked into the app.
expo-secure-store stores small key-value strings. It is meant for tokens, not for large data. It has three main functions: setItemAsync(key, value), getItemAsync(key) and deleteItemAsync(key). Keys may contain only letters, numbers, ., - and _.
Step 1: A tiny demo server
You need a server to log in to. If you already have one, skip this step and change the URL later. Otherwise run this small FastAPI server on your computer. It accepts one hard-coded user and is only for learning. A real server must hash passwords and store users in a database.
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
app = FastAPI()
DEMO_TOKEN = "demo-token-123" # a real server creates a signed, expiring token
class LoginBody(BaseModel):
email: str
password: str
@app.post("/login")
def login(body: LoginBody):
if body.email == "asha@example.com" and body.password == "brew1234":
return {"token": DEMO_TOKEN, "user": {"name": "Asha", "email": body.email}}
raise HTTPException(status_code=401, detail="Wrong email or password")
@app.get("/me")
def me(authorization: str | None = Header(default=None)):
if authorization != f"Bearer {DEMO_TOKEN}":
raise HTTPException(status_code=401, detail="Not logged in")
return {"name": "Asha", "email": "asha@example.com"}Run it with: pip install fastapi uvicorn, then: uvicorn server:app --host 0.0.0.0 --port 8000
A phone cannot reach localhost on your computer. Use your computer's local network IP instead, for example http://192.168.1.5:8000, and keep the phone and computer on the same Wi-Fi. Find the IP with ipconfig on Windows or ifconfig on macOS/Linux. Android emulator users can use http://10.0.2.2:8000.
Plain http:// is fine for local testing. Production apps must use https://.
Step 2: Install and write the API helper
npx expo install expo-secure-storeexpo install picks the version that matches your Expo SDK.
export const API_URL = 'http://192.168.1.5:8000'; // change to your computer's IP
export type User = { name: string; email: string };
export async function loginRequest(email: string, password: string) {
const res = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail ?? 'Login failed');
}
return (await res.json()) as { token: string; user: User };
}
export async function fetchMe(token: string) {
const res = await fetch(`${API_URL}/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Session expired');
return (await res.json()) as User;
}One place for every server call, so the URL and headers are not scattered across screens.
Step 3: An auth context
A context is React's way of sharing state with every screen without passing props down. We keep token and user in one AuthProvider. On app start it reads the saved token from SecureStore and checks it with the server. While it does this, loading is true, so we do not flash the login screen at someone who is already signed in.
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import * as SecureStore from 'expo-secure-store';
import { fetchMe, loginRequest, User } from './api';
const TOKEN_KEY = 'auth_token';
type AuthState = {
token: string | null;
user: User | null;
loading: boolean;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// On app start: restore the saved session
useEffect(() => {
(async () => {
try {
const saved = await SecureStore.getItemAsync(TOKEN_KEY);
if (saved) {
setUser(await fetchMe(saved)); // throws if the token is no longer valid
setToken(saved);
}
} catch {
await SecureStore.deleteItemAsync(TOKEN_KEY);
} finally {
setLoading(false);
}
})();
}, []);
async function signIn(email: string, password: string) {
const result = await loginRequest(email, password);
await SecureStore.setItemAsync(TOKEN_KEY, result.token);
setUser(result.user);
setToken(result.token);
}
async function signOut() {
await SecureStore.deleteItemAsync(TOKEN_KEY);
setToken(null);
setUser(null);
}
return (
<AuthContext.Provider value={{ token, user, loading, 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;
}Note: if the phone is offline at start, fetchMe fails and the user is signed out. The Offline lesson shows a gentler approach.
Step 4: Protect the routes
Expo Router lets you hide screens depending on state with Stack.Protected. A screen inside <Stack.Protected guard={false}> cannot be opened, and if the user is on it when the guard turns false, the router moves them to the first screen that is allowed. So logging in or out moves the user automatically, with no manual router.replace calls.
Our layout: a login screen for signed-out users and an (app) group for signed-in users.
import { ActivityIndicator, View } from 'react-native';
import { Stack } from 'expo-router';
import { AuthProvider, useAuth } from '../src/auth';
function RootNavigator() {
const { token, loading } = useAuth();
if (loading) {
return (
<View style={{ flex: 1, justifyContent: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={!!token}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!token}>
<Stack.Screen name="login" />
</Stack.Protected>
</Stack>
);
}
export default function RootLayout() {
return (
<AuthProvider>
<RootNavigator />
</AuthProvider>
);
}Step 5: Login and home screens
import { useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useAuth } from '../src/auth';
export default function Login() {
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function onSubmit() {
setError('');
setBusy(true);
try {
await signIn(email.trim(), password);
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong');
} finally {
setBusy(false);
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Brew & Bean</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={[styles.button, busy && { opacity: 0.6 }]}
onPress={onSubmit}
disabled={busy || !email || !password}
>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Log in</Text>}
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 12 },
input: { borderWidth: 1, borderColor: '#bbb', borderRadius: 8, padding: 12, fontSize: 16 },
error: { color: '#b00020' },
button: { backgroundColor: '#6f4e37', borderRadius: 8, padding: 14, alignItems: 'center' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '../../src/auth';
export default function Home() {
const { user, signOut } = useAuth();
return (
<View style={styles.container}>
<Text style={styles.title}>Hello, {user?.name}</Text>
<Text>{user?.email}</Text>
<Pressable style={styles.button} onPress={signOut}>
<Text style={styles.buttonText}>Log out</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
title: { fontSize: 24, fontWeight: '700' },
button: { backgroundColor: '#6f4e37', borderRadius: 8, paddingVertical: 12, paddingHorizontal: 24 },
buttonText: { color: '#fff', fontWeight: '600' },
});Create the folder app/(app)/. If you have an old app/index.tsx, delete it so the two files do not clash on the same route.
Step 6: Try it
- 1Start the server with
uvicorn server:app --host 0.0.0.0 --port 8000. - 2Set
API_URLinsrc/api.tsto your computer's IP. - 3Run
npx expo startand open the app in Expo Go or an emulator. - 4Type a wrong password. You should see the red error text.
- 5Log in with
asha@example.comandbrew1234. You should land on the home screen. - 6Close the app completely and reopen it. You should go straight to the home screen.
- 7Press Log out. You should return to the login screen.
Wrong password: 'Wrong email or password' in red under the fields.
Correct login: 'Hello, Asha' with the email below.
Reopen the app: a short spinner, then 'Hello, Asha' (no login needed).Handling expired sessions
Tokens expire. When any API call returns 401, the token is no longer valid, so the app should call signOut() and the guard sends the user to login. A simple way is to wrap fetch in one helper that does this. Many apps also use a refresh token (a longer-lived token kept in SecureStore) to get a new access token silently. Your backend must support that.
export async function authFetch(
path: string,
token: string,
onUnauthorized: () => void,
init: RequestInit = {}
) {
const res = await fetch(`${API_URL}${path}`, {
...init,
headers: { ...(init.headers ?? {}), Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
onUnauthorized(); // e.g. signOut from useAuth()
throw new Error('Session expired');
}
return res;
}Social login and biometrics
Social login ("Continue with Google") saves users from creating passwords. In Expo you can use expo-auth-session with OAuth providers, or use a service such as Firebase Authentication or Supabase Auth, which handle the server side for you. Google sign-in on Android needs a configuration step in Google Cloud and a development build, so check the official Expo guide for your provider.
Biometrics (fingerprint or face unlock) is a convenience layer on top of login, not a replacement. expo-local-authentication asks the phone to verify the user. A common pattern is: keep the token in SecureStore, and on app open ask for a fingerprint before showing the app.
Common mistakes
Check yourself
- Storing the token in AsyncStorage instead of SecureStore.
- Logging tokens or passwords with
console.logwhile debugging and forgetting to remove it. - Showing the login screen for a moment on every start. Use a
loadingstate. - Treating hiding a screen as security. The server must check the token on every request; the app only hides buttons.
- Using
http://URLs in a production app. Use HTTPS only.
How this comes up in interviews
Common questions: "Where do you store an auth token in a mobile app and why?" (SecureStore or Keychain/Keystore, not plain storage), "What is the difference between an access token and a refresh token?" and "How do you handle token expiry?" Be ready to explain the full flow: login, store, attach header, handle 401.
Try it: Add a profile call
Add a button on the home screen that calls GET /me using authFetch and shows the name from the server. Then change the server's DEMO_TOKEN and restart it. What happens when you press the button, and what should happen?
Key takeaways
- Send credentials once, store only the token, never the password.
- Use
expo-secure-storefor tokens. AsyncStorage is not encrypted. - Never put API secrets inside the app. Use your own backend for them.
- Keep a
loadingstate while restoring the session on app start. Stack.Protectedshows screens based on login state, so login and logout navigate for you.- On a 401 response, clear the token and send the user to login.
Check yourself
Finishing the quiz marks the lesson as done.
1. Where should an auth token be stored on the phone?
2. Why does the auth context have a loading state?
3. Your app needs a Gemini API key. Where should it live?
4. What does a 401 response from the API usually mean for a mobile app?
5. What does Stack.Protected guard={!!token} do?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.