Dónde guardar las API keys de tu app Flutter (y por qué compilarlas no las esconde)
La respuesta corta: cualquier clave que compiles dentro de tu app es pública. Da igual que uses .env, --dart-define o un paquete que la ofusque en base64. Si el valor viaja dentro del binario que se instala en el móvil del usuario, alguien puede sacarlo. La pregunta buena no es "cómo escondo esta clave", sino "qué secretos pueden vivir en el cliente y cuáles tienen que salir de ahí".
Esto importa ahora más que hace dos años. Media industria está metiendo features de IA en apps existentes, y con ellas entra la clave de Gemini, de Claude o de OpenAI directamente en el código Flutter. Es el mismo error de siempre con una factura nueva: una clave de LLM filtrada no es un susto de seguridad abstracto, es tu tarjeta pagando el consumo de quien la encuentre.
Vamos a ordenar el problema por tipos de secreto, porque cada uno vive en un sitio distinto.
Tres tipos de secreto, tres sitios distintos
Casi todos los líos vienen de tratar estos tres como si fueran lo mismo.
1. Secretos de servidor. Claves de API de pago, tokens de proveedores de LLM, credenciales de servicios de terceros que facturan por uso. Nunca van en el cliente. No hay una forma "más segura" de ponerlos en la app; la forma segura es no ponerlos.
2. Configuración no sensible. El endpoint de tu backend, el ID del proyecto de Firebase, una flag de feature, la URL de la tienda. Esto puede ir en el binario sin problema, porque no es un secreto: es información que de todas formas se ve en el tráfico de red. Aquí --dart-define hace bien su trabajo.
3. Secretos del usuario. El token de sesión, el refresh token, lo que identifica a esa persona en concreto. No viaja en el binario: lo obtiene la app en tiempo de ejecución, tras el login, y se guarda en el dispositivo. Para esto existe flutter_secure_storage.
El 90% de las decisiones salen solas en cuanto clasificas cada valor en una de estas tres cajas. El problema es el tipo 1, así que vamos a por él.
Lo que no funciona (y lo vemos cada semana)
Hardcodear la clave en un String. Obvio, pero sigue pasando. Un apktool sobre el APK y la clave está en texto plano en cuestión de minutos.
Moverla a un .env o a --dart-define. Es más limpio para tu repo y tu CI, y por eso merece la pena para configuración. Pero no esconde nada: --dart-define inyecta el valor en tiempo de compilación y queda dentro del binario igual que si lo hubieras escrito a mano. Te protege de subir la clave a Git, no de que la extraigan del build.
Confiar en la ofuscación. flutter build --obfuscate renombra clases y funciones y borra información de debug. Hace tu código más incómodo de leer, y está bien tenerlo activado. Pero una cadena que la app necesita usar en claro en algún momento sigue estando ahí; la ofuscación la retrasa, no la elimina. Lo mismo vale para paquetes que guardan la clave en base64: base64 no es cifrado, es un disfraz que se quita con una línea.
Traértela de Remote Config y descifrarla en el móvil. Esta es más sutil y la vemos en gente que ya sabe que hardcodear está mal. Da igual lo elaborado que sea el esquema: si la app descifra la clave para usarla, la clave existe en claro en memoria y es interceptable. Has añadido complejidad y una falsa sensación de seguridad.
El patrón común de todos estos: intentan ganar una partida que no se puede ganar. El cliente es territorio hostil. Asume que alguien con el binario tiene todo lo que hay dentro.
El patrón que sí: el backend como único que conoce la clave
La clave de tu proveedor vive en tu servidor. La app no llama al proveedor; llama a un endpoint tuyo, y tu backend añade la clave y reenvía la petición. El cliente nunca la ve.
App Flutter ──► tu backend (tiene la clave) ──► API del proveedor
Con esto la clave está en una variable de entorno de tu servicio, donde puedes rotarla sin publicar una versión nueva de la app, registrar cada uso y cortar el grifo si algo se desmadra. Para un backend en Go sobre Cloud Run esto son pocas líneas, y de paso te da el sitio natural donde poner rate limiting y validación.
// En vez de llamar al proveedor con la clave en el cliente:
final res = await http.post(
Uri.parse('https://api.tu-dominio.com/v1/chat'),
headers: {'Authorization': 'Bearer $tokenDeSesionDelUsuario'},
body: jsonEncode({'prompt': prompt}),
);
// La clave del LLM nunca sale de tu servidor.
Ahora bien, un proxy abierto es su propio problema: si cualquiera puede llamar a tu endpoint, le has regalado el consumo igual, solo que con un paso más. El proxy necesita dos cosas.
Saber que la petición viene de tu app de verdad. Para esto está la attestation: App Check en Firebase (con Play Integrity y App Attest por debajo) emite un token que confirma que la llamada sale de una instancia legítima de tu app, no de un script. No es infalible, pero sube el listón lo suficiente como para que no te raspen el endpoint con curl. Si usas Firebase AI Logic, por cierto, App Check pasa a ser obligatorio a partir del 2 de noviembre, así que esto ya no es opcional para parte de la base instalada.
Saber quién es el usuario. El token de sesión del login identifica a la persona y te deja aplicar cuotas por usuario. La attestation dice "esto es tu app"; el token de sesión dice "este es Pepe". Necesitas los dos.
Como red extra, restringe la clave en el propio proveedor cuando se pueda: las API keys de Google Cloud se pueden limitar por aplicación y por API concreta, de modo que una clave filtrada sirva para menos. No sustituye al proxy, pero recorta el daño.
¿Y los secretos del usuario?
Aquí el sitio correcto es flutter_secure_storage, que guarda los valores en el Keychain de iOS y en el Keystore de Android en lugar de en shared_preferences (texto plano, nunca metas un token ahí).
const storage = FlutterSecureStorage();
await storage.write(key: 'refresh_token', value: token);
final token = await storage.read(key: 'refresh_token');
La distinción que importa: esto protege datos que la app recibe en tiempo de ejecución y que son de ese dispositivo. No sirve para la clave de tu proveedor de pago, porque esa tendrías que meterla tú en el build, y volvemos al punto de partida. Secure storage es para lo que entra después del login, no para lo que sale de fábrica con la app.
¿Y si no tengo backend?
A veces el proxy se siente desproporcionado para una app pequeña. Dos matices honestos.
Si la clave es de bajo valor y de solo lectura (una API de datos públicos con plan gratuito, por ejemplo), el coste de filtrarla puede ser asumible y quizá no merezca montar infraestructura. Es una decisión de riesgo legítima, siempre que la tomes a sabiendas y no por descuido.
Si la clave factura por uso (cualquier LLM entra aquí), no hay plan pequeño que valga: necesitas el intermediario. La buena noticia es que no tiene que ser un servidor que mantengas a mano. Una Cloud Function, un endpoint serverless o la capa de IA de tu BaaS hacen el trabajo sin que montes un servicio completo. Lo que no es negociable es que la clave esté en un sitio donde tú controlas el acceso, no dentro de 50.000 instalaciones.
Resumen
Clasifica cada valor antes de escribir una línea: configuración no sensible al binario con --dart-define; secretos del usuario al Keychain/Keystore con flutter_secure_storage; secretos de servidor fuera del cliente, siempre. Para estos últimos, un proxy en tu backend con attestation y token de sesión es el patrón que aguanta en producción, y el único que te deja rotar la clave sin publicar una app nueva. Todo lo demás (ofuscar, base64, descifrar en el móvil) es retrasar lo inevitable con la etiqueta equivocada encima.
Si estás metiendo IA en una app que ya está en producción y no tienes claro dónde va a vivir esa clave, es exactamente el tipo de decisión que resolvemos en el desarrollo backend de los proyectos que llevamos. Mejor decidirlo antes de publicar que después de la primera factura sorpresa.




