AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Mobile Secure Storage Crash Debugging

NFNourin Mahfuj Finick··8 min read

Mobile app secure storage is the last line of defense for user credentials, API tokens, payment keys, and cryptographic material. When iOS Keychain operations or Android Keystore calls fail, the damage is rarely a clean crash with a clear stack trace—it's usually a silent authentication failure that locks users out of your app without explanation. These secure storage crashes are uniquely dangerous because they erode trust at the worst possible moment: when a user is trying to log in, make a purchase, or access sensitive data. This guide covers the most common Keychain and Keystore crash patterns, their root causes, and the debugging techniques you need to ship storage that doesn't break.

Why Secure Storage Crashes Are So Hard to Catch

Traditional crash reporters like Firebase Crashlytics catch exceptions and signals—but many Keychain and Keystore failures never throw. Instead, they return error codes that wrapper libraries swallow or convert into generic "authentication failed" messages. The result is a class of bugs that looks like user error in your analytics but is actually a platform-level storage failure.

Three factors make these crashes especially elusive:

  • Hardware dependency. Secure Enclave failures on iOS and StrongBox errors on Android reproduce only on specific devices. A bug that affects 2% of your iPhone 14 Pro users may never surface in your iPhone 15 test device fleet.
  • State-dependent corruption. Keystore file corruption on Android often occurs during system updates or when storage runs critically low—conditions that are hard to simulate in a controlled test environment.
  • No stack trace. Many secure storage APIs return OSStatus codes (iOS) or checked exceptions (Android) that developers catch and re-throw as domain-specific errors, stripping the original failure context.

iOS Keychain Crash Patterns

Apple's Keychain Services API uses a C-based interface with OSStatus return codes. The API is powerful but unforgiving—a single misconfigured attribute can produce a crash that takes days to diagnose.

Error -25300: The Missing Item That Breaks Everything

errSecItemNotFound (-25300) is the most frequently encountered Keychain error, and also the most frequently mishandled. It fires when SecItemCopyMatching, SecItemUpdate, or SecItemDelete targets an item that doesn't exist. In many codebases, this triggers a fatalError:

let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.yourapp.auth",
    kSecAttrAccount as String: "refreshToken",
    kSecReturnData as String: true
]
 
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
 
guard status == errSecSuccess, let data = result as? Data else {
    fatalError("Keychain read failed: \(status)")
}

The fatalError here assumes the item should always exist. But Keychain items vanish for legitimate reasons: the user restored from an iCloud backup that excluded keychain data, the app was deleted and reinstalled, or an entitlement change invalidated the access group. Instead of crashing, treat a missing item as a "not authenticated" state and route the user to login.

Error -25299: Duplicate Item Collisions

errSecDuplicateItem (-25299) occurs when SecItemAdd finds an existing item with the same primary attributes. This is common during app migration flows where code assumes the keychain is empty:

func storeToken(_ token: String) {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.yourapp.auth",
        kSecAttrAccount as String: "refreshToken",
        kSecValueData as String: token.data(using: .utf8)!
    ]
    let status = SecItemAdd(query as CFDictionary, nil)
    if status != errSecSuccess {
        fatalError("Store failed: \(status)") // Boom on reinstall
    }
}

The safe pattern is delete-before-add or SecItemUpdate:

func safelyStoreToken(_ token: String) {
    let baseQuery: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.yourapp.auth",
        kSecAttrAccount as String: "refreshToken"
    ]
    SecItemDelete(baseQuery as CFDictionary)
 
    var addQuery = baseQuery
    addQuery[kSecValueData as String] = token.data(using: .utf8)!
    let status = SecItemAdd(addQuery as CFDictionary, nil)
 
    if status != errSecSuccess {
        reportKeychainError(status, context: "storeToken")
    }
}

Accessibility Attribute Traps

The kSecAttrAccessible attribute controls when a Keychain item is readable. A common trap: setting kSecAttrAccessibleWhenUnlockedThisDeviceOnly and then reading from a background fetch. When the device is locked, SecItemCopyMatching returns errSecItemNotFound—not errSecInteractionNotAllowed—because Apple's API treats "inaccessible right now" the same as "doesn't exist."

If your app performs background work that needs keychain access, use kSecAttrAccessibleAfterFirstUnlock. This makes items available after the first post-boot unlock, covering all background execution windows.

Secure Enclave Compatibility Checks

Hardware-backed keys stored in the Secure Enclave can fail with errSecUnimplemented (-4) or errSecParam (-50) when the device doesn't support the requested algorithm. Always verify enclave availability before attempting operations:

func secureEnclaveAvailable() -> Bool {
    let access = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        .privateKeyUsage,
        nil
    )
    return access != nil
}

Android Keystore Crash Patterns

Android's Keystore has evolved from a software-only JCA provider (API 18) to hardware-backed TEE keys (API 23) and dedicated StrongBox hardware modules (API 28). Each layer introduces distinct failure modes.

KeyPermanentlyInvalidatedException

This is the most disruptive Keystore error. It fires when a key generated with setUserAuthenticationRequired(true) becomes invalidated due to a biometric enrollment change, device PIN change, or work profile modification. The key is gone—permanently—and the only recovery path is regeneration with re-authentication:

val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
 
try {
    val entry = keyStore.getEntry("userKey", null)
    // Use the key normally
} catch (e: KeyPermanentlyInvalidatedException) {
    // Re-generate and prompt user for fresh authentication
    regenerateKeyAndReauthenticate()
}

StrongBox Unavailability

StrongBox provides hardware-backed key storage similar to Apple's Secure Enclave, but not all devices have it. Requesting StrongBox on an unsupported device throws StrongBoxUnavailableException:

val keyGenSpec = KeyGenParameterSpec.Builder(
    "encryptionKey",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setIsStrongBoxBacked(true)
    .build()
 
try {
    keyGenerator.init(keyGenSpec)
    keyGenerator.generateKey()
} catch (e: StrongBoxUnavailableException) {
    // Gracefully fall back to TEE-backed keys
    generateTeeBackedKey()
}

Keystore File Corruption

Android stores keystore data in /data/misc/keystore/user_0/. When this file becomes corrupted—during system updates, interrupted writes, or low-storage scenarios—you'll encounter java.io.IOException: Keystore integrity check failed. The only reliable recovery is deleting the keystore file and regenerating all keys, which requires full user re-authentication. Monitor for this by checking KeyStore.load(null) at every app launch and reporting failures immediately, since the window between corruption and user impact is measured in seconds.

Keystore corruption is particularly dangerous on devices with encrypted adoptable storage or work profiles, where the keystore directory can span multiple encryption domains. In these setups, a single failed write can cascade into corruption across all profiles. Defensive apps verify keystore integrity in a background coroutine at startup and surface a "session expired, please re-login" prompt before the user encounters a crash. This proactive approach turns a catastrophic data loss event into a minor inconvenience.

Key Attestation Failures on Custom ROMs

A lesser-known crash vector affects apps running on devices with custom Android builds or rooted firmware. Key attestation—the process of verifying that a key was generated inside a hardware-backed keystore—can fail silently on these devices because the attestation chain of trust is broken. Calling keyStore.getCertificateChain("myKey") on a device with an unlocked bootloader or custom CA may return an incomplete chain, causing NullPointerException or CertificateException in code that assumes a valid hardware attestation. Always guard attestation checks with try-catch blocks and fall back to software-backed key verification when hardware attestation is unavailable.

Encryption Migration: The Hidden Crash Factory

One of the most common but least discussed sources of secure storage crashes is encryption migration during app updates. If your v1.0 stored tokens using RSA-2048 and v2.0 expects AES-256-GCM ciphertext, the mismatch produces BadPaddingException (Android) or errSecDecode (-26275, iOS) errors that look indistinguishable from data corruption.

The solution is a versioned payload format that supports backward-compatible decryption with progressive re-encryption:

struct VersionedPayload: Codable {
    let version: Int
    let algorithm: String
    let ciphertext: Data
    let iv: Data
}
 
func decryptWithMigration(_ payload: VersionedPayload) throws -> Data {
    switch (payload.version, payload.algorithm) {
    case (1, "RSA-2048"):
        let plaintext = try legacyRSADecrypt(payload)
        try reEncryptWithCurrentScheme(plaintext)
        return plaintext
    case (2, "AES-256-GCM"):
        return try aesGCMDecrypt(payload)
    default:
        throw SecureStorageError.unsupportedVersion
    }
}

Version your encryption scheme from day one—even if you only have one algorithm. The incremental cost is a single integer field. The alternative is an app update that breaks every existing user session.

Monitoring Secure Storage Health

Standard crash reporting tools miss secure storage failures because exceptions are caught, wrapped, and surfaced as "login failed" events. Bugspulse's custom event monitoring captures the raw error codes before they're abstracted away:

fun reportSecureStorageError(
    platform: String,
    errorCode: Int,
    operation: String
) {
    BugsPulse.logCustomEvent("secure_storage_error", mapOf(
        "platform" to platform,
        "error_code" to errorCode.toString(),
        "operation" to operation,
        "os_build" to Build.VERSION.SDK_INT.toString(),
        "device_model" to Build.MODEL
    ))
}

Set up dashboards grouped by error code and device model. Watch for these patterns:

  • -25300 clusters after version bumps → entitlement or access group misconfiguration
  • -25299 spikes on fresh installs → delete-before-add logic missing
  • KeyPermanentlyInvalidatedException on Android 14+ → biometric policy changes
  • errSecUnimplemented on specific iPhone models → Secure Enclave algorithm incompatibility

When secure_storage_error events exceed 0.5% of active sessions for any error code, trigger a Bugspulse alert and investigate before user complaints start arriving.

Building Resilient Secure Storage

Secure storage crashes are preventable. The core principles are straightforward: never assume a keychain item exists, use delete-then-add to avoid duplicate collisions, check hardware capability before requesting enclave or StrongBox operations, version your encryption from the start, and monitor raw error codes before they're swallowed by abstraction layers.

Every silent authentication failure in your crash logs represents a user who may not come back. Register for a free Bugspulse account and start tracking secure storage health alongside your traditional crash metrics. When storage fails silently, only custom events will tell you what's broken before your users do.