Auth tokens on mobile: where they live and why AsyncStorage fails
A field worker logs into your React Native app on a Rs. 7,000 Android tablet, does their rounds, and hands the tablet to the next shift. The token your app stored is still valid. AsyncStorage keeps it in an unencrypted SQLite file on the filesystem. Any app with file access — or a USB cable and ten minutes — can read it.
AsyncStorage is a key-value store. It persists data across app launches. It does not encrypt, it does not integrate with the OS keychain, and it does not clear on device lock. It is fine for UI state, theme preferences, or a draft comment. It is the wrong place for a credential.
iOS: Keychain via react-native-keychain
Apple provides Keychain Services. Items can be scoped to require the device to be unlocked before access. The library wraps the C API behind a JS module.
import * as Keychain from 'react-native-keychain';
await Keychain.setGenericPassword(
'api-auth',
refreshToken,
{ accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET }
);
const creds = await Keychain.getGenericPassword();
const token = creds.password;The `accessControl` option ties retrieval to the current biometric set. If a user adds a fingerprint, the token is invalidated. That is a tradeoff: convenience drops, but a shared device cannot carry forward the previous user's session.
Android: EncryptedSharedPreferences via androidx.security:crypto
Android lacks a single keychain. `EncryptedSharedPreferences` wraps SharedPreferences with AES-256-GCM for values and AES-256-SIV for keys. The master key lives in the Android Keystore, which is hardware-backed on devices with TEE.
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKeyScheme.AES256_GCM)
.build()
val prefs = EncryptedSharedPreferences.create(
context,
'auth_store',
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit().putString('refresh_token', token).apply()Cheap Android hardware is where this matters most. The field devices we ship to are often running Android Go on low-end MediaTek chipsets. The Keystore is still present, and TEE-backed encryption works on Android 6.0 and later. The failure mode is a device without TEE — the key falls back to software-backed storage. Still encrypted, but the key is extractable with root access.
The migration path
If your app is already in production with tokens in AsyncStorage, you can migrate on next launch: read the existing token, store it in the secure location, and delete the AsyncStorage entry. Do not try to dual-source. One source of truth, cleared from the old location on the same launch.
The tradeoff with both Keychain and EncryptedSharedPreferences is that biometric-gated access blocks background refresh. If your app polls for new assignments every five minutes, the token retrieval will fail when the device is locked. For our field service apps, we use two tokens: a long-lived refresh token in biometric-protected storage and a short-lived access token in memory, refreshed when the app is foregrounded and the user authenticates.
What we do
We use `react-native-keychain` for iOS and a thin native module wrapping `EncryptedSharedPreferences` for Android. The module exposes `setToken`, `getToken`, and `clearToken`. The API surface is small because the storage logic is not complex. The complexity is in the lifecycle: when to ask for biometrics, when to fall back to PIN, and when to force re-login. Those are product decisions tied to how the app is used, not library choices.