1.069 Mobile Secure Storage / Keychain Libraries#


Explainer

Domain Explainer: Mobile Secure Storage / Keychain#

What Problem Does This Solve?#

When a mobile app logs a user in, it receives a secret — an access token, refresh token, or session credential. The app needs to store this somewhere that:

  1. Persists across app restarts
  2. Cannot be read by other apps on the device
  3. Cannot be trivially extracted if the device is stolen
  4. Optionally, requires the user to authenticate (Face ID / fingerprint) before access

The naive solution — writing to a file, using AsyncStorage (React Native), or SharedPreferences (Android) — stores data in plaintext. Any app with root access can read it. Backups may expose it.

The right solution is the hardware-backed secure store built into every modern mobile OS.


The Core Concept: Hardware Root of Trust#

Modern mobile devices have dedicated security hardware:

  • iOS: The Secure Enclave (SE) — a separate co-processor on every iPhone/iPad since 2013. It has its own CPU, OS (sepOS), and encrypted memory. The main processor cannot directly access the SE.

  • Android: The Trusted Execution Environment (TEE) — an isolated zone within (or alongside) the main processor. Higher-end devices have a StrongBox — a fully separate security chip (e.g., Google Titan M on Pixel devices).

The key insight: a private key generated inside the SE or TEE never leaves. The hardware performs cryptographic operations (sign, decrypt, ECDH) and returns results. Your app code never sees the raw key bytes.

For credential storage (passwords, tokens), these hardware roots of trust protect the encryption keys used to secure the data. Even if an attacker reads the file from disk, they can’t decrypt it without the hardware-protected key.


iOS: Keychain Services#

The iOS Keychain is an encrypted database managed by the OS. Your app stores items in it; the OS handles encryption, access control, and optionally biometric gating.

Your app → kSecClass: kSecClassGenericPassword
              kSecAttrService: "com.myapp"
              kSecAttrAccount: "[email protected]"
              kSecValueData: [secret bytes]
         → iOS Keychain (encrypted, hardware-protected)

Access control: You specify when the item can be accessed:

  • kSecAttrAccessibleWhenUnlocked — only when the device is unlocked (recommended)
  • kSecAttrAccessibleAfterFirstUnlock — also accessible in background after first unlock
  • kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly — requires device passcode, device-only

Biometric gating: With SecAccessControlCreateWithFlags, you attach Face ID or Touch ID requirements to the item. The Keychain service triggers the biometric prompt automatically when the item is retrieved.

iCloud sync: Items can optionally sync to other Apple devices via iCloud Keychain. For session tokens, always disable this.


iOS: Secure Enclave#

The Secure Enclave holds cryptographic keys (not arbitrary data). Use it when you need:

  • On-device signing (signing HTTP requests, documents, challenges)
  • Key agreement (deriving shared secrets for encryption)
  • Biometric-gated key operations

Only EC P-256 is supported in the Secure Enclave. RSA and Ed25519 must use the regular Keychain (software or hardware-accelerated, but not SE).

CryptoKit (iOS 13+) makes SE usage simple:

let key = try SecureEnclave.P256.Signing.PrivateKey()
let signature = try key.signature(for: myData)
// key.dataRepresentation is an opaque reference you store; the private key never leaves the SE

Android: Android Keystore#

The Android Keystore is the equivalent system. Keys generated in the Keystore cannot be extracted.

App code → KeyPairGenerator.getInstance(EC, "AndroidKeyStore")
         → TEE / StrongBox (hardware)
         → Returns opaque key reference

Hardware levels:

  • StrongBox: Separate chip. Highest assurance. Available on Pixel 3+ and most flagships since 2018.
  • TEE: Isolated processor zone. Hardware-backed. Available on all Android 6.0+ devices.
  • Software: Old devices, emulators. No real protection.

EncryptedSharedPreferences (Jetpack Security): A convenient higher-level wrapper. Stores encrypted key-value pairs where the encryption key lives in the Keystore. Simple API, no biometric gating, transparent to the app.

Biometric + CryptoObject pattern: To gate a cryptographic operation on biometrics, pass a Keystore-backed cipher to BiometricPrompt.CryptoObject. The cipher is unlocked only after genuine biometric success — the key never moves to app memory until the user authenticates.


Cross-Platform Libraries#

Since most mobile developers use React Native or Flutter, cross-platform libraries wrap the platform APIs:

LibraryPlatformWhat it wraps
react-native-keychainReact NativeiOS Keychain, Android Keystore
expo-secure-storeReact Native / ExpoiOS Keychain, Android EncryptedSharedPreferences
flutter_secure_storageFlutteriOS Keychain, Android EncryptedSharedPreferences

These libraries are thin wrappers — they translate your JavaScript/Dart API calls into native platform calls. They don’t add cryptographic functionality; they add unified APIs and biometric prompt handling.


Key Concepts Glossary#

Keychain: The iOS secure credential database. Each app has its own isolated namespace.

Keystore: The Android secure key container. Keys in the Keystore cannot be exported.

Secure Enclave (SE): Apple’s dedicated hardware security processor. Handles cryptographic operations for EC P-256 keys. Available A7+ (2013+).

TEE (Trusted Execution Environment): A secure zone within the main processor. Android’s equivalent of the Secure Enclave for key storage.

StrongBox: A fully separate security chip on modern Android devices. Higher assurance than TEE.

SecAccessControl: An iOS object that attaches biometric and access requirements to a Keychain item.

BiometricPrompt + CryptoObject: The Android pattern for cryptographically binding biometric authentication to a Keystore operation.

kSecAttrSynchronizable: iOS Keychain attribute controlling iCloud sync. Set to false for session tokens.

EncryptedSharedPreferences: Android Jetpack library that stores encrypted key-value pairs using a Keystore-backed master key.

Key Attestation: An Android Keystore feature that generates a certificate chain allowing a server to verify that a key was created in hardware.

Passkeys (FIDO2): A newer standard for authentication using hardware-bound keys. Complements (doesn’t replace) secure storage for tokens and credentials.


What This Domain Does NOT Cover#

  • Encrypted databases (SQLCipher, Room with encryption) — different use case: encrypting local databases, not credentials
  • macOS / Windows keychain — different platforms with different APIs
  • Backend HSMs — server-side hardware security modules
  • Certificate management — installing and validating TLS certificates (different domain)
S1: Rapid Discovery

S1 Approach: Mobile Secure Storage / Keychain Libraries#

Research ID: 1.069 Pass: S1 — Rapid Discovery Date: 2026-02-17

Scope#

What we need to know in 15 minutes: the primary platform APIs and cross-platform abstraction libraries for storing secrets (passwords, tokens, keys) securely on mobile devices.

Key Questions#

  1. What are the platform-native APIs? (iOS Keychain, Android Keystore)
  2. What cross-platform React Native / Flutter libraries wrap them?
  3. How does biometric gating work? Hardware-backed vs software?
  4. What are the key security properties: export restrictions, access control, iCloud sync?

Search Strategy#

  • iOS: Apple docs on Keychain Services, Secure Enclave, CryptoKit
  • Android: Android docs on Android Keystore, Jetpack Security (EncryptedSharedPreferences)
  • Cross-platform: npm registry for react-native-keychain, expo-secure-store; pub.dev for flutter_secure_storage
  • Stars/downloads from npm, GitHub, pub.dev

S1 Overview: Mobile Secure Storage / Keychain Libraries#

The Core Problem#

Mobile apps need to store secrets — API tokens, passwords, cryptographic keys — in a way that:

  • Survives app restart
  • Cannot be extracted by other apps
  • Cannot be trivially read from a device backup
  • Optionally requires user authentication (biometrics or PIN) to access

The platform answer is a dedicated hardware-backed secure store on every modern mobile device.


Platform APIs#

iOS: Keychain Services#

The iOS Keychain is a secure, encrypted database managed by the OS. The main API is SecItem*:

// Store a password
let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.myapp",
    kSecAttrAccount as String: "[email protected]",
    kSecValueData as String: "mysecrettoken".data(using: .utf8)!
]
SecItemAdd(query as CFDictionary, nil)

// Retrieve
var result: AnyObject?
let readQuery: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.myapp",
    kSecAttrAccount as String: "[email protected]",
    kSecReturnData as String: true
]
SecItemCopyMatching(readQuery as CFDictionary, &result)
let data = result as! Data

Item classes: kSecClassGenericPassword, kSecClassInternetPassword, kSecClassKey, kSecClassCertificate

Accessibility: Controls when items are available:

  • kSecAttrAccessibleWhenUnlocked — only when device is unlocked (default, recommended)
  • kSecAttrAccessibleAfterFirstUnlock — after first unlock (for background tasks)
  • kSecAttrAccessibleAlways — always (deprecated, avoid)
  • kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly — requires device passcode, not migrated to new device

Biometric gating:

let access = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    [.biometryAny, .or, .devicePasscode],
    nil
)!
// Use `access` in kSecAttrAccessControl when adding/retrieving the item
// On retrieval, Face ID/Touch ID prompt is shown automatically

iCloud sync: kSecAttrSynchronizable: true — syncs across user’s iCloud Keychain. Use kSecAttrSynchronizable: kCFBooleanFalse to explicitly prevent sync (recommended for session tokens).

Access groups: kSecAttrAccessGroup allows sharing keychain items between apps from the same team.

iOS: Secure Enclave#

The Secure Enclave (SE) is a dedicated hardware security processor, available on A7 chip and later (iPhone 5s, 2013+). Private keys stored in the SE never leave — the CPU never sees the raw key material.

// Generate an EC key pair in the Secure Enclave
let access = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    .privateKeyUsage,  // or .biometryAny for biometric gating
    nil
)!

let attributes: [String: Any] = [
    kSecAttrKeyType as String: kSecAttrKeyTypeEC,       // Only EC (P-256) supported
    kSecAttrKeySizeInBits as String: 256,
    kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
    kSecPrivateKeyAttrs as String: [
        kSecAttrIsPermanent as String: true,
        kSecAttrApplicationTag as String: "com.myapp.key",
        kSecAttrAccessControl as String: access
    ]
]
var error: Unmanaged<CFError>?
let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
// privateKey is a reference — actual bits stay in Secure Enclave

CryptoKit (iOS 13+): Higher-level API for Secure Enclave operations:

import CryptoKit
let privateKey = try SecureEnclave.P256.Signing.PrivateKey()
let publicKey = privateKey.publicKey
let signature = try privateKey.signature(for: data)

Limitations: Only EC P-256 supported. RSA and Ed25519 are NOT supported in Secure Enclave.


Android: Keystore System#

Android Keystore is a hardware-backed key store (on most modern devices). Keys generated inside the Keystore cannot be extracted.

// Generate an AES key backed by Keystore
val keyGenerator = KeyGenerator.getInstance(
    KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
keyGenerator.init(
    KeyGenParameterSpec.Builder(
        "my_key_alias",
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)           // Requires biometric or PIN
    .setInvalidatedByBiometricEnrollment(true)    // Invalidate on new fingerprint
    .build()
)
val key = keyGenerator.generateKey()

Hardware backing levels (checked at runtime):

  • StrongBox: Dedicated security chip (Titan M on Pixel, SE on iPhone equivalent). Available API 28+ on supported hardware.
  • TEE (Trusted Execution Environment): Isolated processor within main SoC. Hardware-backed on most modern devices.
  • Software: Emulated in userspace. No hardware protection.
val keyInfo = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
keyInfo.isInsideSecureHardware   // TEE or StrongBox
keyInfo.securityLevel            // SecurityLevel enum (API 31+)

Biometric authentication:

val biometricPrompt = BiometricPrompt(activity, executor, callback)
// The callback's onAuthenticationSucceeded gives a CryptoObject
// wrapping the Keystore-backed cipher — the key is released only after biometric success

Jetpack Security / EncryptedSharedPreferences:

// High-level wrapper: stores encrypted key-value pairs
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val sharedPreferences = EncryptedSharedPreferences.create(
    context,
    "secret_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// Use like normal SharedPreferences

Jetpack Security wraps Android Keystore behind a simpler interface. The master key lives in Keystore; data lives encrypted in SharedPreferences XML.


Cross-Platform Libraries#

react-native-keychain#

GitHub: oblador/react-native-keychain | ~3.4K stars | npm: react-native-keychain | ~350K downloads/week Version: 10.0.0 | Language: JS + native (Swift/Kotlin)

The standard React Native library for secure credential storage:

import * as Keychain from 'react-native-keychain';

// Store credentials
await Keychain.setGenericPassword('username', 'password', {
  service: 'myapp',
  accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED,
  accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY,
});

// Retrieve (triggers biometric prompt if accessControl set)
const credentials = await Keychain.getGenericPassword({ service: 'myapp' });
// credentials.username, credentials.password

// Internet passwords (URL-keyed)
await Keychain.setInternetCredentials('example.com', 'user', 'pass');

iOS backend: Uses iOS Keychain (kSecClassGenericPassword) Android backend: Uses Android Keystore + EncryptedSharedPreferences (or Keystore directly for keys) Biometrics: Supports BIOMETRY_ANY, BIOMETRY_CURRENT_SET, DEVICE_PASSCODE

expo-secure-store#

npm: expo-secure-store | Part of Expo SDK (no separate install in managed workflow) Version: 15.0.8 | Size limit: 2KB per value

Simpler API, part of the Expo ecosystem:

import * as SecureStore from 'expo-secure-store';

await SecureStore.setItemAsync('token', 'abc123', {
  keychainAccessible: SecureStore.WHEN_UNLOCKED,
});
const token = await SecureStore.getItemAsync('token');

iOS: Uses iOS Keychain Android: Uses Android Keystore-backed EncryptedSharedPreferences Limitation: 2KB value size limit. Not suitable for storing large blobs.

expo-local-authentication#

import * as LocalAuthentication from 'expo-local-authentication';

const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
const result = await LocalAuthentication.authenticateAsync({
  promptMessage: 'Authenticate',
  fallbackLabel: 'Use PIN',
});
if (result.success) { /* proceed */ }

Note: This library triggers authentication only — it does not gate key access. For hardware-bound key access, use react-native-keychain with ACCESS_CONTROL.BIOMETRY_ANY.

flutter_secure_storage#

pub.dev: flutter_secure_storage | 160/160 pub points | version: 10.0.0 (2025) Downloads: High (pub.dev). v9+ migrated Android backend from JetSec to Google Tink.

const storage = FlutterSecureStorage();
await storage.write(key: 'token', value: 'abc123');
final token = await storage.read(key: 'token');
await storage.delete(key: 'token');

iOS: Uses Keychain Services Android: Uses EncryptedSharedPreferences (Jetpack Security) since v5.x. Older versions used RSA + AES manually.


Quick Verdict Table#

ToolPlatformBackendBiometric GatingHardware-BackedNotes
iOS KeychainiOSKeychain DBOptional (SecAccessControl)Yes (data encrypted by OS)Standard for credentials
Secure EnclaveiOSSE chipOptionalYes (A7+, private key never leaves)For key operations only
Android KeystoreAndroidTEE / StrongBoxOptionalYes (API 23+, StrongBox API 28+)Standard for cryptographic keys
EncryptedSharedPreferencesAndroidKeystore + SharedPrefsNo (transparent)Yes (master key in Keystore)Easy key-value store
react-native-keychainReact NativeNative platformYesYesBest RN option
expo-secure-storeReact NativeNative platformNoYesSimple, 2KB limit
flutter_secure_storageFlutterNative platformNoYesStandard Flutter option

S1 Recommendation: Mobile Secure Storage / Keychain Libraries#

Preliminary Winners#

Use CaseRecommendation
React Native: store API tokens, passwordsreact-native-keychain — biometric gating, platform-native
React Native (Expo managed): simple token storageexpo-secure-store — built-in, simple, 2KB limit
React Native: biometric authentication (no key storage)expo-local-authentication
Flutter: store secretsflutter_secure_storage — standard, maintained
iOS native: credential storageiOS Keychain (SecItem* API)
iOS native: on-device signing/encryptionSecure Enclave via CryptoKit
Android native: credential storageEncryptedSharedPreferences (Jetpack Security)
Android native: cryptographic keysAndroid Keystore (KeyPairGenerator/KeyGenerator)

Key Decision: react-native-keychain vs expo-secure-store#

  • Need biometric gating on the storage itself? → react-native-keychain
  • Need values > 2KB? → react-native-keychain
  • Simple token storage, no biometrics, Expo managed workflow? → expo-secure-store
  • Want to avoid extra native module? → expo-secure-store

Anti-Patterns#

  • Never use AsyncStorage for secrets (plaintext)
  • Never use mmkv for secrets (fast, but not hardware-backed)
  • Don’t roll your own encryption on top of unprotected storage — use platform APIs directly

S1 Synthesis: Mobile Secure Storage / Keychain Libraries#

Key Findings#

1. Two Distinct Use Cases#

Secure storage on mobile falls into two categories:

  • Credential storage: Storing passwords, tokens, API keys as opaque byte blobs (iOS Keychain, Android EncryptedSharedPreferences)
  • Cryptographic key storage: Storing key material where the device does signing/encryption operations (Secure Enclave, Android Keystore key operations)

Most apps need credential storage. Key storage is for apps doing on-device cryptography.

2. Hardware Backing Is Standard#

  • iOS: All Keychain items are protected by the Secure Enclave indirectly (the Keychain encryption key is derived from hardware). Secure Enclave is available on A7+ (2013+).
  • Android: Hardware Keystore (TEE) is required for API 23+ (Android 6.0+). StrongBox (dedicated chip) available on most post-2018 flagships.
  • Both platforms: Private keys never leave hardware. Only the public key is accessible to app code.

3. Cross-Platform Libraries Are Thin Wrappers#

react-native-keychain and flutter_secure_storage simply call the native platform APIs. They add:

  • Unified JavaScript/Dart API
  • Biometric prompt handling (react-native-keychain)
  • Sensible defaults

They do NOT add cryptographic functionality beyond what the platform provides.

4. Biometric Gating Is Platform-Specific#

  • iOS: SecAccessControl with .biometryAny gates the Keychain item — Keychain service shows Face ID/Touch ID. The key never moves to app memory without authentication.
  • Android: BiometricPrompt used with a CryptoObject wrapping a Keystore-backed cipher. The cipher is unlocked only after biometric success.
  • Cross-platform: react-native-keychain exposes this; expo-secure-store does not (no biometric gating).

5. Common Pitfalls#

  • AsyncStorage is NOT secure: AsyncStorage stores data in plaintext. Never use it for secrets.
  • expo-secure-store 2KB limit: Not suitable for storing certificates or large tokens.
  • iCloud sync by default on iOS: Without explicit opt-out, Keychain items may sync to iCloud. Set kSecAttrSynchronizable: false.
  • Android backup: By default, Android can back up SharedPreferences. EncryptedSharedPreferences are safe from backup attack because the key stays in Keystore (not backed up).

What S2 Should Investigate#

  • Key attestation (Android): verifying a key is hardware-backed remotely
  • iOS key invalidation on biometric enrollment change (kSecAccessControlBiometryCurrentSet)
  • EncryptedSharedPreferences vs raw Keystore: trade-offs
  • react-native-keychain v9 changes vs v8
  • Rooted/jailbroken device threat model: what protection remains?
S2: Comprehensive

S2 Approach: Mobile Secure Storage / Keychain Libraries#

Research ID: 1.069 Pass: S2 — Comprehensive Analysis Date: 2026-02-17

Deep-Dive Scope#

  1. iOS Keychain internals: access control flags, biometric gating mechanics, key invalidation, iCloud sync behavior, access groups
  2. Secure Enclave deep dive: supported algorithms, key lifecycle, CryptoKit vs SecKey API
  3. Android Keystore internals: hardware attestation, StrongBox vs TEE, biometric CryptoObject pattern, key invalidation on biometric change
  4. EncryptedSharedPreferences: what it actually does under the hood, limitations
  5. react-native-keychain v9: complete API, platform differences, biometric prompt customization
  6. Threat model: what remains if device is rooted/jailbroken?
  7. Key attestation: verifying hardware backing remotely (Android)

S2 Comprehensive Analysis: Mobile Secure Storage / Keychain Libraries#

Research ID: 1.069 Pass: S2 — Comprehensive Analysis Date: 2026-02-17


1. iOS Keychain: Deep Dive#

Access Control and Item Accessibility#

The kSecAttrAccessible attribute controls when Keychain items can be accessed:

ValueAccessible WhenDevice MigrationiCloud Sync
kSecAttrAccessibleWhenUnlockedDevice unlockedYesYes
kSecAttrAccessibleAfterFirstUnlockAfter first unlock (background OK)YesYes
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyUnlocked + passcode setNoNo
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyUnlockedNoNo
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyAfter first unlockNoNo

ThisDeviceOnly variants: The item is encrypted with a device-specific key derived from hardware. It cannot be migrated to a new device or restored from iCloud backup.

Recommended for session tokens: kSecAttrAccessibleWhenUnlockedThisDeviceOnly — requires unlocked device, doesn’t roam.

SecAccessControl: Biometric Gating#

SecAccessControlCreateWithFlags creates an access control object that gates Keychain access:

// Gate: biometric (Face ID or Touch ID) OR device passcode
let access = SecAccessControlCreateWithFlags(
    kCFAllocatorDefault,
    kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    [.biometryAny, .or, .devicePasscode],
    nil
)!

// Store item with access control
let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.myapp.sensitive",
    kSecAttrAccount as String: "apikey",
    kSecValueData as String: secretData,
    kSecAttrAccessControl as String: access,
]
SecItemAdd(query as CFDictionary, nil)

// Retrieve — iOS will prompt Face ID/Touch ID automatically
let readQuery: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.myapp.sensitive",
    kSecAttrAccount as String: "apikey",
    kSecReturnData as String: true,
    kSecUseOperationPrompt as String: "Authenticate to access your API key",
]
var result: AnyObject?
SecItemCopyMatching(readQuery as CFDictionary, &result)

Key flags:

  • .biometryAny: Any enrolled biometric. Adding a new finger/face does NOT invalidate the item.
  • .biometryCurrentSet: Only the currently enrolled set. Adding a new fingerprint invalidates the item — user must re-store it. Use this when you want to detect new enrollment.
  • .devicePasscode: Device PIN fallback.
  • .or / .and: Combine flags.

iCloud Keychain Sync#

By default (no kSecAttrSynchronizable set), items are NOT synced. To explicitly sync:

kSecAttrSynchronizable as String: kCFBooleanTrue  // sync
kSecAttrSynchronizable as String: kCFBooleanFalse // this device only
// kSecAttrSynchronizable: kSecAttrSynchronizableAny  // match both in queries

Security implication: Synced items leave the device and can appear on other Apple devices. Never sync session tokens, private keys, or sensitive credentials. Sync is appropriate for passwords users would re-enter on multiple devices.

Keychain Access Groups (App-to-App Sharing)#

Apps from the same developer team can share Keychain items:

kSecAttrAccessGroup as String: "TEAMID.com.mycompany.shared"

Access groups must be declared in the app’s Entitlements.plist. The team ID prefix is required.


2. Secure Enclave: Deep Dive#

What the Secure Enclave Is#

The Secure Enclave (SE) is a hardware co-processor present on all Apple devices with A7 chip or later (2013+). It has its own OS (sepOS), dedicated memory, and does not share memory with the Application Processor.

Key security property: A private key generated in the SE can never be extracted. All private key operations (sign, ECDH) happen inside the SE. The app receives only the result.

Supported Algorithms#

AlgorithmKeychainSecure Enclave
EC P-256
EC P-384
RSA (any size)
Ed25519✅ (iOS 14+)
AES❌ (no symmetric in SE)

The Secure Enclave only supports EC P-256. All signing, ECDH, and encryption use P-256.

CryptoKit API (iOS 13+)#

import CryptoKit

// Generate SE-backed signing key
let privateKey = try SecureEnclave.P256.Signing.PrivateKey()

// Sign data
let data = Data("hello".utf8)
let signature = try privateKey.signature(for: data)

// Verify (uses public key — no SE required)
let publicKey = privateKey.publicKey
let isValid = publicKey.isValidSignature(signature, for: data)

// ECDH key agreement (for encryption)
let ecdhKey = try SecureEnclave.P256.KeyAgreement.PrivateKey()
let sharedSecret = try ecdhKey.sharedSecretFromKeyAgreement(with: peerPublicKey)

// Persist the key across sessions
let dataRepresentation = privateKey.dataRepresentation  // opaque blob, safe to store
let restored = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: dataRepresentation)
// This blob contains a reference to the SE key, not the key itself

Biometric gating with CryptoKit:

let access = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    .biometryCurrentSet,
    nil
)!

let privateKey = try SecureEnclave.P256.Signing.PrivateKey(
    accessControl: access
)
// Using this key prompts biometrics automatically

Low-Level SecKey API#

For apps targeting iOS 12 or using custom key attributes:

let access = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    .privateKeyUsage,
    nil
)!

let attributes: [String: Any] = [
    kSecAttrKeyType as String: kSecAttrKeyTypeEC,
    kSecAttrKeySizeInBits as String: 256,
    kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
    kSecPrivateKeyAttrs as String: [
        kSecAttrIsPermanent as String: true,
        kSecAttrApplicationTag as String: "com.myapp.key".data(using: .utf8)!,
        kSecAttrAccessControl as String: access,
    ]
]
var error: Unmanaged<CFError>?
let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
// Retrieve later with SecItemCopyMatching using the tag

3. Android Keystore: Deep Dive#

Hardware Backing Levels#

Android Keystore hardware backing is stratified:

┌─────────────────────────────────────┐
│ Application processor (untrusted)   │
│    ┌────────────────────────────┐   │
│    │ TEE (Trusted Execution Env) │  │
│    │  - Keys stored here (TEE)   │  │
│    └────────────────────────────┘   │
│    ┌────────────────────────────┐   │
│    │ StrongBox (dedicated chip)  │  │
│    │  - Separate CPU, OS, memory │  │
│    └────────────────────────────┘   │
└─────────────────────────────────────┘
  • Software (API < 23): Key bytes in app process memory. No real protection.
  • TEE: Most modern Android devices. Isolated from main OS. Hardware-backed per Android CDD requirement since API 23.
  • StrongBox (API 28+): Dedicated security chip (e.g., Google Titan M on Pixel, Samsung’s embedded security chip). Highest assurance.

Check hardware backing:

val factory = KeyFactory.getInstance(key.algorithm, "AndroidKeyStore")
val keyInfo = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
// API 23-30:
val isHardwareBacked: Boolean = keyInfo.isInsideSecureHardware
// API 31+:
val level: Int = keyInfo.securityLevel  // SecurityLevel.TRUSTED_ENVIRONMENT, STRONGBOX, or SOFTWARE

Key Generation Patterns#

AES key for encryption (EncryptedSharedPreferences uses this):

val keyGenerator = KeyGenerator.getInstance(
    KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
keyGenerator.init(
    KeyGenParameterSpec.Builder(
        "my_aes_key",
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    )
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setKeySize(256)
    // Require unlock to use (not biometric — transparent to user)
    .setUnlockedDeviceRequired(true)         // API 28+
    .build()
)

EC key for signing with biometric gating:

val keyPairGenerator = KeyPairGenerator.getInstance(
    KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore"
)
keyPairGenerator.initialize(
    KeyGenParameterSpec.Builder(
        "my_ec_key",
        KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
    )
    .setDigests(KeyProperties.DIGEST_SHA256)
    .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
    .setUserAuthenticationRequired(true)
    .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
    .setInvalidatedByBiometricEnrollment(true)  // Invalidate on new enrollment
    .setIsStrongBoxBacked(true)  // Request StrongBox (falls back if unavailable)
    .build()
)
val keyPair = keyPairGenerator.generateKeyPair()

Biometric + CryptoObject Pattern#

The secure way to gate a key behind biometrics:

// 1. Get the Keystore-backed key
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val key = keyStore.getKey("my_ec_key", null) as PrivateKey

// 2. Initialize a cipher with it (before authentication)
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(key)

// 3. Show biometric prompt with the cipher bound to it
val cryptoObject = BiometricPrompt.CryptoObject(signature)
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
    override fun onAuthenticationSucceeded(result: AuthenticationResult) {
        // The signature is now unlocked
        val sig = result.cryptoObject?.signature!!
        sig.update(dataToSign)
        val signatureBytes = sig.sign()
    }
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
    .setTitle("Sign in")
    .setNegativeButtonText("Cancel")
    .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
    .build()
prompt.authenticate(promptInfo, cryptoObject)

Why CryptoObject matters: Without CryptoObject, biometric authentication just returns a boolean — the app could bypass it by catching the callback and lying. With CryptoObject, the Keystore cryptographic operation is only authorized after genuine biometric authentication at the hardware level.

EncryptedSharedPreferences Under the Hood#

App code
  └── EncryptedSharedPreferences
        ├── Master key in Android Keystore (AES-256-GCM)
        ├── Encrypts keys with AES-256-SIV (deterministic, for key lookup)
        └── Encrypts values with AES-256-GCM (probabilistic)
        → Stores encrypted blobs in regular SharedPreferences XML

What you get: The XML file on disk is ciphertext. Even if an attacker reads the file (e.g., root access or backup), they can’t decrypt without the Keystore master key. The master key is hardware-protected.

Limitation: Keys and values are limited by Android SharedPreferences constraints (~4KB typical). The master key is NOT biometric-gated (always accessible when unlocked) — this is the trade-off for ease of use.

Deprecation notice: androidx.security:security-crypto (which provides EncryptedSharedPreferences) was deprecated in April 2025 at version 1.1.0-alpha07. No further versions will be published. The replacement for new projects is DataStore + Google Tink library. Existing code continues to work. flutter_secure_storage v9+ already completed this migration, switching from JetSec to Tink internally.

Key Attestation#

Key attestation allows a server to verify that a key was generated in hardware:

// Generate key with attestation challenge
keyPairGenerator.initialize(
    KeyGenParameterSpec.Builder("my_key", PURPOSE_SIGN)
        .setAttestationChallenge(challengeFromServer)  // server-provided nonce
        .build()
)
// Get certificate chain
val chain = keyStore.getCertificateChain("my_key")
// Send chain[0] (leaf) through chain[last] (root) to server
// Server verifies:
// 1. Root certificate matches Google's attestation root
// 2. Attestation extension in leaf cert shows key details (hardware-backed, key purpose, etc.)
// 3. Challenge matches (prevents replay)

This is used in high-security apps (banking, government) to verify that the authenticating device’s key is genuinely hardware-backed.


4. react-native-keychain: Complete API#

v10.0.0 (latest; v10 removed FacebookConceal, v9 removed pre-API-23 SharedPreferences fallback):

import * as Keychain from 'react-native-keychain';

// --- Generic Password (any service name) ---
await Keychain.setGenericPassword('username', 'password', {
  service: 'myservice',
  accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY,
  authenticationType: Keychain.AUTHENTICATION_TYPE.BIOMETRICS,
  securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE,  // Android only: require hardware
});

const creds = await Keychain.getGenericPassword({
  service: 'myservice',
  authenticationPrompt: {
    title: 'Authenticate',
    description: 'Access your credentials',
  },
});
// creds.username, creds.password

// --- Internet credentials (URL-keyed, iOS Keychain kSecClassInternetPassword) ---
await Keychain.setInternetCredentials('api.example.com', 'token', 'secret');
const internetCreds = await Keychain.getInternetCredentials('api.example.com');

// --- RSA key pair for signing (native key in Keystore/Keychain) ---
const keypair = await Keychain.createKeys({ accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED });
// keypair.publicKey — PEM format
// keypair.privateKey — not returned; key reference only
// iOS: RSA 2048-bit in Keychain (NOT Secure Enclave — SE only supports EC P-256)
// Android: RSA 2048-bit in Android Keystore (hardware-backed)

// Check biometric support
const biometryType = await Keychain.getSupportedBiometryType();
// Returns: 'TouchID' | 'FaceID' | 'Fingerprint' | null

// Check if key exists (returns null or credentials object)
const exists = await Keychain.hasGenericPassword({ service: 'myservice' });

// Delete
await Keychain.resetGenericPassword({ service: 'myservice' });

ACCESSIBLE constants:

  • WHEN_UNLOCKED — device unlocked (default)
  • WHEN_UNLOCKED_THIS_DEVICE_ONLY — unlocked, not backed up
  • AFTER_FIRST_UNLOCK — background access OK
  • WHEN_PASSCODE_SET_THIS_DEVICE_ONLY — requires passcode set

ACCESS_CONTROL constants:

  • BIOMETRY_ANY — any enrolled biometric
  • BIOMETRY_CURRENT_SET — current enrollment set (new fingerprint invalidates)
  • DEVICE_PASSCODE — device PIN
  • APPLICATION_PASSWORD — custom app password (shows prompt)

iOS vs Android behavior:

  • iOS: Uses SecItemAdd with kSecAttrAccessControl
  • Android: Uses Keystore-backed AES + EncryptedSharedPreferences for passwords; Keystore key pair for createKeys()

5. Threat Model: Rooted / Jailbroken Devices#

iOS (Jailbroken)#

What protection remains:

  • Items with ThisDeviceOnly + biometric gating: The biometric check can be bypassed via Frida hooks, but the underlying Keychain encryption is still active.
  • Secure Enclave keys: Cannot be extracted even on jailbroken devices. The private key bytes are never accessible to any software. A jailbroken device can trigger SE operations but cannot read the key.
  • Items without biometric gating: On a jailbroken device with physical access, Keychain items can be dumped (e.g., Keychain-Dumper tool). This is the most significant risk.

Mitigation: Use biometric gating for sensitive items. SE-backed keys are inherently resistant.

Android (Rooted)#

What protection remains:

  • Keystore keys: On most devices, hardware-backed keys cannot be exported even with root. The TEE’s private memory is not accessible to the Android OS or root.
  • EncryptedSharedPreferences: The encrypted file can be read by root, but decryption requires the Keystore master key. If the key is hardware-backed, extraction is not possible.
  • Soft-backed keys (older devices): Root can extract these.

StrongBox: Highest assurance — physically separate chip, resistant to kernel-level compromise.

Key attestation: Detects whether device is running verified boot (useful for server-side checks).

Summary#

ScenarioiOS KeychainiOS Secure Enclave KeysAndroid TEE KeysAndroid SW Keys
Normal deviceProtectedProtectedProtectedProtected
Jailbroken (no biometric gate)ExposedProtectedProtectedProtected
Jailbroken (biometric gate)Bypass possible with FridaProtectedProtectedProtected
Rooted AndroidEncrypted, key in TEEN/AProtectedExposed
Root + physical SE attackN/AProtected (hardware)Requires hardware attackExposed

6. expo-secure-store: Limitations#

import * as SecureStore from 'expo-secure-store';

// Basic CRUD
await SecureStore.setItemAsync('key', 'value');
const value = await SecureStore.getItemAsync('key');
await SecureStore.deleteItemAsync('key');

// Options
await SecureStore.setItemAsync('key', 'value', {
  keychainService: 'myapp',  // iOS service name grouping
  keychainAccessible: SecureStore.WHEN_UNLOCKED,
  // No ACCESS_CONTROL — no biometric gating
});

// Check availability
const available = await SecureStore.isAvailableAsync();

Hard limit: 2KB per value. This is a known limitation. For larger values, use react-native-keychain or encrypt+compress before storing.

No biometric gating: expo-secure-store does not expose SecAccessControl / biometric prompts. For biometric-gated storage, use react-native-keychain.

On Web (Expo Web): Uses localStorage — NOT secure. Must check SecureStore.isAvailableAsync() before trusting it.


Sources#


S2 Recommendation: Mobile Secure Storage / Keychain Libraries#

Decision Table#

NeediOS NativeAndroid NativeReact NativeFlutter
Store API token (simple)kSecClassGenericPasswordEncryptedSharedPreferencesexpo-secure-storeflutter_secure_storage
Store API token (biometric gate)kSecClassGenericPassword + SecAccessControlKeystore AES + BiometricPromptreact-native-keychainflutter_secure_storage
Sign data on-deviceSecure Enclave via CryptoKitAndroid Keystore EC keyreact-native-keychain createKeys()N/A (use native module)
Verify hardware backingAlways SEkeyInfo.isInsideSecureHardwareSECURITY_LEVEL.SECURE_HARDWAREPlatform-specific
Encrypt local databaseN/A (SQLCipher/CryptoKit)EncryptedSharedPreferences / RoomN/A (use SQLCipher)N/A

Definitive Rankings#

React Native secure storage:

  1. react-native-keychain — full-featured, biometric gating, no value size limit
  2. expo-secure-store — simple, but 2KB limit and no biometric gating

Flutter:

  1. flutter_secure_storage — only major option, well-maintained

iOS native: Secure Enclave for keys, Keychain Services for credentials Android native: Android Keystore for keys, EncryptedSharedPreferences for credentials


S2 Synthesis: Mobile Secure Storage / Keychain Libraries#

What S2 Confirmed and Added#

1. kSecAttrSynchronizable Defaults to False — But Be Explicit#

Items are not synced by default, but it’s best practice to explicitly set kSecAttrSynchronizable: kCFBooleanFalse for session tokens to prevent accidental sync if defaults change.

2. The CryptoObject Pattern Is Essential for Real Biometric Security#

On Android, simply checking biometricPrompt.authenticate() success without a CryptoObject does not cryptographically bind the biometric to the key operation. An attacker with root could hook the callback. The CryptoObject pattern makes the cryptographic operation contingent on genuine hardware-verified biometric success.

3. .biometryCurrentSet vs .biometryAny Is a Real Design Decision#

Using .biometryCurrentSet on iOS (or setInvalidatedByBiometricEnrollment(true) on Android) means adding a new fingerprint/face invalidates stored secrets. This is a security feature (prevents a phone thief from enrolling their face), but it’s also a UX problem (user must re-authenticate with their original method to re-store). Most apps use .biometryAny for better UX.

4. expo-secure-store’s 2KB Limit Is Frequently Hit#

JWT tokens (with claims) + refresh tokens can exceed 2KB. This surprises Expo developers. Solution: store only a session ID (short string) or switch to react-native-keychain for larger values.

5. Hardware Attestation Is Underutilized#

Android Key Attestation provides cryptographic proof of hardware backing to a server. This is underused in most apps but is the right solution for high-assurance scenarios (banking, healthcare credentials).

6. On iOS, SE Keys Are Most Resistant Even When Jailbroken#

Keychain items (without biometric gate) are the weakest link — they can be dumped from jailbroken devices. SE-backed private keys cannot be extracted by any software. This is a meaningful distinction for app design.

Decisions Made#

  • SECURITY_LEVEL.SECURE_HARDWARE on Android (react-native-keychain): Throw an error if hardware backing unavailable rather than silently degrade to software. Apps that need security should require hardware.
  • Platform capability detection before use: getSupportedBiometryType() and isAvailableAsync() before assuming biometrics are available.
S3: Need-Driven

S3 Approach: Mobile Secure Storage / Keychain Libraries#

Research ID: 1.069 Pass: S3 — Need-Driven Discovery Date: 2026-02-17

Persona and Use Case Scope#

Target personas:

  1. React Native developer building a consumer app (auth tokens, passwords)
  2. iOS developer building a high-security app (banking, healthcare)
  3. Android developer needing hardware key attestation
  4. Cross-platform developer choosing between expo-secure-store and react-native-keychain
  5. Security engineer auditing mobile app credential storage

Use Cases to Cover#

  1. Store OAuth refresh token securely
  2. Biometric-gated access to credentials
  3. On-device document signing (Secure Enclave / Android Keystore EC key)
  4. Detect and require hardware backing
  5. Share credentials between related apps (iOS access groups)
  6. Protect against jailbreak/root (detection + mitigation)
  7. Cross-platform with Expo managed workflow

S3 Library Comparison: Mobile Secure Storage / Keychain Libraries#

Decision Matrix#

Factorreact-native-keychainexpo-secure-storeflutter_secure_storage
Biometric gating✅ Yes❌ No❌ No
Value size limitNone2KBNone
iOS backendKeychain ServicesKeychain ServicesKeychain Services
Android backendKeystore + EncryptedSharedPrefsEncryptedSharedPrefsEncryptedSharedPrefs
Requires native moduleYes (bare RN or prebuild)Yes (Expo SDK)Yes
Key pair creation✅ Yes❌ No❌ No
GitHub stars (2026)~3.2KPart of Expo (~26K)~1.2K
npm/pub downloads~500K/weekN/A (Expo SDK)~400K/month
Active maintenance✅ Yes✅ Yes (Expo team)✅ Yes
LicenseMITMITBSD-2-Clause

Decision Tree#

Need mobile credential storage
├── Using Flutter?
│   └── → flutter_secure_storage (only real option)
│
└── Using React Native
    ├── Using Expo managed workflow?
    │   ├── Simple token (< 2KB), no biometrics needed?
    │   │   └── → expo-secure-store (built-in, simple)
    │   └── Need biometrics, or > 2KB, or key pair?
    │       └── → react-native-keychain (with expo plugin / prebuild)
    └── Bare React Native
        └── → react-native-keychain (de facto standard)

Platform Native vs Cross-Platform#

When to use platform-native APIs (not cross-platform libraries):

ScenarioUse nativeWhy
iOS: on-device signing with hardware keyYes (CryptoKit + SE)Cross-platform libs don’t expose full Secure Enclave key operations
Android: Key AttestationYes (Keystore API)Only available in native code
iOS: app-to-app credential sharing (access groups)Yes (SecItem with kSecAttrAccessGroup)Cross-platform libs don’t expose access groups
High-assurance StrongBox requirementYes (Android Keystore directly)More control over key spec
Everything elseNo — use cross-platformSaves significant complexity

The “Not Secure” Anti-Pattern#

What developers commonly use that is NOT secure:

// ❌ Plaintext — visible to any app, backed up, no protection
AsyncStorage.setItem('token', myToken)

// ❌ MMKV — fast but not hardware-backed, not encrypted at rest (unless you add it)
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
storage.set('token', myToken)  // NOT secure for credentials

// ❌ Redux Persist to AsyncStorage — same problem
// ❌ .env files for runtime secrets — these ship in the app bundle

// ✅ Correct
import * as Keychain from 'react-native-keychain';
await Keychain.setGenericPassword('username', myToken, { service: 'myapp' })

Tool Personas#

  • expo-secure-store: The beginner-friendly path. 5-line integration, always works in Expo managed workflow. Covers the 90% case. Hits a wall at 2KB and biometrics.

  • react-native-keychain: The professional path. More API surface, handles all cases, works in bare or managed workflow (with expo plugin). ~500K downloads/week shows it’s the industry standard for RN.

  • flutter_secure_storage: The Flutter default. No real competition in the Flutter space. Upgraded to Jetpack Security on Android (v5.x, 2022), removing the old RSA-wrapped-AES approach.

  • iOS Keychain + CryptoKit: The native path. Required for Secure Enclave operations (signing, key agreement), access groups, or any capability cross-platform libs don’t expose. CryptoKit makes SE usage approachable.

  • Android Keystore directly: Required for Key Attestation, StrongBox enforcement, or custom key lifecycle management. Jetpack Security’s EncryptedSharedPreferences wraps this for simpler use cases.

Layering Pattern#

Common architecture for React Native apps:

┌─────────────────────────────────────────────┐
│ App code (auth service, credential manager) │
├─────────────────────────────────────────────┤
│ react-native-keychain                        │
│ (biometric gate, platform API unification)  │
├─────────────────────────────────────────────┤
│ iOS Keychain        │ Android Keystore       │
│ (kSecClassGeneric)  │ (EncryptedSharedPrefs) │
└─────────────────────────────────────────────┘

For apps that also need on-device cryptography (signing, encryption):

┌────────────────────────────────────────────────┐
│ App code (signing service)                      │
├────────────────────────────────────────────────┤
│ Native module bridge (custom or react-native-  │
│ keychain createKeys)                            │
├────────────────────────────────────────────────┤
│ iOS Secure Enclave  │ Android Keystore EC key  │
│ (CryptoKit / SecKey)│ (KeyPairGenerator)       │
└────────────────────────────────────────────────┘

S3 Recommendation: Mobile Secure Storage / Keychain Libraries#

By Use Case#

ScenarioRecommendation
RN: store auth token, no biometricsexpo-secure-store (if < 2KB) or react-native-keychain
RN: biometric-gated credentialsreact-native-keychain with ACCESS_CONTROL.BIOMETRY_ANY
RN: on-device key pairreact-native-keychain.createKeys()
Flutter: any credential storageflutter_secure_storage
iOS: hardware-bound signing keyCryptoKit + Secure Enclave
iOS: share credentials between appsNative SecItem API with kSecAttrAccessGroup
Android: hardware key attestationAndroid Keystore directly
Android: simple encrypted preferencesJetpack EncryptedSharedPreferences

Anti-Patterns to Avoid#

  • AsyncStorage for any secret
  • MMKV for credentials (no hardware backing)
  • expo-secure-store for values > 2KB
  • expo-local-authentication alone (authentication only, does not gate key access)

Key Insight#

The platform APIs (Keychain, Keystore) do the actual work. Cross-platform libraries are thin wrappers that add unified JS/Dart APIs and biometric prompt handling. Choose based on: biometric gate needed? value size? Expo managed workflow? key pair operations?


S3 Use Cases: Mobile Secure Storage / Keychain Libraries#


Use Case 1: Store OAuth Refresh Token (React Native)#

Scenario: After login, store a refresh token that persists across app restarts.

expo-secure-store (simple path)#

import * as SecureStore from 'expo-secure-store';

// Store after login
export async function saveRefreshToken(token: string) {
  await SecureStore.setItemAsync('refresh_token', token, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}

// Retrieve on app start
export async function getRefreshToken(): Promise<string | null> {
  return SecureStore.getItemAsync('refresh_token');
}

// Clear on logout
export async function clearRefreshToken() {
  await SecureStore.deleteItemAsync('refresh_token');
}

Suitable when: Token fits in 2KB, no biometric gate needed, Expo managed workflow.

import * as Keychain from 'react-native-keychain';

export async function saveTokens(accessToken: string, refreshToken: string) {
  // Store both as username/password pair
  await Keychain.setGenericPassword(accessToken, refreshToken, {
    service: 'com.myapp.auth',
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}

export async function getTokens() {
  const result = await Keychain.getGenericPassword({ service: 'com.myapp.auth' });
  if (!result) return null;
  return { accessToken: result.username, refreshToken: result.password };
}

export async function clearTokens() {
  await Keychain.resetGenericPassword({ service: 'com.myapp.auth' });
}

Suitable when: Tokens may exceed 2KB, or need biometric gate, or bare React Native (no Expo).


Use Case 2: Biometric-Gated API Key (React Native)#

Scenario: Sensitive API key should only be retrieved after biometric authentication.

import * as Keychain from 'react-native-keychain';

// Store with biometric gate
export async function storeApiKey(key: string) {
  const biometryType = await Keychain.getSupportedBiometryType();
  if (!biometryType) {
    throw new Error('Biometrics not available — cannot store securely');
  }

  await Keychain.setGenericPassword('apikey', key, {
    service: 'com.myapp.apikey',
    accessible: Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
    accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_ANY,
    authenticationType: Keychain.AUTHENTICATION_TYPE.BIOMETRICS,
  });
}

// Retrieve — triggers biometric prompt
export async function retrieveApiKey(): Promise<string> {
  const creds = await Keychain.getGenericPassword({
    service: 'com.myapp.apikey',
    authenticationPrompt: {
      title: 'Authenticate',
      subtitle: 'Use biometrics to access your API key',
      description: 'Confirm your identity to continue',
      cancel: 'Cancel',
    },
  });
  if (!creds) throw new Error('No key stored');
  return creds.password;
}

iOS behavior: Face ID / Touch ID prompt shown by Keychain service automatically. Android behavior: Biometric prompt shown by react-native-keychain.


Use Case 3: On-Device Document Signing (iOS Native — Secure Enclave)#

Scenario: Banking app needs to sign transaction requests with a hardware-bound key. The key must never leave the device.

import CryptoKit
import Foundation

class DeviceSigner {
    private let keyTag = "com.bankapp.signing.v1"

    // Generate key on first use
    func getOrCreateSigningKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
        // Try to load existing key
        if let existing = try? loadKey() { return existing }

        // Create new with biometric gate
        let access = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            [.privateKeyUsage, .biometryCurrentSet],  // Invalidate on new biometric enrollment
            nil
        )!

        let key = try SecureEnclave.P256.Signing.PrivateKey(accessControl: access)
        // Store the data representation (opaque reference, not the private key)
        try storeKeyData(key.dataRepresentation)
        return key
    }

    func sign(payload: Data) throws -> Data {
        let key = try getOrCreateSigningKey()
        // This triggers Face ID / Touch ID prompt
        let signature = try key.signature(for: payload)
        return signature.derRepresentation
    }

    // The public key for server-side verification registration
    var publicKeyPEM: String {
        get throws {
            let key = try getOrCreateSigningKey()
            return key.publicKey.x963Representation.base64EncodedString()
        }
    }

    private func storeKeyData(_ data: Data) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassKey,
            kSecAttrApplicationTag as String: keyTag.data(using: .utf8)!,
            kSecValueData as String: data,
        ]
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeyError.storageFailed }
    }

    private func loadKey() throws -> SecureEnclave.P256.Signing.PrivateKey? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassKey,
            kSecAttrApplicationTag as String: keyTag.data(using: .utf8)!,
            kSecReturnData as String: true,
        ]
        var result: AnyObject?
        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
        let data = result as! Data
        return try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: data)
    }
}

enum KeyError: Error { case storageFailed }

Security properties:

  • Private key never leaves Secure Enclave
  • .biometryCurrentSet ensures key is invalidated if attacker enrolls their face on a stolen device
  • kSecAttrAccessibleWhenUnlockedThisDeviceOnly prevents backup/migration

Use Case 4: Require Hardware Backing (Android)#

Scenario: High-security Android app refuses to operate if keys are not hardware-backed.

import android.security.keystore.KeyProperties.*
import android.security.keystore.KeyInfo

fun generateHardwareBackedKey(alias: String): Boolean {
    val keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM_EC, "AndroidKeyStore")
    keyPairGenerator.initialize(
        KeyGenParameterSpec.Builder(alias, PURPOSE_SIGN or PURPOSE_VERIFY)
            .setDigests(DIGEST_SHA256)
            .setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
            .setUserAuthenticationRequired(false)  // Can be changed to true for biometric gate
            .build()
    )
    val keyPair = keyPairGenerator.generateKeyPair()

    // Verify hardware backing
    val keyStore = java.security.KeyStore.getInstance("AndroidKeyStore")
    keyStore.load(null)
    val key = keyStore.getKey(alias, null) ?: return false
    val factory = java.security.KeyFactory.getInstance(key.algorithm, "AndroidKeyStore")
    val keyInfo = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo

    if (!keyInfo.isInsideSecureHardware) {
        // Reject — key is software-backed (old device or emulator)
        keyStore.deleteEntry(alias)
        return false
    }
    return true
}

// In app startup:
if (!generateHardwareBackedKey("my_app_key")) {
    showDialog("Your device does not support secure hardware storage. This app requires it.")
    finish()
}

Use Case 5: iOS App Group Credential Sharing#

Scenario: Main app and a Share Extension need access to the same authentication token.

// In both the main app and the Share Extension:
let sharedCredential: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.myapp.shared",
    kSecAttrAccount as String: "auth_token",
    kSecAttrAccessGroup as String: "TEAMID12345.com.myapp.shared",  // Must match entitlements
    kSecValueData as String: tokenData,
    kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
]
SecItemAdd(sharedCredential as CFDictionary, nil)

// Retrieve (same from both apps):
let readQuery: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.myapp.shared",
    kSecAttrAccount as String: "auth_token",
    kSecAttrAccessGroup as String: "TEAMID12345.com.myapp.shared",
    kSecReturnData as String: true,
]
var result: AnyObject?
SecItemCopyMatching(readQuery as CFDictionary, &result)

Requirements:

  • Same Apple Developer Team ID
  • Keychain Sharing entitlement (com.apple.security.keychain-access-groups) in both targets’ entitlements
  • Same access group string in both

Use Case 6: Cross-Platform Expo App#

Scenario: Expo managed workflow app needs to store an auth token. No bare React Native.

// auth-storage.ts
import * as SecureStore from 'expo-secure-store';

const TOKEN_KEY = 'auth_token';

export async function storeToken(token: string): Promise<void> {
  // Check availability (returns false on Expo Go simulator without native module)
  const available = await SecureStore.isAvailableAsync();
  if (!available) {
    // Fallback for development only — never in production
    console.warn('SecureStore unavailable — likely Expo Go web');
    return;
  }

  if (token.length > 2000) {
    // Approach: store only the token ID, fetch full token from server
    throw new Error('Token exceeds secure store limit — store token reference instead');
  }

  await SecureStore.setItemAsync(TOKEN_KEY, token, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  });
}

export async function getToken(): Promise<string | null> {
  return SecureStore.getItemAsync(TOKEN_KEY);
}

export async function clearToken(): Promise<void> {
  await SecureStore.deleteItemAsync(TOKEN_KEY);
}

Use Case 7: Detect Compromised Device (Jailbreak/Root)#

Scenario: App wants to warn users or refuse to operate on compromised devices.

React Native#

// react-native-device-info provides basic checks
import DeviceInfo from 'react-native-device-info';

const isCompromised = await DeviceInfo.isEmulator() ||
                      (Platform.OS === 'android' ? await DeviceInfo.hasSystemFeature('android.hardware.faketouch') : false);

// More thorough: react-native-jail-monkey or freerasp
import JailMonkey from 'jail-monkey';
if (JailMonkey.isJailBroken() || JailMonkey.hookDetected()) {
  Alert.alert('Security Warning', 'This device may be compromised.');
}

iOS Native: canary approach#

// Store a test item with ThisDeviceOnly — jailbreak tools bypass this check incorrectly
// Better: check for presence of common jailbreak files or hooks
func isJailbroken() -> Bool {
    #if targetEnvironment(simulator)
    return false
    #else
    let paths = ["/Applications/Cydia.app", "/private/var/lib/apt", "/usr/sbin/sshd"]
    return paths.contains { FileManager.default.fileExists(atPath: $0) }
    #endif
}

Note: Jailbreak detection is an arms race — determined attackers bypass it. The real protection is the Secure Enclave (SE keys cannot be extracted even on jailbroken devices). Use detection as a UX warning, not a security guarantee.

S4: Strategic

S4 Strategic Analysis: Mobile Secure Storage / Keychain Libraries#


1. Platform API Stability (iOS Keychain / Android Keystore)#

Confidence: 10/10 — These are permanent OS APIs.

iOS Keychain has been part of iOS since iPhone OS 2.0 (2008). The SecItem API has been stable for over a decade. Apple has added to it (CryptoKit in iOS 13, Secure Enclave access via CryptoKit) but never removed functionality.

Android Keystore became mandatory in Android 6.0 (2015) and has expanded with each API level. Google has committed to it as the security foundation for Android’s Enterprise and Payment APIs.

Verdict: No lock-in concern. These are platform fundamentals, not third-party libraries.


2. cross-platform Library Viability#

react-native-keychain#

Maintenance: Active. oblador (Oliver Lazorenko) has maintained it since 2015. v9.0.0 released 2024. 3.2K stars. Pull request activity healthy as of 2026.

Risk: Single primary maintainer. If maintenance drops, the React Native community has historically forked or maintained through community PRs. The surface area is small (thin wrapper around platform APIs) — it’s not difficult to maintain.

Estimate: HIGH confidence for 5-year horizon. Widely adopted (~500K npm downloads/week). If abandoned, migration to expo-secure-store or a fork is straightforward.

expo-secure-store#

Maintenance: Maintained by the Expo team (Expo, acquired by major React Native contributors). Part of Expo SDK release cycle. Expo’s business model depends on these libraries being maintained.

Risk: Tied to Expo SDK release cycle. Breaking changes with SDK major versions. If Expo pivots, these libraries could be deprecated in favor of differently structured alternatives.

Estimate: HIGH confidence for 5-year horizon. Expo is commercially backed.

flutter_secure_storage#

Maintenance: Community maintained. Significant update in 2022 (v5.x, switching to Jetpack Security on Android). Currently at v9.x (2025). Multiple contributors.

Risk: The Flutter ecosystem is backed by Google but community libraries can go dormant. flutter_secure_storage is the standard — it would be replaced by another maintained library, not by a void.

Estimate: HIGH confidence for 5-year horizon. Only serious option in the Flutter space ensures continued maintenance.


3. Passkeys: The Emerging Game-Changer#

Passkeys (FIDO2 / WebAuthn) are now supported natively on iOS (16+) and Android (9+). They use the device’s Secure Enclave / TEE to store a private key bound to the hardware, with biometric authentication.

What This Means for Secure Storage#

Passkeys solve authentication (replacing passwords) but NOT secret storage (storing tokens, keys). They are complementary:

Passkeys (FIDO2):
├── Use case: Login (authentication)
├── Backend: Secure Enclave / TEE (same hardware)
├── Protocol: WebAuthn challenge-response
└── Does NOT replace: token storage, encryption keys, API credentials

iOS Keychain / Android Keystore + react-native-keychain:
├── Use case: Storing credentials, tokens, encryption keys
├── Backend: Same Secure Enclave / TEE
└── Still needed even when passkeys handle authentication

React Native Passkey Support#

react-native-passkey (v3.x, 2024) is the leading library:

import { Passkey } from 'react-native-passkey';

// Registration
const result = await Passkey.create({
  challenge: serverChallenge,
  rp: { id: 'example.com', name: 'My App' },
  user: { id: userId, name: '[email protected]', displayName: 'User' }
});

// Authentication
const assertion = await Passkey.get({
  challenge: serverChallenge,
  rpId: 'example.com'
});

Strategic implication: Passkeys complement secure storage, not replace it. Apps will use both: passkeys for login, Keychain/Keystore for session token storage after authentication.


4. Hardware Security: Trend Analysis#

Both platforms are increasing hardware security guarantees:

  • iOS: Secure Enclave in every iPhone since 2013. Now also on iPad, Apple Watch, Mac (T2, M-series). Hardware-backed passkeys in iOS 16+.
  • Android: StrongBox (dedicated chip) now standard on Pixel 3+ (2018) and most flagship Android phones post-2019. Android 14 requires hardware-backed key storage on new devices.

Trend: Hardware key storage is getting more available and mandatory on both platforms. Libraries that abstract it (react-native-keychain, expo-secure-store, flutter_secure_storage) will become more valuable as hardware capabilities grow.


5. Lock-In Analysis#

LayerLock-In Risk
iOS Keychain / Android KeystoreNone — platform APIs are permanent
react-native-keychainVery low — thin wrapper, easy to replace or migrate
expo-secure-storeVery low — same as above; Expo is commercially backed
flutter_secure_storageVery low — thin wrapper
Cross-platform library APILow — the API is simple (get/set/delete); migration is straightforward

The abstraction layer adds minimal risk. Because these libraries are thin wrappers, switching between them (if needed) requires changing only the storage calls, not the credential management logic.


6. What’s NOT Covered (and Why)#

Room / SQLCipher for encrypted databases: Separate domain (encrypted databases, not credential storage). Not included — different use case.

Keychains for non-mobile (macOS, Windows): Out of scope. Different platform APIs.

HSMs (Hardware Security Modules) for backend: Different domain. Mobile SE/TEE is the frontend equivalent.


S4 Approach: Mobile Secure Storage / Keychain Libraries#

Research ID: 1.069 Pass: S4 — Strategic Analysis Date: 2026-02-17

Strategic Evaluation Scope#

  1. Platform API stability: iOS Keychain and Android Keystore longevity (both are OS-native, essentially permanent)
  2. Cross-platform library maintenance: react-native-keychain, expo-secure-store, flutter_secure_storage health
  3. Emerging changes: Passkeys (FIDO2), on-device ML credential use cases
  4. Lock-in: Risk of using a library vs native API directly
  5. Long-term viability of each choice over 5+ year horizon

S4 Recommendation: Mobile Secure Storage / Keychain Libraries#

Final Selections#

ContextPrimary ChoiceConfidence
React Native (any)react-native-keychain⭐ Best Path
React Native (Expo, simple)expo-secure-store⭐ Best Path
Flutterflutter_secure_storage⭐ Best Path
iOS native key opsCryptoKit + Secure Enclave⭐ Best Path
Android native key opsAndroid Keystore API⭐ Best Path
Authentication (not storage)Passkeys / react-native-passkey✅ Emerging Standard

Key Takeaways#

  1. Platform APIs are permanent — iOS Keychain and Android Keystore are OS fundamentals with no viability risk.

  2. Cross-platform wrappers are thin — react-native-keychain and flutter_secure_storage do 95% of what you need with < 10 lines of code. Their lock-in risk is minimal.

  3. Biometric gating requires react-native-keychain — expo-secure-store does not support it. This is the primary decision factor in React Native.

  4. For key operations, go native — Secure Enclave signing and Android Keystore attestation require platform APIs. Cross-platform libs expose the common case only.

  5. Passkeys complement, not replace — Passkeys replace passwords for authentication. Secure storage is still needed for tokens, session credentials, and encryption keys.


S4 Viability: Mobile Secure Storage / Keychain Libraries#

Long-Term Viability Table#

Technology3-Year Confidence10-Year ConfidenceRisk Factors
iOS Keychain Services🟢 Very High🟢 Very HighApple removes deprecated APIs cautiously; no risk
iOS Secure Enclave (CryptoKit)🟢 Very High🟢 Very HighApple’s security foundation; expanding not contracting
Android Keystore🟢 Very High🟢 Very HighGoogle’s security requirement; mandatory since API 23
EncryptedSharedPreferences🟢 High🟢 HighJetpack library; Google-backed; v1.1 stable
react-native-keychain🟢 High🟡 MediumSingle maintainer risk; but simple and replaceable
expo-secure-store🟢 High🟢 HighExpo-backed; commercial incentive to maintain
flutter_secure_storage🟢 High🟡 MediumCommunity; no single corporate backer
Passkeys (complement)🟢 High🟢 Very HighApple + Google joint commitment; platform standard

Slots#

Credential Storage — React Native#

Slot: react-native-keychain

  • Biometric gating, no size limit, key pair creation
  • Industry standard for bare and managed RN alike
  • If abandoned: switch to expo-secure-store (for simple cases) or fork

Credential Storage — Expo Managed Only#

Slot: expo-secure-store

  • Built-in, zero config, Expo-backed
  • For values < 2KB and no biometric gate requirement

Credential Storage — Flutter#

Slot: flutter_secure_storage

  • No real alternative; community maintained but de facto standard

Platform Key Operations (all languages)#

Slot: Native APIs (CryptoKit / Android Keystore Java API)

  • Cross-platform libraries don’t expose the full capability
  • For signing, key agreement, hardware attestation: go native

Monitoring Signals#

Watch for:

  • oblador/react-native-keychain going > 6 months without PR activity
  • Expo deprecating or replacing expo-secure-store
  • Android changing Keystore API surface (unlikely — stable since API 23)
  • Apple removing SecItem* API (would have multi-year deprecation notice)
Published: 2026-03-06 Updated: 2026-03-06