SecureStore & Keychain/Keystore
expo-secure-store is the default token vault for Expo apps. It delegates to iOS Keychain and Android Keystore, keeping refresh tokens out of unencrypted SQLite (AsyncStorage) and out of your JS bundle.
Search across all documentation pages
expo-secure-store is the default token vault for Expo apps. It delegates to iOS Keychain and Android Keystore, keeping refresh tokens out of unencrypted SQLite (AsyncStorage) and out of your JS bundle.
npx expo install expo-secure-storeNo config plugin is required for basic usage on SDK 57 - install and import.
Tooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
Threat model comparison
AsyncStorage SecureStore (Keychain / Keystore)
──────────────────── ─────────────────────────────────────────
Unencrypted SQLite Hardware-backed encryption (device-dependent)
Readable on rooted Requires device unlock + app entitlement
devices with file on iOS; Keystore-backed on Android
browsers
Backed up as plain WHEN_UNLOCKED_THIS_DEVICE_ONLY excludes
app data in some iCloud Keychain sync (iOS)
Android backups// ❌ Never do this
import AsyncStorage from "@react-native-async-storage/async-storage";
await AsyncStorage.setItem("refresh_token", token);
// ✅ Correct
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("auth.refresh_token", token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});AsyncStorage + token - automate in CI with a custom ESLint ruleRelated: ../expo-rules/security-rules-for-mobile/security-rules-for-mobile.md - Tier 2 storage rules
Wrap SecureStore behind an interface so tests swap in memory and migrations stay centralized.
// src/features/auth/adapters/secureTokenStorage.ts
import * as SecureStore from "expo-secure-store";
const KEYS = {
refreshToken: "auth.refresh_token",
lastUserId: "auth.last_user_id",
} as const;
const secureOptions: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
};
export const secureTokenStorage = {
async saveRefreshToken(token: string) {
await SecureStore.setItemAsync(KEYS.refreshToken, token, secureOptions);
},
async loadRefreshToken() {
return SecureStore.getItemAsync(KEYS.refreshToken, secureOptions);
},
async clearAll() {
await Promise.all(
Object.values(KEYS).map((key) => SecureStore.deleteItemAsync(key)),
);
},
};auth.refresh_token) avoid collisions with other features using SecureStoreSecureStore directlyclearAll is the logout primitive - call from sign-out and account-switch flowsRelated: Mobile Auth Basics - cold-start session restore
iOS Keychain accessibility controls when items can be read.
import * as SecureStore from "expo-secure-store";
// Recommended for refresh tokens:
SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
// Alternatives - use deliberately:
SecureStore.AFTER_FIRST_UNLOCK // background fetch can read - higher risk
SecureStore.WHEN_UNLOCKED // may sync via iCloud Keychain
SecureStore.ALWAYS // avoid for auth secrets| Option | Background read | iCloud sync | Use case |
|---|---|---|---|
WHEN_UNLOCKED_THIS_DEVICE_ONLY | No | No | Refresh tokens (default choice) |
AFTER_FIRST_UNLOCK | Yes (until lock) | No | Background refresh before user unlocks |
WHEN_UNLOCKED | No | Possible | Rare - cross-device keychain needs |
WHEN_UNLOCKED_THIS_DEVICE_ONLY for refresh tokens - matches banking app postureAFTER_FIRST_UNLOCK only when you must refresh in background before first unlock and accept the tradeoffSecureStore is for small secrets - not arbitrary JSON caches.
// ❌ Too large - user object with avatar URL, preferences, roles
await SecureStore.setItemAsync("user", JSON.stringify(largeUserObject));
// ✅ Store token + non-sensitive ID; fetch profile from API
await SecureStore.setItemAsync("auth.refresh_token", refreshToken, secureOptions);
await SecureStore.setItemAsync("auth.last_user_id", userId, secureOptions);react-native-mmkv with encryption key stored in SecureStoreShip a one-time migration for apps that previously stored tokens incorrectly.
// src/features/auth/adapters/migrateTokenStorage.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
const LEGACY_KEY = "refresh_token";
const SECURE_KEY = "auth.refresh_token";
const MIGRATION_FLAG = "auth.storage_migrated_v1";
export async function migrateLegacyTokens() {
const done = await AsyncStorage.getItem(MIGRATION_FLAG);
if (done) return;
const legacy = await AsyncStorage.getItem(LEGACY_KEY);
if (legacy) {
await SecureStore.setItemAsync(SECURE_KEY, legacy, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
await AsyncStorage.removeItem(LEGACY_KEY);
}
await AsyncStorage.setItem(MIGRATION_FLAG, "1");
}AuthProvider bootstrapSecureStore stores secrets; biometrics gate access to the app - see the dedicated biometrics page for step-up patterns.
// SecureStore holds the token; biometrics gate whether app unlocks session
import * as LocalAuthentication from "expo-local-authentication";
export async function unlockAppOrSignOut(
loadRefresh: () => Promise<string | null>,
signOut: () => Promise<void>,
) {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const enrolled = await LocalAuthentication.isEnrolledAsync();
if (!hasHardware || !enrolled) {
return loadRefresh();
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock MyApp",
fallbackLabel: "Use passcode",
});
if (!result.success) {
await signOut();
return null;
}
return loadRefresh();
}authenticateAsync APIRelated: Biometrics with LocalAuthentication - step-up auth patterns
export async function signOutCompletely(queryClient: { clear: () => void }) {
await secureTokenStorage.clearAll();
queryClient.clear();
// Navigation reset handled by caller - router.replace("/sign-in")
}deleteItemAsync is idempotent - safe to call when key is already absentMock the module in Jest - SecureStore is not available in Node.
// jest.setup.ts
jest.mock("expo-secure-store", () => ({
setItemAsync: jest.fn(),
getItemAsync: jest.fn(),
deleteItemAsync: jest.fn(),
WHEN_UNLOCKED_THIS_DEVICE_ONLY: "WHEN_UNLOCKED_THIS_DEVICE_ONLY",
}));expo-crypto for hashing and random bytes - not as a replacement for SecureStore.Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 19, 2026