# Secure token storage in Expo stops leaking sessions attackers can replay

> Stop storing auth tokens in plaintext AsyncStorage. SecureStore with Keychain and Keystore done right.
> By Dave · 2026-09-10
> Source: https://otf-kit.dev/blog/expo-secure-store-tokens-production

Storing an auth token in AsyncStorage feels harmless during development. The login works, the session persists across restarts, and nothing complains. Then your app ships, and that same token sits in plaintext in your app's sandbox, readable to anything with backup access, device dumps, or a rooted phone. On iOS it can end up inside unencrypted iTunes backups. On Android it sits in shared preferences XML. This is the single most common credential-storage mistake in shipped Expo apps, and fixing it after launch means forcing every user to log in again.

Expo's answer is `expo-secure-store`: a thin wrapper over iOS Keychain Services and Android's EncryptedSharedPreferences backed by the Android Keystore. Small API, serious protection. This guide covers how to use it correctly in production, including the edge cases the reference docs leave to you.

## What SecureStore actually protects and what it does not

SecureStore encrypts small key-value pairs with hardware-backed keys where the device supports it. On iOS, values land in the Keychain with `kSecAttrAccessibleAfterFirstUnlock` semantics by default, which means they survive app reinstalls but are unavailable before first enable after reboot. On Android, values are encrypted with a key held in the Android Keystore, and the encrypted blob lives in SharedPreferences.

What it does not do: it is not a database. Values much above roughly 2 KB have historically been rejected by some iOS releases, so store tokens and refresh tokens, not user profiles or cached API responses. It does not protect data in memory, it does not stop a debugger attached to a debug build, and it does not replace server-side session expiry. Think of it as a safe for secrets, not a vault for everything.

One more platform truth worth internalizing early: each Expo project gets an isolated store. No other app can read your keys, but neither can your own app's other build variants if the bundle identifier or keychain access group differs. That isolation is the point, but it bites during migration, which we will get to.

## Move auth tokens out of AsyncStorage first

If your AI-generated starter put the Supabase session in AsyncStorage, you are in crowded company. Most templates do this because it works everywhere including web. The production fix is a small storage adapter that routes native token persistence through SecureStore while keeping the web path untouched.

```typescript
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';

const SESSION_KEY = 'sb-auth-session';

export const sessionStorage = {
  async getItem(key: string): Promise<string | null> {
    if (Platform.OS === 'web') {
      return localStorage.getItem(key);
    }
    return SecureStore.getItemAsync(key);
  },
  async setItem(key: string, value: string): Promise<void> {
    if (Platform.OS === 'web') {
      localStorage.setItem(key, value);
      return;
    }
    await SecureStore.setItemAsync(key, value);
  },
  async removeItem(key: string): Promise<void> {
    if (Platform.OS === 'web') {
      localStorage.removeItem(key);
      return;
    }
    await SecureStore.deleteItemAsync(key);
  },
};
```

Wire this adapter into your Supabase client as its storage backend and every session read and write on native goes through the Keychain or Keystore with zero changes to your auth call sites. The adapter shape also gives you one place to add logging, so token writes show up in your debug telemetry instead of happening invisibly inside a library.

Teams pairing this with a hardened sign-in flow should read the [Supabase auth hardening checklist](/blog/supabase-auth-production-hardening) next, since storage is only one layer of session safety.

## Gate sensitive reads behind biometrics

SecureStore supports a `requireAuthentication` option that ties decryption to a successful device authentication prompt. This is the right call for high-value secrets: refresh tokens in banking or health apps, API keys for paid tiers, anything whose theft means money or regulated data. It is not the right default for the everyday access token your app reads on every cold start, because prompting for Face ID before the home screen renders is a fast track to one-star reviews.

The practical pattern is two tiers. Standard session tokens live in SecureStore without an auth prompt so the app boots silently. Elevated secrets, such as a step-up token for payments or account deletion, live under separate keys with `requireAuthentication: true`. Pair the elevated tier with the biometric setup from our [biometric auth production guide](/blog/expo-biometric-auth-production-guide) so the prompt copy, fallback path, and review-safe configuration are already handled.

```typescript
// Everyday token: silent read on boot
await SecureStore.setItemAsync('sb-auth-session', sessionJson);

// Elevated secret: biometric prompt on every read
await SecureStore.setItemAsync('step-up-token', token, {
  requireAuthentication: true,
  authenticationPrompt: 'Confirm it is you to continue',
});
```

Note the documented Expo Go limitation: `requireAuthentication` is not supported in Expo Go when biometric hardware is present, because the client app lacks the Face ID usage description key. Test this path in a development build, never in Expo Go, or you will chase a bug that does not exist in production builds.

## Handle rotation, revocation, and logout on every path

Secure storage done wrong creates zombie sessions: the server revoked the token, but the client still holds a copy and keeps retrying with it. Your storage layer needs to treat deletion as a first-class operation with the same care as writes.

Build a single `clearSession` function and call it from every logout path: user-initiated sign-out, server-side 401 handling, token refresh failure, account deletion, and admin-forced revocation via push. Audit your codebase for direct `SecureStore.deleteItemAsync` calls outside that function; each one is a path where someone remembered to clear the token but forgot the in-memory copy, or vice versa.

```typescript
import { queryClient } from './query-client';

export async function clearSession(reason: string) {
  await sessionStorage.removeItem(SESSION_KEY);
  await SecureStore.deleteItemAsync('step-up-token').catch(() => {
    // Key may never have been set; absence is the desired end state.
  });
  queryClient.clear();
  analytics.track('session_cleared', { reason });
}
```

The `.catch` on the elevated key is deliberate, not lazy. `deleteItemAsync` throws when the key does not exist, and a logout flow must never crash because a user who never performed a step-up action has no step-up token. Log the reason for every clear. When users report being logged out unexpectedly, that analytics event is the difference between a diagnosis and a guess.

Refresh-token rotation deserves its own line in your threat model. When your provider rotates refresh tokens on each use, the window between receiving the new pair and persisting it is a crash window: if the app dies mid-write, the old token is already invalid and the new one never landed. Write the new pair before discarding the old, and treat a refresh failure after a crash as a signal to route to login rather than retrying with a dead token.

## Survive restores, reinstalls, and multi-device sessions

Keychain items survive app deletion on iOS by default. That surprises teams twice. First, when a tester deletes and reinstalls the app and finds themselves still logged in, which looks like a bug but is platform behavior. Second, when a revoked token persists across reinstall and the fresh install boots into a broken authenticated state. Decide your policy explicitly: if reinstall should mean logged out, check a flag in non-persisted storage on first launch and clear SecureStore when the flag is missing.

iCloud Keychain sync adds another wrinkle. Keychain items can follow the user to a new device, where your server may not recognize the session. Your boot sequence should validate the restored session against the server before rendering authenticated UI, and fall back to login on any mismatch. This validation call doubles as your session-health check, so it earns its place regardless.

Android has the mirror-image problem: EncryptedSharedPreferences do not survive a device factory reset, and Keystore keys can be invalidated by biometric enrollment changes or OS upgrades depending on how they were generated. Handle `getItemAsync` failures at boot as a normal event, not an exception. A missing or undecryptable token is just an anonymous user; route to login quietly instead of crashing or showing an error screen for something the user cannot fix.

## Checklist before you ship

Run through this list in a release build on a physical device, not a simulator and not Expo Go. Simulators skip the hardware-backed key paths that cause real-world failures.

First, confirm no auth token, refresh token, or API secret is written to AsyncStorage anywhere in the bundle, including inside third-party SDKs with their own default storage. Grep for `AsyncStorage` writes and justify each one. Second, verify the logout-everywhere matrix: sign-out, expired refresh, revoked server session, and account deletion each leave zero keys behind. Third, test the biometric-gated keys with biometrics enrolled, removed mid-session, and failing repeatedly, confirming the fallback never strands the user. Fourth, delete and reinstall the app and confirm the first-launch behavior matches your declared policy. Fifth, restore from an iCloud or Google backup onto a second device and confirm the session validates or re-authenticates cleanly.

Token storage is invisible when it works and catastrophic when it fails. A weekend spent moving two keys into the Keychain and Keystore, with logout paths you have actually tested, is the cheapest security work on your entire roadmap. Ship it before strangers arrive, because migrating stored sessions under live users is the migration nobody enjoys.

## Sources

- [Expo SecureStore reference](https://docs.expo.dev/versions/latest/sdk/securestore/): official documentation for `expo-secure-store`, covering installation, config plugin setup, `requireAuthentication` behavior, and platform storage semantics.
