+1 (415) 943-4271

Auth That Passes Security Review: Passkeys, Tokens, and Secure Storage in React Native

Every enterprise engagement we join eventually reaches the same meeting: someone from security asks where the refresh token lives. It is worth having a good answer, because auth is the one part of a mobile app where a shortcut turns into an incident. Here is the shape we recommend in 2026, and the parts that reliably go wrong.

Rule zero: no secrets in the bundle

A JavaScript bundle is not a secret. Anything in app.config.js, .env, or Constants.expoConfig.extra ships to the device in readable form and can be extracted from an IPA or APK in minutes. Public identifiers (client IDs, analytics keys) are fine. Client secrets, provider API keys, and signing keys are not — they belong on a backend you control.

That single rule dictates the auth architecture: the app uses PKCE and public-client flows, your backend holds anything privileged.

Where tokens go

Three tiers, and only one of them is correct for long-lived credentials:

  • In memory — the access token. Short-lived (minutes), never persisted, gone on cold start. This is the goal, not a compromise.
  • expo-secure-store / react-native-keychain — the refresh token. Backed by iOS Keychain and Android Keystore/EncryptedSharedPreferences, encrypted at rest by hardware-backed keys.
  • AsyncStorage / MMKV (unencrypted) / Redux-persist — never. This is plain text on disk. It is also, in our experience, where roughly half of the codebases we audit are keeping their refresh tokens.
import * as SecureStore from "expo-secure-store";

const REFRESH_KEY = "auth.refresh";

export async function saveRefreshToken(token: string) {
  await SecureStore.setItemAsync(REFRESH_KEY, token, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}

export async function clearSession() {
  await SecureStore.deleteItemAsync(REFRESH_KEY);
}

WHEN_UNLOCKED_THIS_DEVICE_ONLY matters: it keeps the item out of iCloud Keychain backups, so a restored device backup does not carry a live session onto new hardware. Security reviewers ask about this specifically.

Passkeys, concretely

Passkeys are now the default recommendation for consumer apps and increasingly acceptable for workforce apps. From React Native they are a native-API feature, not a JS one — expo-passkey-style modules or a thin Turbo Module over ASAuthorizationPlatformPublicKeyCredential (iOS) and Credential Manager (Android).

The code is the easy part. The deployment is where teams lose a week:

  1. Host /.well-known/apple-app-site-association with your webcredentials entry, served as application/json over HTTPS with no redirect.
  2. Host /.well-known/assetlinks.json with your Android package name and the SHA-256 fingerprint of the signing key that actually signs the store build — if you use Play App Signing, that is Google's key, not your upload key.
  3. Add the webcredentials:yourdomain.com associated domain entitlement (iOS) and matching asset_statements (Android).
  4. Verify on a real device installed from TestFlight/Play internal track. Simulators and debug builds lie about domain association.

The relying-party ID must be a domain you control and must stay stable forever. Changing it invalidates every registered passkey, so pick the apex domain deliberately rather than the marketing subdomain of the moment.

Always keep a second factor path — email OTP or an authenticator — for users on devices without passkey support or with a broken credential sync.

OAuth / OIDC without the footguns

Use the system browser (expo-auth-session, or ASWebAuthenticationSession / Custom Tabs directly). Never a WebView: it breaks SSO, is rejected by many identity providers, and hands your app the user's password keystrokes.

Authorization Code + PKCE, no implicit flow, and validate the state parameter on return. Deep-link the redirect back into a dedicated route so a stray callback cannot be replayed into an arbitrary screen.

Silent refresh without the thundering herd

The classic bug: five queries fire on app resume, all get a 401, all call /refresh, four of them get rejected because the provider rotated the token, and the user is logged out for no reason. Single-flight the refresh:

let inFlight: Promise<string> | null = null;

async function getAccessToken(): Promise<string> {
  if (isValid(cached)) return cached.token;
  if (!inFlight) {
    inFlight = doRefresh().finally(() => {
      inFlight = null;
    });
  }
  return inFlight;
}

Wire that into one interceptor (Axios, or a fetch wrapper feeding TanStack Query), retry the original request exactly once, and treat a second 401 as a real logout. Refresh a little early — a 60-second skew window avoids races with clock drift and slow networks.

Biometric gating is a UX control, not a security boundary

expo-local-authentication returning success: true is a JavaScript boolean; on a compromised device it can be forced. Use Face ID / fingerprint to unlock a credential, not to unlock a screen: store the token with requireAuthentication, so the OS keystore refuses to release the bytes without a fresh biometric match. Then a bypass yields nothing to replay.

Also get the ordinary lifecycle right — re-lock on background after a configured idle period, and handle enrollment changes (a new fingerprint added) by invalidating the stored item.

Logout, and the parts people forget

A correct logout clears secure storage, drops in-memory tokens, revokes the refresh token server-side, resets the query cache (stale personal data in a cache is a real leak), clears the push token registration for that user, and calls the provider's end-session endpoint so the system browser session does not silently sign the next user back in.

Hardening: what is worth it

When a security questionnaire arrives, these are the items we push back on or accept:

  • Certificate pinning — reasonable for a small number of first-party API hosts. Pin to an intermediate CA rather than a leaf, ship a backup pin, and make the pin set remotely updatable or you will brick your install base on a certificate rotation.
  • Jailbreak/root detection — best-effort signal, defeatable. Fine as telemetry or a risk input; never the only control protecting server-side data.
  • Code obfuscation — Hermes bytecode is already not readable JavaScript. Enable R8/ProGuard on Android, strip source maps from release builds while uploading them to your crash reporter. Beyond that, diminishing returns.
  • Screenshot and clipboard hygiene — cheap and often required in finance/health: mark sensitive screens no-capture, and avoid Clipboard for tokens.

The honest framing for a reviewer: the client is untrusted. Every one of these raises cost for an attacker; none replaces authorization checks on the server.

A short pre-launch checklist

  • No secrets in the bundle; verified by unzipping the release artifact.
  • Refresh token in Keychain/Keystore, device-only, cleared on logout.
  • Access token in memory only, single-flight refresh, one retry.
  • System browser for OAuth, PKCE, state validated.
  • Passkey association files served correctly and tested from a store-signed build.
  • Biometrics gate the credential, not the screen.
  • Query cache and push registration cleared on logout.
  • Server-side authorization on every endpoint, independent of client checks.

Where we fit

We do this work as part of most engagements: designing the token lifecycle, writing the native module for passkeys or keystore-bound credentials, and answering the enterprise security questionnaire that gates the rollout. If your app is heading into a security review — or already failed one — get in touch.