Where to Store JWTs in Mobile Apps: Keychain, Keystore, and Why AsyncStorage Isn't Enough
Our localStorage vs cookies comparison covers JWT storage for web apps, but the right answer for a React Native, iOS, or Android app is different hardware entirely. Mobile platforms give you a dedicated, hardware-backed secure storage layer that web browsers simply don't have — and the most common mobile JWT mistake is not using it.
The Mistake: Treating Mobile Storage Like Web Storage
AsyncStorage in React Native (and its underlying platform equivalents, UserDefaults on iOS or SharedPreferences on Android when accessed directly) is unencrypted, plain-text storage on disk. It's fine for UI preferences and non-sensitive cache. It is not fine for a JWT. On a rooted Android device or a jailbroken iPhone, that storage is trivially readable by any app with the right permissions — there's no secure enclave involved at all. If your app currently calls AsyncStorage.setItem('token', jwt), that's the one line to fix before anything else here.
iOS: the Keychain
iOS gives every app access to the Keychain, a system-managed encrypted store backed by the device's Secure Enclave on modern hardware. Items can be scoped with accessibility levels that control exactly when they're readable:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly— the right default for most tokens: only readable while the device is unlocked, and never included in a device backup or migrated to a new device (which matters, because you don't want a restored backup silently carrying forward someone else's valid session).- Biometric-gated items (
kSecAccessControlBiometryCurrentSet) are available if you want a token that requires Face ID/Touch ID to read — appropriate for a highly sensitive refresh token, usually overkill for a short-lived access token.
In React Native, react-native-keychain wraps this for you rather than requiring native Swift/Objective-C code:
import * as Keychain from 'react-native-keychain';
await Keychain.setGenericPassword('jwt', accessToken, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
const credentials = await Keychain.getGenericPassword();
const accessToken = credentials ? credentials.password : null;Android: the Keystore
Android's equivalent is the Android Keystore system — but it's important to understand what it actually protects. The Keystore is designed to hold cryptographic *keys* securely (ideally in hardware-backed storage on devices that support it), not arbitrary strings. The standard pattern for storing a token is therefore: generate or retrieve a Keystore-backed AES key, use it to encrypt the token, and store the resulting ciphertext in regular SharedPreferences — the token data itself never leaves the device unencrypted, but it's the *key* that's hardware-protected, not the token bytes directly.
In practice, you don't need to hand-roll this. EncryptedSharedPreferences (part of Android's Jetpack Security library) implements exactly this pattern for you, and react-native-keychain uses it automatically on Android, so the same JS call shown above works correctly on both platforms without you needing to know which native mechanism is underneath.
What Actually Belongs in Secure Storage
Not every token needs the same protection level:
- Refresh tokens (long-lived, high blast radius if stolen) belong in Keychain/Keystore, full stop.
- Access tokens (short-lived, 5–15 minutes typically) are lower stakes individually, but there's rarely a reason to treat them differently — if you're already calling the secure storage API for the refresh token, store the access token the same way.
- Never put a JWT secret or signing key on a mobile device at all — a mobile app is a client; it should only ever hold tokens issued *to* it, never the key used to issue or verify them. See how to store JWT secrets securely for where signing keys actually belong (your backend, not the client).
Memory, Not Just Disk
Secure storage solves the at-rest problem. It doesn't stop a token from sitting in app memory while in use, which matters for a narrower set of threats (a jailbroken device with a memory-inspection tool attached). For most apps this is an acceptable residual risk — don't let a theoretical memory-inspection attack talk you out of using the token at all, that's not a realistic trade. Just don't log tokens to the console or crash-reporting breadcrumbs, which is a far more common real leak than memory inspection ever is.
Frequently Asked Questions
Is `AsyncStorage` ever acceptable for a JWT?
Not for production. It's plain, unencrypted storage — fine for UI state, not for anything you'd call a credential. Use react-native-keychain (or the native Keychain/Keystore APIs directly) instead.
Do I need biometric gating on every token?
No — reserve biometric-gated access for something especially sensitive, like a long-lived refresh token in a high-risk app (banking, health data). Gating every access token behind a biometric prompt creates friction for no real security gain, since the device-unlock requirement already covers most of the realistic threat model.
What about web views inside a mobile app?
A web view embedded in a native app still has access to browser storage (localStorage, cookies) for whatever it renders, separate from the native app's own storage. If your app passes a token into a web view, treat that as a separate storage decision — see localStorage vs cookies for that side of it, and avoid handing the web view a long-lived token if you can get away with a short-lived one scoped to just what that web content needs.
Does this apply to Flutter apps too?
The same hardware-backed targets apply (Keychain on iOS, Keystore on Android) — Flutter's flutter_secure_storage package is the equivalent wrapper to react-native-keychain, using the same underlying platform mechanisms.
Should refresh tokens ever be stored server-side instead of on the device?
For higher-security apps, yes — a server-side refresh token store (so a revoked session can be killed immediately, see JWT revocation) is stronger than relying purely on device storage and token expiry. For most apps, secure on-device storage plus reasonably short refresh token lifetimes is a sound, simpler default.
---
Generate a secure JWT secret for your mobile backend: our free JWT Secret Generator runs entirely in your browser.
*Related: localStorage vs Cookies for JWT · How to Store JWT Secrets Securely · JWT Revocation: Blocklist Patterns*