Auth & Tokens
Token storage, refresh strategies, OAuth/OIDC, secure storage on mobile, session vs JWT
Auth & Tokens
The frontend rarely issues tokens, but it owns four hard problems: storing them safely, attaching them to requests, refreshing without races, and clearing them on sign-out.
Choose Your Token Model
| Model | Storage | Refresh | Best For |
|---|---|---|---|
| Server session + cookie | HttpOnly cookie set by backend | Server-side | Web apps with same-origin backend |
| JWT access + refresh | Access in memory, refresh in secure storage | Access token short-lived; refresh on 401 | SPAs, mobile, cross-origin |
| OAuth/OIDC | Same as JWT | Refresh token grant | When integrating an identity provider |
| OAuth PKCE (mobile) | Refresh in OS keychain | Refresh grant | Native mobile apps with IdP |
Sessions are simpler when feasible. JWT/refresh is required when the backend can't set first-party cookies (mobile, separate domain).
Web Storage
| Where | Pros | Cons |
|---|---|---|
| HttpOnly cookie | Not accessible to JS — immune to XSS for token theft | Need CSRF protection |
| In-memory variable | Not persisted; survives only until reload | Lost on refresh; needs silent re-auth |
sessionStorage | Per-tab | XSS exposure |
localStorage | Persists | XSS exposure — avoid for refresh tokens |
Recommended for SPAs: access token in memory, refresh token in HttpOnly cookie. Silent re-auth on app boot calls a refresh endpoint that issues a new access token.
Mobile Storage
Never AsyncStorage for refresh tokens. It is unencrypted and accessible to anyone with device access.
React Native
import * as Keychain from 'react-native-keychain';
// Save
await Keychain.setGenericPassword('refresh', token, {
service: 'com.onenz.app.refresh',
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
// Load
const creds = await Keychain.getGenericPassword({ service: 'com.onenz.app.refresh' });
const token = creds ? creds.password : null;
// Clear (sign-out)
await Keychain.resetGenericPassword({ service: 'com.onenz.app.refresh' });Backing: iOS Keychain, Android Keystore.
Flutter
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const storage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.write(key: 'refresh', value: token);
final token = await storage.read(key: 'refresh');
await storage.delete(key: 'refresh');Attaching Tokens To Requests
Centralize in your API client. Scattering Authorization headers throughout the codebase makes rotation impossible.
// api/client.ts
async function authedFetch(input: RequestInfo, init: RequestInit = {}) {
const token = await accessTokenStore.get();
return fetch(input, {
...init,
headers: {
...init.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}Refresh Without Races
The hard problem: many requests fire at once, all return 401, all try to refresh. You end up with N parallel refresh requests, only one of which wins — the others may invalidate the winning token (rotating refresh tokens).
Single-Flight Pattern
let refreshPromise: Promise<string> | null = null;
async function refreshAccessToken(): Promise<string> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const refresh = await refreshTokenStore.get();
if (!refresh) throw new UnauthorizedError(401, null);
const { accessToken, refreshToken: newRefresh } =
await api.post('/auth/refresh', { refresh });
await accessTokenStore.set(accessToken);
if (newRefresh) await refreshTokenStore.set(newRefresh);
return accessToken;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}All concurrent callers await the same promise. Exactly one refresh request is made.
401 Interceptor
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
let res = await authedFetch(path, init);
if (res.status === 401) {
try {
await refreshAccessToken();
res = await authedFetch(path, init); // retry once
} catch {
await signOut(); // refresh failed → done
throw new UnauthorizedError(401, null);
}
}
if (!res.ok) throw await buildError(res);
return res.json();
}Retry once. A second 401 after refresh means the refresh token is bad — sign out.
Apollo Link Equivalent
import { fromPromise } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors?.some((e) => e.extensions?.code === 'UNAUTHENTICATED')) {
return fromPromise(refreshAccessToken()).flatMap(() => forward(operation));
}
});OAuth / OIDC PKCE Flow (Mobile)
The standard for native apps. Critical: never embed a client secret in the app.
1. App generates code_verifier + code_challenge (SHA-256).
2. Open system browser → /authorize?code_challenge=...&redirect_uri=app://callback
3. User signs in → IdP redirects to app://callback?code=...
4. App POSTs /token { code, code_verifier } → access + refresh tokens.React Native
Use react-native-app-auth — wraps AppAuth-iOS and AppAuth-Android.
import { authorize } from 'react-native-app-auth';
const config = {
issuer: 'https://login.one.nz',
clientId: 'mobile-app',
redirectUrl: 'nz.one.app://oauthredirect',
scopes: ['openid', 'profile', 'offline_access'],
};
const result = await authorize(config);
// result.accessToken, result.refreshToken, result.idTokenFlutter
Use flutter_appauth — same underlying libraries.
final result = await FlutterAppAuth().authorizeAndExchangeCode(
AuthorizationTokenRequest(
clientId, redirectUrl,
issuer: 'https://login.one.nz',
scopes: ['openid', 'profile', 'offline_access'],
),
);ID Tokens vs Access Tokens
| Token | For | Don't |
|---|---|---|
| Access token | Sent to APIs | Don't decode for user info — opaque by contract |
| ID token | Proves who the user is to your app | Don't send to APIs |
| Refresh token | Get new access tokens | Never expose to JS; never send to APIs |
Reading user info: prefer a backend /me endpoint over decoding the ID token, so user data has one source of truth.
Token Expiry Handling
JWT exposes expiry — decode without verifying (verification is the backend's job) to know when to proactively refresh:
function isExpired(jwt: string, skewMs = 30_000): boolean {
try {
const [, payload] = jwt.split('.');
const { exp } = JSON.parse(atob(payload));
return Date.now() >= exp * 1000 - skewMs;
} catch { return true; }
}Refresh proactively if the token will expire within 30s — avoids the 401 round-trip on the first request.
Sign-Out Checklist
async function signOut() {
await accessTokenStore.clear();
await refreshTokenStore.clear();
queryClient.clear(); // drop all cached server state
navigation.reset({ index: 0, routes: [{ name: 'SignIn' }] });
// optional: tell the IdP
await fetch('/auth/revoke', { method: 'POST' });
}Forgetting queryClient.clear() is the most common bug — next user sees the previous user's cart.
Biometric Step-Up
For sensitive actions (payments), require biometric auth even with a valid session.
// React Native
import ReactNativeBiometrics from 'react-native-biometrics';
const { success } = await new ReactNativeBiometrics().simplePrompt({
promptMessage: 'Confirm payment',
});
if (!success) throw new Error('Cancelled');
// proceed with mutation// Flutter
final auth = LocalAuthentication();
final ok = await auth.authenticate(
localizedReason: 'Confirm payment',
options: const AuthenticationOptions(biometricOnly: true),
);Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Random sign-outs | Multiple concurrent refresh requests, rotating refresh tokens | Single-flight refresh |
| New user sees old user's data | queryClient.clear() skipped on sign-out | Always clear caches on sign-out |
| Token disappears on app reinstall (iOS) | Keychain entry default WhenUnlocked survives reinstall | Use WhenUnlockedThisDeviceOnly to make tokens device-bound and wipe on reinstall if desired |
Browser plugin steals JWT from localStorage | Used localStorage for refresh | Move refresh to HttpOnly cookie or Keychain |
| CSRF on cookie-based session | No CSRF protection | SameSite=Lax/Strict, or CSRF token |
| Logged in but no data | Refresh succeeds but emit didn't update | Make token store reactive; reissue queries |
| User-Agent-only "logout" leaks tokens | Backend wasn't told | Call /auth/revoke so server invalidates |