React Native Secure Storage and Keychain
AI generated
RN
native
React Native · iOS · Android · Security
React Native Secure Storage and Keychain
why tokens never belong in AsyncStorage

AsyncStorage stores values unencrypted as plain text, readable with physical access or on a rooted device. Secure storage via the iOS Keychain and Android Keystore encrypts secrets at the operating system level and can additionally bind them to biometry. This article shows how react-native-keychain and expo-secure-store correctly store access tokens, refresh tokens, and other sensitive data.

17 min read Keychain · Android Keystore · Secure Storage iOS · Android · Expo

1. Why AsyncStorage is unsuitable for secrets

AsyncStorage stores values as property list files on iOS and as an SQLite database on Android, in both cases unencrypted in plain text. Anyone with physical access to a device, or examining a rooted or jailbroken device, can open and read these files directly, without analyzing any app code at all. For non-sensitive settings like a theme flag that is not a problem, but for access tokens, refresh tokens, or API keys it is a direct security risk.

The reasoning error that often leads to exactly this problem in practice: AsyncStorage feels, from its API, like a simple key-value store, without the missing encryption ever becoming visible in day-to-day development. A penetration test or a backup analysis then usually uncovers the problem late in the project. Secure storage must therefore be planned in from the start as soon as any secret is meant to persist on the device.

The correct ground rule for every React Native project: anything an attacker with device access should not be able to see in plain text belongs in a store encrypted by the operating system itself, meaning the Keychain on iOS or the Android Keystore. AsyncStorage remains the right choice for uncritical app settings, but never for tokens or credentials.

2. Overview of secure storage solutions

Several libraries have established themselves for secure storage in React Native. react-native-keychain is the most common choice in the bare workflow and offers direct access to the iOS Keychain and Android Keystore, including biometry binding. expo-secure-store is the counterpart for the Expo managed workflow with a deliberately leaner API. Encrypted MMKV additionally offers very high performance for frequent read/write access, while react-native-encrypted-storage combines a simple, AsyncStorage-like API with encryption underneath.

The choice depends on the workflow and performance requirements: anyone already working in the Expo managed workflow who does not want to touch native configuration is well served by expo-secure-store. Anyone who needs fine-grained control over access control flags and biometry binding, for example for banking or health apps, can hardly avoid react-native-keychain, since it passes through the native Keychain API more directly.

In every case it's important to remember: all of these solutions are ultimately thin wrappers around the same two operating system mechanisms, iOS Keychain Services and the Android Keystore. Understanding these two fundamentals, covered in the next two sections, therefore matters more than knowing the details of any single library's API.

Encrypted MMKV deserves a special mention here: it encrypts values itself with a symmetric key before writing them to disk, while that key itself should in turn live in real secure storage. This combination of fast, encrypted bulk storage and a small, hardware-protected master key is a proven pattern for apps that need to read and write many encrypted records with low latency.

3. iOS Keychain fundamentals

The Keychain is an encrypted database managed by the operating system, completely separate from the rest of the app sandbox filesystem. Values that land in the Keychain are encrypted with a hardware-backed key that never leaves the Secure Enclave. Even a full filesystem backup contains Keychain contents only in encrypted form, so simply copying the backup does not expose the secrets.

Access control flags additionally govern when and under what circumstances an entry is readable. kSecAttrAccessibleWhenUnlockedThisDeviceOnly is the right choice for most use cases: the entry is readable only while the device is unlocked and is additionally explicitly excluded from the iCloud Keychain backup, so it never leaves the device, not even in encrypted form.

For entries that should additionally be bound to biometry, SecAccessControlCreateFlags with the biometryCurrentSet flag comes into play. This binds the entry to the biometry currently enrolled on the device, so resetting or re-enrolling Face ID or Touch ID automatically invalidates access to the entry, an important security property against biometric data added later by someone else.

4. Android Keystore fundamentals

The Android Keystore System is the functional counterpart to the iOS Keychain: cryptographic keys are generated and managed with hardware backing, without the private key itself ever being accessible to the app as raw data. Instead of reading the key directly, the app only uses it indirectly through encryption and decryption operations performed by the Keystore.

EncryptedSharedPreferences from the Jetpack Security Library builds directly on the Keystore as a high-level API and offers an API that feels almost identical to normal SharedPreferences, while transparently encrypting keys and values. Most React Native libraries for secure storage use exactly this mechanism under the hood to achieve security comparable to the iOS Keychain on Android.

On devices with the appropriate hardware, StrongBox is additionally available, a dedicated security chip module that performs key operations completely isolated from the main processor. StrongBox thus offers even stronger isolation than the software Keystore alone, but is not available on every Android device and should be treated as an optional enhancement, not a requirement.


// AuthStorage.js — react-native-keychain with biometry-gated access
import * as Keychain from 'react-native-keychain';

async function saveRefreshToken(token) {
  await Keychain.setGenericPassword('refresh_token', token, {
    service: 'de.mironsoft.app.refreshToken',
    accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}

async function loadRefreshToken() {
  const credentials = await Keychain.getGenericPassword({
    service: 'de.mironsoft.app.refreshToken',
  });
  return credentials ? credentials.password : null;
}

async function clearRefreshToken() {
  await Keychain.resetGenericPassword({
    service: 'de.mironsoft.app.refreshToken',
  });
}

export { saveRefreshToken, loadRefreshToken, clearRefreshToken };

5. Implementing with react-native-keychain

The basic functions setGenericPassword() and getGenericPassword() store and load a value under a username and password field, though in practice often only the password field is used for the actual token. Via the accessControl option, as shown above, an entry can additionally be bound to biometry or the device passcode, so simply reading the Keychain database without successful authentication is not enough.

The service name field acts as a namespace to store multiple independent credentials separately within the same project, for example an access token and a refresh token under different service names. Without this separation, a second setGenericPassword() call would overwrite the first entry, since react-native-keychain manages only a single generic entry per service by default.

In practice it pays to also handle access to particularly sensitive entries, such as the refresh token, gracefully in the error case: if the biometry check fails or is cancelled by the user, getGenericPassword() throws an error that the app should catch cleanly and communicate understandably to the user, rather than crashing the application.

6. Implementing with expo-secure-store

In the Expo managed workflow, expo-secure-store handles the same task with a deliberately reduced API: setItemAsync(key, value) and getItemAsync(key) store and read values under a simple key, without touching native configuration. Internally, the library also uses the Keychain on iOS and the Keystore on Android, so the security guarantees are identical to react-native-keychain.

The keychainAccessible option lets you configure the same access control behavior as react-native-keychain on iOS. One practical limitation concerns Android: due to the underlying Keystore mechanics, the size of a stored value is limited to roughly 2 KB, which is generally enough for tokens but not suitable for larger data sets such as whole objects or images.

Anyone who wants to store larger, but still sensitive, amounts of data should therefore follow a hybrid approach: keep the actual encryption key small and in secure storage, while storing the data encrypted with it in the regular filesystem or in an encrypted database like MMKV. This pattern avoids the size limitation without compromising security.


// SecureStoreExample.js — expo-secure-store usage
import * as SecureStore from 'expo-secure-store';

async function saveAccessToken(token) {
  await SecureStore.setItemAsync('access_token', token, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED,
  });
}

async function loadAccessToken() {
  try {
    return await SecureStore.getItemAsync('access_token');
  } catch (error) {
    // Item not found or keychain inaccessible — treat as logged out
    return null;
  }
}

async function clearAccessToken() {
  await SecureStore.deleteItemAsync('access_token');
}

export { saveAccessToken, loadAccessToken, clearAccessToken };

7. Token storage strategy for authentication

For authentication flows, a clear separation between access token and refresh token pays off. The access token is short-lived, is sent with every API request, and can only be misused for a short window if compromised. The refresh token is longer-lived and considerably more sensitive, since it allows renewing the access token without re-entering login credentials. Both belong in secure storage, but the refresh token deserves the stricter access control configuration.

Token rotation, where every refresh call issues a new refresh token and invalidates the old one, further limits the risk of a stolen but not yet used token. Important here: the complete JWT signing secret should never be stored client side, since the app could then forge valid tokens itself. Signature verification remains exclusively the backend's job.

A biometry-gated refresh token retrieval adds an extra layer of security: instead of automatically reading the refresh token on every app start without user interaction, the app additionally requires biometric confirmation before reading the token from the Keychain. This prevents an unlocked but unattended device from automatically continuing an existing session.

8. Migration and error handling

Existing apps that mistakenly stored tokens in AsyncStorage should perform the migration to secure storage automatically on the next app start: read existing values from AsyncStorage, write them to the Keychain or Android Keystore, and then delete the original AsyncStorage entries. This migration should run once and be marked with a flag so it is not unnecessarily repeated on every app start.

After a device restore from a backup, for example when switching to a new device, Keychain access can fail because hardware-bound keys cannot be transferred through a backup. This particularly affects entries with ThisDeviceOnly access control flags. The app should treat this case as a normal exception, silently discard the affected token, and prompt the user to log in again instead of crashing.

In general: an "item not found" error when reading from secure storage is a perfectly normal, expected state, for example on the very first app start with no prior login. Every read access should therefore be defensively wrapped in a try/catch or an equivalent error check and interpret this case explicitly as "not logged in" rather than as a technical error.

Uninstalling and reinstalling an app also behaves differently across platforms for secure storage: on Android, Keystore entries are reliably removed on uninstall, while iOS Keychain entries can, in some configurations, survive a reinstall. Anyone relying on a reinstall automatically resetting all secrets should test this behavior explicitly per platform rather than silently assuming it.

A simple test for this: install the app, write a test token to secure storage, uninstall the app, reinstall it, and check whether the test token is still readable. This manual check catches platform-specific misbehavior early, before it leads to unexpectedly persistent sessions in production.

9. Storage solutions compared

The following overview summarizes which storage solution fits which use case in React Native.

Solution Encryption Biometry binding Use case
AsyncStorage None Not possible Uncritical app settings
Encrypted MMKV Yes, symmetric Indirectly via the key Large, performance-critical data volumes
react-native-keychain / expo-secure-store Yes, Keychain / Keystore Yes, natively supported Tokens, credentials, small secrets

For the vast majority of use cases, especially access tokens and refresh tokens, combining react-native-keychain or expo-secure-store with appropriate access control flags is the right choice. Encrypted MMKV usefully complements this solution when larger encrypted data volumes need to be stored with high read/write frequency, while AsyncStorage should consistently stay limited to uncritical settings.

Mironsoft

React Native development for iOS and Android

Secrets that actually stay safe on the device?

We migrate existing AsyncStorage tokens to Keychain and Android Keystore, configure access control flags to match your security model, and build a resilient token storage strategy for your login flow.

Keychain & Keystore

Configuring react-native-keychain and expo-secure-store correctly

Token strategy

Access/refresh token separation, rotation, biometry-gated access

Migration

Safely taking over existing AsyncStorage data without downtime

10. Summary

Secure storage in React Native means placing secrets not in unencrypted AsyncStorage, but in the operating-system-encrypted Keychain on iOS or the Android Keystore. react-native-keychain and expo-secure-store pass through these native mechanisms via a unified API, including access control flags like WhenUnlockedThisDeviceOnly and optional biometry binding via biometryCurrentSet.

For authentication flows, a clear separation between a short-lived access token and a sensitive refresh token pays off, combined with token rotation and, where sensible, biometry-gated access to particularly sensitive entries. Migrating existing AsyncStorage code and handling missing entries or post-restore errors robustly round out a solid secure storage concept without disrupting the user experience with technical errors.

React Native Secure Storage and Keychain — the essentials at a glance

Never AsyncStorage for secrets

AsyncStorage is unencrypted. Tokens and credentials always belong in Keychain/Keystore.

Access control flags

WhenUnlockedThisDeviceOnly and biometryCurrentSet bind entries to device state and biometry.

Libraries

react-native-keychain (bare workflow) and expo-secure-store (managed workflow) wrap Keychain/Keystore.

Token strategy

Access token short-lived, refresh token more strictly protected, rotation, and never the JWT secret client side.

11. FAQ: React Native Secure Storage and Keychain

1Why is AsyncStorage unsuitable for tokens?
Unencrypted plain-text storage, directly readable with physical access or on a rooted device.
2react-native-keychain or expo-secure-store?
expo-secure-store in the managed workflow with no native configuration, react-native-keychain in the bare workflow for more control.
3Size limit on Android?
Roughly 2 KB per value due to Keystore mechanics. Store larger data encrypted in the filesystem, only the key in secure storage.
4What does WhenUnlockedThisDeviceOnly mean?
Readable only while unlocked, explicitly excluded from the iCloud backup. The value never leaves the device.
5How do you bind entries to biometry?
Via biometryCurrentSet in SecAccessControlCreateFlags. If enrolled biometry changes, access is automatically invalidated.
6Is StrongBox strictly required?
No, an optional extra security layer on supported devices, not a requirement for secure storage.
7How do I migrate from AsyncStorage?
Read values once, write to Keychain/Keystore, delete old entries, mark the migration as complete via a flag.
8Why does access fail after a device restore?
Hardware-bound keys with ThisDeviceOnly don't transfer to a new device. Catch the error and prompt the user to log in again.
9Treat access and refresh tokens the same?
No. The refresh token is longer-lived and more sensitive, deserving stricter access control flags than the short-lived access token.
10Can backups leak secrets?
Not with correct ThisDeviceOnly flags, since these explicitly exclude entries from the backup. Without these flags it's theoretically possible.