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.
Table of Contents
- 1. Why AsyncStorage is unsuitable for secrets
- 2. Overview of secure storage solutions
- 3. iOS Keychain fundamentals
- 4. Android Keystore fundamentals
- 5. Implementing with react-native-keychain
- 6. Implementing with expo-secure-store
- 7. Token storage strategy for authentication
- 8. Migration and error handling
- 9. Storage solutions compared
- 10. Summary
- 11. FAQ
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.