Store tokens securely, refresh sessions safely and use OAuth with PKCE on a public client.
Why it matters
Almost every production app has accounts, and the mobile side of authentication is where many security mistakes happen. A phone can be lost, stolen, backed up, or rooted, and your app's code is in the user's hands, so anyone can unpack it and read it. Mobile auth has to assume all of that.
What is different from the web
- No httpOnly cookies by default. Web apps can keep a session in a cookie the JavaScript cannot read. Mobile apps usually hold tokens themselves and send them in an
Authorization: Bearer <token>header, so where you store the token matters. - Your bundle is public. Anything in your JavaScript (API keys, client secrets) can be extracted from the app package. Treat the app as a public client: it can hold no secret.
- Long sessions. Users expect to stay signed in for weeks, so apps pair a short-lived access token with a longer-lived refresh token.
- Biometrics (Face ID, fingerprint) can unlock a stored credential locally, but they are not a login by themselves.
Storing tokens securely
Use the platform's secure storage:
- iOS Keychain and Android Keystore-backed encrypted storage. In Expo,
expo-secure-storewraps both. In bare React Native,react-native-keychainis common. - Not AsyncStorage, plain files, or Redux persisted to disk: those are unencrypted and can end up in backups.
- Keep tokens in memory while the app runs and read them from secure storage at startup.
import * as SecureStore from "expo-secure-store";
export async function saveSession({ accessToken, refreshToken }) {
await SecureStore.setItemAsync("refreshToken", refreshToken);
memory.accessToken = accessToken; // short-lived; memory is enough
}
export async function clearSession() {
await SecureStore.deleteItemAsync("refreshToken");
memory.accessToken = null;
}
SecureStore is meant for small values (a few kilobytes), which fits tokens.
Access and refresh tokens
A typical flow:
- The user signs in. The server returns an access token (valid for minutes) and a refresh token (valid for days or weeks).
- Every API call sends the access token.
- When the server answers 401, the API client calls the refresh endpoint once, stores the new tokens, and retries the original request.
- If refreshing fails, the session is over: clear storage, clear caches, and send the user to sign-in.
Two details matter in production. If five requests fail with 401 at the same moment, only one refresh should run and the others should wait for it (share a single pending promise). And servers should rotate refresh tokens (issue a new one on each refresh and revoke the old one), so a stolen token stops working quickly.
Signing in with another provider
For "Sign in with Google/Apple" or your company's identity provider, use OAuth 2.0 with PKCE (Proof Key for Code Exchange) in the system browser, for example with expo-auth-session or the providers' native SDKs. PKCE exists precisely because mobile apps cannot keep a client secret. Never ask for the user's Google password in your own form, and never embed a client secret in the app. Apple's guidelines require apps that offer certain third-party sign-in options to also offer an equivalent privacy-focused option such as Sign in with Apple; read the current App Review Guidelines before you ship (checked on 2026-10-23).
Biometrics
expo-local-authentication asks the OS to verify Face ID or a fingerprint and returns success or failure. Use it to gate access to a credential already on the device ("Unlock with Face ID"), with a fallback to the device passcode or a password. The server never sees the fingerprint.
Common mistakes
- Tokens in AsyncStorage.
- API secrets or admin keys in the app bundle. Put privileged calls on your server.
- Logging tokens or full request headers to the console or a crash reporter.
- Refresh storms: many parallel refresh calls that invalidate each other.
- Sign-out that clears the token but leaves cached personal data on screen.
- Building your own password reset or 2FA from scratch when the backend already has a secure flow; the app should call the backend's endpoints.
Quick recap
- Mobile apps are public clients: no secrets in the bundle.
- Store refresh tokens in the Keychain/Keystore (expo-secure-store), keep access tokens short-lived.
- On 401, refresh once, share the pending refresh, retry, and sign out cleanly if it fails.
- Use OAuth with PKCE in the system browser for third-party sign-in; biometrics unlock local credentials.
Try it
Take the quiz in the Do tab. Then look at the auth endpoints you built in Back-End Web APIs and decide which token each would return to a mobile client.