React Native Keychain and secure storage: what to use, what to skip
react-native-keychain (bare) or Expo SecureStore (managed) for tokens, credentials, and anything sensitive. Never AsyncStorage. SSL pinning is a network-layer complement, not a substitute. Half the breaches we see start with "we stored the refresh token in AsyncStorage."
The storage question in React Native sounds like a tooling question. It's a security question dressed as a tooling question.
AsyncStorage is the default option every tutorial reaches for. It's plaintext. It lives on disk. On a jailbroken or rooted device it's a file. On a backed-up device, the backup often includes it. And it's where most teams still put the access token the moment they ship.
Here's the field guide.
The three options, ranked by when you should use them
1. Expo SecureStore — the right default for managed Expo apps
If you're on managed Expo, start here. SecureStore is built into the SDK. It wraps iOS Keychain and Android Keystore. You don't install anything. You don't touch native code. The API is four functions and they all do the right thing.
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('refreshToken', token);
const token = await SecureStore.getItemAsync('refreshToken');
await SecureStore.deleteItemAsync('refreshToken');
Works. Ship it. The only time to reach past SecureStore on managed Expo is when you need something it doesn't expose — biometric-gated access, access groups, item sharing across apps, or fine-grained accessibility flags.
2. react-native-keychain — the right default for bare React Native
On bare React Native, react-native-keychain is the one. It's the library every serious production app we audit has installed. Same underlying primitives (iOS Keychain, Android Keystore), richer API surface.
import * as Keychain from 'react-native-keychain';
// Store
await Keychain.setGenericPassword('user', token, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY,
});
// Retrieve
const credentials = await Keychain.getGenericPassword();
if (credentials) {
const { username, password } = credentials;
}
// Delete
await Keychain.resetGenericPassword();
The interesting part is the options object. Get these wrong and you get security theater. Get them right and the data is genuinely protected.
3. Encrypted AsyncStorage libs — only when you can't use the above
Libraries like react-native-encrypted-storage exist because some teams need a drop-in replacement for AsyncStorage with encryption on top. It's fine. It's not as good as Keychain/SecureStore — you're asking JavaScript-adjacent code to do the encryption instead of leaning on OS primitives with hardware backing.
Use it only when you've got a concrete reason you can't use Keychain or SecureStore. Migrating away from AsyncStorage gradually is a legit reason. "I heard it was simpler" is not.
The options that actually matter on react-native-keychain
Most Keychain bugs we find in audits are misconfigured options, not missing ones. The library is correct. The config is wrong.
accessible — when the item is readable
Controls when iOS will unlock the item. Most teams want WHEN_UNLOCKED_THIS_DEVICE_ONLY. It means: only when the device is unlocked, and don't back this up to iCloud / restore to another device.
The footgun: the default on older versions of the library was WHEN_UNLOCKED (without THIS_DEVICE_ONLY). That backs up. If the user restores their iPhone, the token travels. You don't want that for auth material.
accessControl — who can read the item
This is the biometric gate. Options like BIOMETRY_ANY, BIOMETRY_CURRENT_SET, and USER_PRESENCE mean the OS requires Face ID / Touch ID / PIN before releasing the value.
The tradeoff: every getGenericPassword() becomes a biometric prompt. Put access-token refresh behind biometry and your app prompts constantly. Gate the long-lived credential (refresh token, keychain-wrapped password) with biometry; keep short-lived access tokens in memory or in a separate, non-gated entry.
service — namespace your entries
If your app stores more than one secret, use service to namespace them. Otherwise every call reads and writes the same slot and you get weird bugs where logging out of one identity also wipes credentials for another.
What never to store in AsyncStorage
Not a complete list. A starting list.
- Auth tokens (access, refresh, API keys)
- OAuth client secrets (but actually: don't ship those in a mobile app at all)
- User credentials (username + password, biometric seeds)
- Session identifiers that grant server access
- Encryption keys for other stored data
- PII governed by HIPAA / GDPR / CCPA
- Anything you'd be embarrassed to see in a breach disclosure
AsyncStorage is fine for UI state, last-viewed IDs, cached non-sensitive API responses, feature flags, onboarding progress. Anything an attacker with file-system access wouldn't care about.
The network-side complement: SSL pinning
Secure storage protects data at rest. SSL pinning protects it in transit — specifically against man-in-the-middle attacks using compromised or user-installed certificates. react-native-ssl-pinning and react-native-ssl-public-key-pinning are the two options most teams use.
Pin the leaf certificate or, better, the public key. Ship the pin in the app bundle. If the server presents a cert whose key doesn't match, the request fails closed.
The honest downside: pinning creates a rotation problem. If your server's certificate chain changes and you didn't ship a new app with an updated pin, every user gets a hard failure. We've seen this take down apps for a full day because nobody in the rotation playbook remembered the mobile app.
Pragmatic stance:
- Finance, health, government, enterprise: pin. The threat model justifies the operational cost.
- Consumer apps handling payment or medical data: pin. The regulators expect it.
- Most other consumer apps: skip it. The outage risk from rotation mistakes usually exceeds the MITM risk. Invest the effort in the token storage and auth flow instead.
If you pin, pin two certificates (primary + backup) and document the rotation procedure in the same place as your DNS and TLS cert rotation playbook. The mobile app has to ship before the server cut-over.
Decision table
| You are... | Use | Also consider |
|---|---|---|
| Managed Expo | Expo SecureStore | Pin if regulated |
| Bare React Native | react-native-keychain | Pin if regulated |
| Stuck in AsyncStorage | Migrate to above | One-shot migration on launch |
| Need biometric-gated access | react-native-keychain w/ accessControl | Separate entries per sensitivity |
| Regulated industry (finance/health/gov) | Keychain/SecureStore + SSL pinning | Document rotation procedure |
| Consumer app, no regulatory pressure | Keychain/SecureStore | Skip pinning; fix storage first |
Migrating off AsyncStorage without breaking users
If you're reading this and you have tokens in AsyncStorage today, the migration is a one-shot, version-gated function on app launch.
const MIGRATION_VERSION = 'v1-2026-05';
async function migrateToSecureStorage() {
const done = await AsyncStorage.getItem('migration:secureStorage');
if (done === MIGRATION_VERSION) return;
const token = await AsyncStorage.getItem('refreshToken');
if (token) {
await Keychain.setGenericPassword('user', token, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
await AsyncStorage.removeItem('refreshToken');
}
await AsyncStorage.setItem('migration:secureStorage', MIGRATION_VERSION);
}
Run it in your app-launch bootstrap, before any authenticated request fires. Version the marker so it doesn't re-run. Drop the AsyncStorage read path in the next release — leaving it behind is how legacy code wins.
react-native-keychain, migrate the new flow, and never go back to clean up the AsyncStorage reads. A month later someone adds a new feature that reaches for the old AsyncStorage key because the example in the internal wiki shows it. Delete the example.
Common mistakes, ranked by frequency
- Tokens in AsyncStorage. The default mistake. Most prevalent.
- Keychain with wrong
accessibleflag. Allowing iCloud backup of auth material. - Biometric prompt on every token refresh. Destroys UX; users turn off biometry.
- SSL pinning without a rotation plan. The cert changes, the app breaks, the team gets paged.
- Storing OAuth client secrets in the app bundle. They're extractable from any compiled binary. Move the flow.
- "JavaScript-layer encryption" of AsyncStorage with a hardcoded key. The key is in the bundle. This is not encryption.
- Leftover debug logging of secrets.
console.log(token)in production. Check your Sentry / Crashlytics breadcrumbs.
What this looks like in an audit
When we run a React Native security audit, secure storage is the first thing we check. It takes 30 minutes and it's the highest-signal finding we ever deliver. About half the apps we see still have auth material somewhere they shouldn't. Usually it's one of: AsyncStorage, a plaintext config file, a hardcoded OAuth client secret, or a debug log that still prints tokens.
Fix this layer and you've closed the most common path to a serious breach. Then worry about pinning, obfuscation, jailbreak detection — the layers above.
Want us to audit your React Native secure storage?
We run fixed-price security audits that include storage review, dependency CVE scan, and SSL pinning assessment. Two weeks, senior engineers, written report.
See the Security Audit →Frequently Asked Questions
AsyncStorage is plaintext key-value storage. react-native-keychain wraps iOS Keychain and Android Keystore — OS-level secure storage with hardware backing on most modern devices. Don't store credentials, tokens, or PII in AsyncStorage. Use Keychain (or Expo SecureStore) for anything sensitive.
On managed Expo, use SecureStore — it's built-in and covers the same underlying Keychain/Keystore APIs for the common case. On bare React Native, or when you need advanced features (biometric auth, access control, sharing across apps), use react-native-keychain.
Yes — but the encryption is the OS's, not the library's. On iOS it stores in Keychain, protected by the Secure Enclave on supporting devices. On Android it uses the Keystore system with hardware-backed keys where available. The library is a thin bridge; the security guarantees are the OS's.
If you're in regulated verticals (finance, health, government) or handling auth tokens for high-value accounts, yes. SSL pinning blocks man-in-the-middle attacks via compromised CAs or user-installed root certs. For most consumer apps it's overkill and a support headache — rotation mistakes ship more outages than attacks.
Write a one-shot migration on app launch: read from AsyncStorage, write to Keychain/SecureStore, wipe AsyncStorage. Version the migration so it only runs once. Drop the AsyncStorage read path in the next release so legacy code doesn't win.
On managed Expo, no — native module. On Expo with a dev client or after expo prebuild, yes. If you're on managed Expo and don't want to eject, SecureStore covers the common case. If you need what only react-native-keychain offers, you're probably already past managed workflow anyway.
Pin two certificates — the current one and the next one. Ship the next pin in a release before the server cuts over. Document the rotation in the same playbook as your DNS/TLS rotation. The most common outage isn't the attack — it's forgetting the mobile app in the rotation sequence.
Layer four or five on the defense stack. Fix storage and transport first. Add detection if your threat model justifies it (finance, enterprise). Recognize that determined attackers bypass detection; it raises cost, doesn't close attack surface.