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

Debug Mobile Encryption & Crypto Crashes: iOS & Android

NFNourin Mahfuj Finick··10 min read

Encryption is the last line of defense for every byte of user data your mobile app touches, yet the code paths that perform it are also some of the most crash-prone in production. Cryptography failures on iOS and Android rarely announce themselves politely: they surface as Swift traps, BadPaddingException, AEADBadTagException, or keys that silently become invalid after a user re-enrolls Face ID or changes their fingerprint. This guide walks through the most common encryption and crypto crash patterns across CryptoKit, CommonCrypto, the Security framework, javax.crypto, and the Android Keystore, with concrete fixes and a debugging workflow you can ship today.

Why crypto crashes are different

Most crashes give you a stack trace that points at the failing line. Crypto crashes are nastier because the root cause is often several steps removed: a key generated with different parameters in production, a nonce reused across two encryptions, or a biometric-bound key quietly invalidated by a passcode change. Treating them like ordinary null-pointer bugs sends you in circles. Instead, learn each platform's failure signatures and trace them back to the cryptographic parameter that caused them.

iOS: CryptoKit, CommonCrypto, and SecKey crash patterns

Apple's modern cryptography stack spans three APIs, each failing in its own way. CryptoKit is the Swift-native, high-level API you should default to; CommonCrypto is the older C API that powers a lot of legacy code; and the Security framework's SecKey provides key generation and signing primitives that underpin biometric authentication.

CryptoKit authenticationFailure: the AEAD tag mismatch

The most common CryptoKit failure is a thrown error that developers force-unwrap into a trap. AES.GCM.seal(_:using:) throws CryptoKitError.authenticationFailure when the encrypted payload's authentication tag does not match. In practice that means a wrong key, tampered ciphertext, or a reused nonce.

import CryptoKit
 
func decrypt(_ box: Data, using key: SymmetricKey) -> Data? {
    do {
        let sealed = try AES.GCM.SealedBox(combined: box)
        // authenticationFailure is thrown here on tag mismatch
        return try AES.GCM.open(sealed, using: key)
    } catch {
        // Log the CryptoKitError; never trap
        return nil
    }
}

The force-unwrap pattern — try! AES.GCM.open(...) — turns a recoverable error into a hard crash that looks like an untraceable Fatal error: 'try!' expression unexpectedly raised an error. Never force-unwrap a throwing crypto call. Catch it, log the underlying error, and fall back gracefully.

incorrectKeySize and incorrectParameterSize

CryptoKitError.incorrectKeySize and incorrectParameterSize are thrown when a key or IV does not match what an algorithm requires. ChaCha20-Poly1305, for example, expects a 256-bit key and a 96-bit nonce. A key built from a naive Data(utf8) conversion will almost never be the right length.

// Bad: password bytes are not a valid 256-bit key
let key = SymmetricKey(data: Array("hunter2".utf8))
// Good: derive a fixed-size key with SHA-256 first
let good = SymmetricKey(data: SHA256.hash(data: Array("hunter2".utf8)))

The same class of bug produces incorrectParameterSize when you hand a 16-byte IV to an API that wants 12. Fix these at the source by deriving keys deterministically and asserting sizes in debug builds.

Force-unwrapping keychain Data and base64/hex conversion

A surprising number of crypto crashes are string-conversion crashes. Keys fetched from the keychain come back as optional Data?, and a keychain item that was not restored to a new device returns nil. The classic trap is let keyData = keychainData! followed by a base64 decode that returns nil because the server sent non-standard padding.

guard let raw = SecItemCopyMatching(query, nil) as? Data,
      let key = String(data: raw, encoding: .utf8) else {
    // Key not present or not restorable — handle, don't trap
    return
}

Always treat key material as optional. This is the same nil-key bug we cover in our guide on debugging keychain and keystore storage crashes.

CommonCrypto kCCBufferTooSmall and status codes

Legacy code built on CommonCrypto fails via returned status codes rather than exceptions, and ignoring them is how you end up with garbage data or buffer overruns. CCCrypt returns kCCBufferTooSmall, kCCParamError, or kCCDecodeError instead of throwing.

import CommonCrypto
 
func aesCBCDecrypt(_ cipher: Data, key: Data, iv: Data) -> Data? {
    var outLen = 0
    var out = Data(count: cipher.count + kCCBlockSizeAES128)
    let status = out.withUnsafeMutableBytes { outBuf in
        cipher.withUnsafeBytes { inBuf in
            key.withUnsafeBytes { keyBuf in
                iv.withUnsafeBytes { ivBuf in
                    CCCrypt(CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES),
                            CCOptions(kCCOptionPKCS7Padding), keyBuf.baseAddress, key.count,
                            ivBuf.baseAddress, inBuf.baseAddress, cipher.count,
                            outBuf.baseAddress, out.count, &outLen)
                }
            }
        }
    }
    guard status == kCCSuccess else { return nil } // kCCBufferTooSmall, kCCDecodeError...
    return out.prefix(outLen)
}

Check the status code every single time. kCCDecodeError during decryption is usually a padding mismatch — a signal that the key, IV, or ciphertext is wrong, not that your buffer math is off.

Biometric-bound SecKey invalidation

Keys created with SecAccessControl and a biometric requirement can be permanently invalidated when the user re-enrolls Face ID or Touch ID, or changes their passcode. After invalidation, signing calls fail with errSecAuthFailed (-25293), and a force-unwrapped SecKey reference traps.

let flags: SecAccessControlCreateFlags = [.privateKeyUsage, .biometryCurrentSet]
// 'biometryCurrentSet' invalidates the key when biometrics change

Using .biometryCurrentSet is intentional — it invalidates keys when the enrolled biometric changes — but your app must handle the resulting errSecAuthFailed by re-enrolling the user rather than crashing. Cache the SecKey reference, never assume it stays valid across launches, and re-generate on demand.

Main-thread crypto and ANR on iOS

AES-GCM on a 100 MB blob, or PBKDF2 with a high iteration count, will pin the main thread and trigger a watchdog kill. Offload all non-trivial crypto to a background queue and hop back to the main thread only to update the UI.

Android: javax.crypto and Keystore crash patterns

Android's javax.crypto package throws exceptions generously, and the Keystore system adds failure modes tied to device and biometric state.

BadPaddingException and IllegalBlockSizeException

BadPaddingException during Cipher.doFinal almost always means a wrong key, corrupted ciphertext, or a block-mode/padding mismatch between encrypt and decrypt. IllegalBlockSizeException means the data length is not a multiple of the block size and you forgot padding. Both are unchecked exceptions in Java, so they crash your app unless you catch them.

fun aesCbcDecrypt(ciphertext: ByteArray, key: SecretKey, iv: ByteArray): ByteArray? {
    return try {
        val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding")
        cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
        cipher.doFinal(ciphertext)
    } catch (e: BadPaddingException) {
        null // wrong key or tampered data
    } catch (e: IllegalBlockSizeException) {
        null // missing or mismatched padding
    }
}

Catch these as data-corruption signals and route them to your error tracking. A BadPaddingException in the field often indicates a key-rotation bug or a server that changed its padding scheme, both of which deserve an alert.

AEADBadTagException: GCM authentication failure

The GCM equivalent of CryptoKit's authenticationFailure is AEADBadTagException, thrown when the authentication tag does not validate — tampered ciphertext, wrong key, or a reused IV. Catch it, log the context, and never retry the same operation expecting a different result.

NoSuchAlgorithmException and provider gaps

NoSuchAlgorithmException, NoSuchProviderException, and NoSuchPaddingException surface when a device or API level does not support the algorithm you requested. Older devices lack some modern ciphers, and certain devices ship Conscrypt builds that lag. AES/GCM/NoPadding is safe on API 21+, but exotic modes are not. Validate algorithm availability in a startup probe and keep a fallback transformation ready.

KeyPermanentlyInvalidatedException

When a key is generated with setUserAuthenticationRequired(true) or setInvalidatedByBiometricEnrollment(true), it becomes permanently invalid the moment the user adds or removes a fingerprint or changes their lock-screen credential. Any subsequent Cipher.init throws KeyPermanentlyInvalidatedException, and the correct response is to re-create the key and prompt for re-authentication.

val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
keyGen.init(
    KeyGenParameterSpec.Builder("my-key", KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .setUserAuthenticationRequired(true)
        .setInvalidatedByBiometricEnrollment(true)
        .build()
)

Catch KeyPermanentlyInvalidatedException (and its sibling UserNotAuthenticatedException) explicitly, delete the old alias, and re-run key generation. Our biometric authentication debugging guide goes deeper on this authentication-state machine.

Null keys and UnrecoverableKeyException

KeyStore.getKey(...) returns null when the alias does not exist — after an app restore, a work-profile wipe, or a clear-data that left the alias behind. Dereferencing the null key throws a bare NullPointerException with no crypto context.

val key = keyStore.getKey("my-key", null) as? SecretKey ?: run {
    generateKey() // regenerate instead of dereferencing null
    return
}

UnrecoverableKeyException and KeyStoreException similarly indicate the key material cannot be loaded. Wrap all Keystore access in null-safe paths.

The 12-byte GCM IV rule and SecureRandom blocking

GCM in Android expects a 12-byte IV; anything else throws InvalidAlgorithmParameterException. Generate it with GCMParameterSpec(128, iv) where iv is exactly 12 bytes, and never reuse it for the same key.

val iv = ByteArray(12)
SecureRandom().nextBytes(iv)
val spec = GCMParameterSpec(128, iv)

A subtle Android footgun is that the first call to SecureRandom can block while the system seeds its entropy pool, especially right after boot. Seed it once at startup on a background thread so your first encryption on a cold device does not stall the UI and trip an ANR.

Cross-cutting failure patterns

Several crypto bugs are platform-agnostic. Hardcoded keys are the top one: an AES key or HMAC secret compiled into the binary can be extracted, and it makes rotation a nightmare. Derive keys from a server-managed secret using a KDF, and build rotation in from day one. The OWASP Mobile Application Security project is a good baseline for these practices.

Nonce and IV reuse is the second. GCM and ChaCha20-Poly1305 are catastrophically broken if a nonce is reused with the same key — two ciphertexts under one nonce leak the authentication key. Generate nonces with a CSPRNG, never a resettable counter, and store them alongside the ciphertext. The NIST GCM specification spells out exactly why reuse is fatal.

HMAC verification and constant-time comparison round out the list. When you verify a MAC, compare digests with MessageDigest.isEqual (Android) or CryptoKit's HMAC type plus a constant-time compare — never == on hex strings, which leaks timing information.

A debugging workflow for production crypto crashes

When a crypto crash lands in your error dashboard, work a fixed sequence. First, capture the exception class and the exact algorithm, key size, IV length, and padding in the event metadata — that context is what separates a five-minute fix from a week-long mystery. Second, reproduce with the same key parameters, because almost every crypto failure is a parameter mismatch, not a logic bug. Third, add a startup probe that runs a known-vector encrypt/decrypt round-trip on every cold start, so algorithm-availability and provider problems surface before real user data is involved.

Observability matters more here than almost anywhere else. Because crypto failures are usually silent until they crash, you want the exception type, the key alias (never the key material), the algorithm string, and the device API level attached to every event. A crash-reporting platform that captures those structured fields turns a BadPaddingException into a diagnosable key-rotation bug in minutes. That is exactly the context Bugspulse attaches to every mobile error, and why teams trust it to catch encryption failures before users notice.

Ship crypto that fails safely

Encryption crashes are avoidable, but only if you stop treating cryptography as a black box. On iOS, catch CryptoKitError and CommonCrypto status codes, guard every SecKey and keychain access against nil, and offload heavy work off the main thread. On Android, catch BadPaddingException, IllegalBlockSizeException, and AEADBadTagException as data-corruption signals, regenerate keys on KeyPermanentlyInvalidatedException, and enforce the 12-byte GCM IV rule. Apply the cross-cutting checklist — no hardcoded keys, no nonce reuse, constant-time HMAC comparison — and instrument every failure with its full cryptographic context.

Crypto does not fail with a friendly message; it fails with a signature you have to recognize. Learn the signatures, capture the context, and your encryption layer will stop being the source of your worst production outages. Ready to see those failures the moment they happen? Start your free Bugspulse account and attach structured crypto context to every crash, today.