all systems operationalall operational

apenaloza.cl · bitácora

SCL (UTC−3) · v2026.10

~/blog/flutter-secure-storage-bad-decrypt-android-login.md

When secure storage locks you out of login: BAD_DECRYPT in Flutter and Android

A login that works on almost every phone but always fails on a few Android devices, even with the right password. The culprit wasn't the network or the server: it was the secure storage encryption. What happens, how to diagnose it, and how to fix it without losing sessions.

author
Alejandro Peñaloza
Published
Reading
4 min
Portada de BAD_DECRYPT: candado azul agrietado con una fisura roja junto al texto "cuando el almacenamiento seguro te bloquea el login" y las etiquetas Flutter, Android y flutter_secure_storage
In this article
  1. The first mistake: guessing instead of measuring
  2. How flutter_secure_storage works on Android
  3. Other typical secure storage failures on Android
  4. The fix
  5. Lessons

Some bugs hide really well. This was one of them: some users of a Flutter app couldn't log in on Android. They always got a generic "We couldn't sign you in, please try again later". Meanwhile, on my test phones (two Android devices and an iPhone) everything worked perfectly.

The clues seemed contradictory:

  • The password was correct.
  • "Clear data" on the app didn't fix it.
  • Uninstalling and reinstalling from the store didn't either.
  • It failed every time, not just occasionally.

The first mistake: guessing instead of measuring

My first suspect was secure storage. I ruled it out quickly because "if clearing data doesn't fix it, it can't be the storage". I went down another path: network errors, TLS, timeouts, odd server responses. I hardened all of that (retries, better messages, handling exceptions that used to slip through). All useful, but none of it was the problem.

What actually cracked the case was adding a diagnostic log the user could share. It's an in-memory record, with no sensitive data, of every login step and every request: method, path, status and timing. The user sends it via WhatsApp or email with a button that only shows up on technical errors (never on a wrong password).

The first log that came in made everything clear:

[splash] PlatformException: ... javax.crypto.BadPaddingException:
         error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT
[login]  inicio
[http]   POST /oauth/token -> 200 (1046 ms)
[http]   ERROR PlatformException: ... BadPaddingException: BAD_DECRYPT (17 ms)
[login]  error

The server responded 200: the password was fine. The app saved the token without error. But on the next request, when reading that same freshly saved token, decryption failed. And since it happened with newly created data, clearing data changed nothing. The problem wasn't in the data, it was in the encryption.

How flutter_secure_storage works on Android

On iOS, flutter_secure_storage uses the Keychain and rarely brings surprises. On Android it's a different story: the plugin encrypts values and stores them in SharedPreferences, and the key lives (directly or indirectly) in the Android Keystore.

In the 8.x versions the default scheme was:

Piece Default algorithm (v8)
Key encryption (key cipher) RSA/ECB/PKCS1Padding
Data encryption (storage cipher) AES/CBC/PKCS7Padding

In other words: an AES key encrypts your data, that AES key is stored wrapped with an RSA key from the Keystore, and on every operation it gets unwrapped and used. It works on the vast majority of devices. But it depends on how each manufacturer implements the Keystore and the cryptographic providers. If on some device that chain doesn't behave the same when writing as when reading, you get exactly this: writes fine, reads wrong, BAD_DECRYPT.

flowchart LR
  A[Value] -->|AES-CBC| B[Ciphertext in SharedPreferences]
  K[AES key] -->|wrapped with RSA PKCS1| S[SharedPreferences]
  R[RSA key] --- KS[(Android Keystore)]
  S -->|unwrap| K
  B -->|decrypt| X{OK?}
  X -->|no| E[BadPaddingException / BAD_DECRYPT]

Other typical secure storage failures on Android

Besides the case above, there are other known scenarios that end in the same symptom ("I can't get in" or "the app keeps logging me out"):

  1. Backup and restore. With allowBackup enabled, Android can back up SharedPreferences to the cloud and restore them on another device or after a reinstall. But Keystore keys don't travel: you're left with data encrypted with a key that no longer exists. That storage is unreadable forever.
  2. Device security changes. Removing or changing the PIN or pattern, or a system update, can invalidate Keystore keys on some devices.
  3. Manufacturer implementations. Some manufacturers' layers, or newer Android versions, sometimes change the behavior of older algorithms (such as RSA with PKCS1).
  4. Swallowed errors. If your code does catch (e) and shows a generic message, you'll never know it was a storage PlatformException and not a network problem.

The fix

1. Upgrade to flutter_secure_storage 10

Version 10 rewrote the Android implementation:

  • It changes the default algorithms to RSA/ECB/OAEPwithSHA-256andMGF1Padding for the key and AES/GCM/NoPadding for the data.
  • With migrateOnAlgorithmChange (enabled by default) it migrates automatically data saved with the old algorithms, so your users don't lose their session when updating.
  • resetOnError is enabled by default: if the storage becomes unreadable, it resets it instead of leaving the app stuck.
dependencies:
  flutter_secure_storage: ^10.3.4

Be careful about jumping straight to v11. Version 11 removes the old algorithms (RSA_ECB_PKCS1Padding, AES_CBC_PKCS7Padding) and can no longer read data from 8.x. If you go from 8 to 11 without going through 10, all your users lose their session. Ship 10 first, let it migrate, and only then move to 11.

2. Test the storage at startup: write and read

A health check at app startup helps a lot. But I learned that writing isn't enough: in the real case, the write worked and what failed was the read. The check has to be a round trip:

static Future<void> repararSiIlegible() async {
  const probe = '_storage_probe';
  try {
    await storage.write(key: probe, value: '1');
    if (await storage.read(key: probe) != '1') {
      throw StateError('read does not match what was written');
    }
    await storage.delete(key: probe);
  } catch (e) {
    // Unreadable storage: there's no session to lose, so reset it.
    log('unreadable storage: $e');
    try {
      await storage.deleteAll();
    } catch (_) {}
  }
}

3. Disable backup of encrypted data

If your encrypted data depends on the Keystore, there's no point in backing it up:

<application
    android:allowBackup="false"
    android:fullBackupContent="false"
    android:dataExtractionRules="@xml/data_extraction_rules">

With data_extraction_rules.xml excluding sharedpref (and anything else that applies) from cloud-backup and device-transfer.

4. Shareable logs without sensitive data

This was the real hero. Some rules that worked for me:

  • Mask before storing, not on export: tokens, cookies, passwords, emails, personal identifiers and query strings.
  • Store only the path of URLs, without parameters.
  • Include device data that helps with diagnosis: Android version and SDK, manufacturer and model, security patch, language and time zone. Nothing that identifies the person.
  • Show the share button only on technical errors. A wrong password is not a bug.

Lessons

  • "Clearing data doesn't fix it" doesn't rule out the storage. It only rules out that the saved data is corrupted. If encryption fails with fresh data, the problem is the algorithm or the device's Keystore.
  • Writing doesn't prove you can read. Health checks have to do the full cycle.
  • It working on your phones proves nothing when the problem depends on the manufacturer and the Android version.
  • Without telemetry you're guessing. A small, in-memory, sanitized log that the user can share with one tap is worth more than hours of hypotheses.
  • Encryption migrations are done in stages. Skipping a major version can wipe out the session of all your users in one stroke.

share:linkedinx

Comments

No comments yet. Want to leave the first one?

Leave a comment

Comments are reviewed before they go live. I don’t ask for your email or store your IP.

$ ls ~/blog | grep related

Keep reading