~/blog/flutter-secure-storage-bad-decrypt-android.md
Cuando el almacenamiento seguro te bloquea el login: BAD_DECRYPT en Flutter y Android
Un login que funciona en casi todos los teléfonos pero falla siempre en unos pocos Android, aunque la clave esté bien. La culpa no era la red ni el servidor: era el cifrado del almacenamiento seguro. Qué pasa, cómo diagnosticarlo y cómo corregirlo sin perder sesiones.
- autor
- Alejandro Peñaloza
- Publicado
- Lectura
- 5 min
- English
- Read in English →

En este artículo
Hay bugs que se esconden muy bien. Este fue uno de ellos: algunos usuarios de una app Flutter no podían iniciar sesión en Android. Les salía siempre un genérico "No pudimos iniciar sesión, intenta más tarde". Mientras tanto, en mis teléfonos de prueba (dos Android y un iPhone) todo funcionaba perfecto.
Las pistas parecían contradictorias:
- La contraseña era correcta.
- "Borrar datos" de la app no lo arreglaba.
- Desinstalar y reinstalar desde la tienda tampoco.
- Fallaba siempre, no de vez en cuando.
El primer error: adivinar en vez de medir
Mi primera sospecha fue el almacenamiento seguro. La descarté rápido porque "si borrar datos no lo arregla, no puede ser el storage". Me fui por otro lado: errores de red, TLS, timeouts, respuestas raras del servidor. Blindé todo eso (reintentos, mejores mensajes, manejo de excepciones que antes se escapaban). Todo útil, pero ninguno era el problema.
Lo que de verdad destrabó el caso fue agregar un log de diagnóstico que el usuario pudiera compartir. Es un registro en memoria, sin datos sensibles, con cada paso del login y cada request: método, path, status y tiempo. El usuario lo envía por WhatsApp o correo con un botón que solo aparece ante errores técnicos (nunca por clave incorrecta).
El primer log que llegó lo dejó todo claro:
[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] errorEl servidor respondía 200: la clave estaba bien. La app guardaba el token sin error. Pero en el siguiente request, al leer ese mismo token recién guardado, el descifrado fallaba. Y como pasaba con datos recién creados, borrar datos no cambiaba nada. El problema no estaba en los datos, estaba en el cifrado.
Cómo funciona flutter_secure_storage en Android
En iOS, flutter_secure_storage usa el Keychain y casi no da sorpresas. En Android la historia es otra: el plugin cifra los valores y los guarda en SharedPreferences, y la llave vive (directa o indirectamente) en el Android Keystore.
En las versiones 8.x el esquema por defecto era:
| Pieza | Algoritmo por defecto (v8) |
|---|---|
| Cifrado de la llave (key cipher) | RSA/ECB/PKCS1Padding |
| Cifrado de los datos (storage cipher) | AES/CBC/PKCS7Padding |
Es decir: una llave AES cifra tus datos, esa llave AES se guarda envuelta con una llave RSA del Keystore, y en cada operación se desenvuelve y se usa. Funciona en la gran mayoría de los equipos. Pero depende de cómo cada fabricante implementa el Keystore y los proveedores criptográficos. Si en algún equipo esa cadena no se comporta igual al escribir que al leer, obtienes exactamente esto: escribe bien, lee mal, BAD_DECRYPT.
flowchart LR
A[Valor] -->|AES-CBC| B[Texto cifrado en SharedPreferences]
K[Llave AES] -->|envuelta con RSA PKCS1| S[SharedPreferences]
R[Llave RSA] --- KS[(Android Keystore)]
S -->|desenvolver| K
B -->|descifrar| X{¿OK?}
X -->|no| E[BadPaddingException / BAD_DECRYPT]
Otros fallos típicos del almacenamiento seguro en Android
Además del caso anterior, hay otros escenarios conocidos que terminan en el mismo síntoma ("no puedo entrar" o "la app me saca"):
- Backup y restauración. Con
allowBackupactivo, Android puede respaldarSharedPreferencesen la nube y restaurarlas en otro equipo o tras reinstalar. Pero las llaves del Keystore no viajan: quedan datos cifrados con una llave que ya no existe. Ese storage es ilegible para siempre. - Cambios de seguridad del dispositivo. Quitar o cambiar el PIN o patrón, o una actualización del sistema, puede invalidar llaves del Keystore en algunos equipos.
- Implementaciones de fabricante. Capas como las de algunos fabricantes, o versiones nuevas de Android, a veces cambian el comportamiento de algoritmos antiguos (como RSA con PKCS1).
- Errores que se tragan. Si tu código hace
catch (e)y muestra un mensaje genérico, nunca sabrás que fue unaPlatformExceptiondel storage y no un problema de red.
La solución
1. Actualizar a flutter_secure_storage 10
La versión 10 reescribió la implementación de Android:
- Cambia los algoritmos por defecto a
RSA/ECB/OAEPwithSHA-256andMGF1Paddingpara la llave yAES/GCM/NoPaddingpara los datos. - Con
migrateOnAlgorithmChange(activo por defecto) migra solo los datos guardados con los algoritmos antiguos, así tus usuarios no pierden la sesión al actualizar. resetOnErrorviene activo por defecto: si el storage queda ilegible, lo reinicia en vez de dejar la app trabada.
dependencies:
flutter_secure_storage: ^10.3.4Ojo con saltar directo a la v11. La 11 elimina los algoritmos antiguos (
RSA_ECB_PKCS1Padding,AES_CBC_PKCS7Padding) y ya no puede leer datos de la 8.x. Si pasas de 8 a 11 sin pasar por la 10, todos tus usuarios pierden la sesión. Primero publica la 10, deja que migre, y recién después sube a la 11.
2. Probar el storage al arrancar: escribir y leer
Una prueba de salud al iniciar la app ayuda mucho. Pero aprendí que escribir no basta: en el caso real, la escritura funcionaba y lo que fallaba era la lectura. La prueba tiene que ser de ida y vuelta:
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('lectura no coincide con lo escrito');
}
await storage.delete(key: probe);
} catch (e) {
// Storage ilegible: no hay sesión que perder, se reinicia.
log('storage ilegible: $e');
try {
await storage.deleteAll();
} catch (_) {}
}
}3. Desactivar el backup de datos cifrados
Si tus datos cifrados dependen del Keystore, no tiene sentido respaldarlos:
<application
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules">Con data_extraction_rules.xml excluyendo sharedpref (y lo demás que corresponda) de cloud-backup y device-transfer.
4. Logs compartibles y sin datos sensibles
Este fue el verdadero héroe. Algunas reglas que me funcionaron:
- Enmascarar antes de guardar, no al exportar: tokens, cookies, contraseñas, correos, identificadores personales y query strings.
- Guardar solo el path de las URLs, sin parámetros.
- Incluir datos del equipo que sirvan para diagnosticar: versión de Android y SDK, fabricante y modelo, parche de seguridad, idioma y zona horaria. Nada que identifique a la persona.
- Mostrar el botón de compartir solo en errores técnicos. Una clave incorrecta no es un bug.
Lecciones
- "Borrar datos no lo arregla" no descarta el storage. Solo descarta que los datos guardados estén corruptos. Si el cifrado falla con datos nuevos, el problema es el algoritmo o el Keystore del equipo.
- Escribir no prueba que puedas leer. Las pruebas de salud tienen que hacer el ciclo completo.
- Que funcione en tus teléfonos no prueba nada cuando el problema depende del fabricante y de la versión de Android.
- Sin telemetría estás adivinando. Un log pequeño, en memoria y sanitizado, que el usuario pueda compartir con un toque, vale más que horas de hipótesis.
- Las migraciones de cifrado se hacen por etapas. Saltarse una versión mayor puede borrar la sesión de todos tus usuarios de un plumazo.
Comentarios
Aún no hay comentarios. ¿Quieres dejar el primero?
Deja un comentario
$ ls ~/blog | grep relacionados
Sigue leyendo

Bitácora de guerra: depurar CORS, refactorizar código propio e integrar Webpay sin perder la tarde
Problemas reales y cómo los resolví: un error de CORS que solo fallaba en producción, una fuga de memoria en el servidor, código mío de hace un año refactorizado sin vergüenza y una guía práctica para integrar Webpay Plus, Mercado Pago y Flow.

Productividad para desarrolladores: VS Code, Git avanzado, gestión ágil y scripts de automatización
Mi flujo de trabajo diario como desarrollador: las extensiones y ajustes de VS Code que sí aportan, Git más allá de commit y push (rebase, cherry-pick y bisect), cómo gestionar proyectos pequeños sin burocracia y los scripts que automatizan lo repetitivo.

De localhost a producción sin llorar: VPS, DNS, hosting y Docker para desarrolladores
Guía práctica para sacar un proyecto a producción: cómo asegurar un VPS desde cero, configurar DNS y Cloudflare, elegir entre hosting compartido, VPS o cloud, y levantar tu base de datos local con Docker Compose en segundos.