Steven's Knowledge

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

ModelStorageRefreshBest For
Server session + cookieHttpOnly cookie set by backendServer-sideWeb apps with same-origin backend
JWT access + refreshAccess in memory, refresh in secure storageAccess token short-lived; refresh on 401SPAs, mobile, cross-origin
OAuth/OIDCSame as JWTRefresh token grantWhen integrating an identity provider
OAuth PKCE (mobile)Refresh in OS keychainRefresh grantNative 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

WhereProsCons
HttpOnly cookieNot accessible to JS — immune to XSS for token theftNeed CSRF protection
In-memory variableNot persisted; survives only until reloadLost on refresh; needs silent re-auth
sessionStoragePer-tabXSS exposure
localStoragePersistsXSS 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.

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.idToken

Flutter

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

TokenForDon't
Access tokenSent to APIsDon't decode for user info — opaque by contract
ID tokenProves who the user is to your appDon't send to APIs
Refresh tokenGet new access tokensNever 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

SymptomCauseFix
Random sign-outsMultiple concurrent refresh requests, rotating refresh tokensSingle-flight refresh
New user sees old user's dataqueryClient.clear() skipped on sign-outAlways clear caches on sign-out
Token disappears on app reinstall (iOS)Keychain entry default WhenUnlocked survives reinstallUse WhenUnlockedThisDeviceOnly to make tokens device-bound and wipe on reinstall if desired
Browser plugin steals JWT from localStorageUsed localStorage for refreshMove refresh to HttpOnly cookie or Keychain
CSRF on cookie-based sessionNo CSRF protectionSameSite=Lax/Strict, or CSRF token
Logged in but no dataRefresh succeeds but emit didn't updateMake token store reactive; reissue queries
User-Agent-only "logout" leaks tokensBackend wasn't toldCall /auth/revoke so server invalidates

On this page