A production Flutter application was recently subjected to a complete reverse engineering engagement. Every layer of its API authentication scheme was reconstructed from compiled binaries: embedded private keys were extracted from memory, the signing message format was recovered through Dart AOT disassembly, and the production API was successfully called from a standalone Python script.
The full methodology is documented in the companion post. This post addresses the other side of that research: what you can actually do to defend against it.
The honest answer is that no mobile app can be made unbreakable. Any code that runs on hardware your users control can, in principle, be analyzed. The goal of mobile app hardening is not to achieve impossibility — it is to raise the cost of analysis high enough that most attackers stop, and to limit the blast radius when analysis does succeed.
What the Research Revealed About Flutter's Default Security Posture
Before discussing defenses, it helps to be specific about what Flutter provides by default and where it falls short.
What Flutter's Dart AOT compilation actually protects:
- Class and method names are stripped when compiled with
--obfuscate, making casual string searches and decompilers less useful - Dart bytecode is not present — there is no equivalent to Android's Dalvik bytecode that tools like jadx can decompile into readable Java
- The compiled output is dense ARM64 machine code, which is slower to analyze manually than higher-level representations
What Flutter's Dart AOT compilation does not protect:
- String constants are stored in the object pool and fully recoverable with tools like Blutter, regardless of
--obfuscate - Function addresses are recoverable from closure entries in the object pool
- Any data embedded in the binary — API keys, signing keys, base URLs — is readable by anyone who can attach a debugger or memory scanner
What BoringSSL's static linking actually protects:
- Standard SSL bypass tools (Objection, frida-ssl-bypass) cannot find
SSL_CTX_set_verifyby name because the symbol does not exist - Tools that rely on intercepting system-level TLS libraries will fail
What BoringSSL's static linking does not protect against:
- Application-layer function hooks, which bypass TLS entirely by hooking before data is encrypted
- reFlutter, which patches
libflutter.soat the binary level to redirect TLS - Frida Gadget injection, which gives full instrumentation access from process start
Understanding these distinctions prevents two common mistakes: over-trusting Flutter's built-in protections and ignoring them entirely.
1. Never Embed Private Keys in the Binary
The most critical finding in the research was two RSA private keys stored in plaintext inside the application's native library.
Memory scanning for -----BEGIN PRIVATE KEY----- found both keys within minutes of attaching Frida. Once found, they are permanent — unlike a password, a private key embedded in a shipped APK cannot be rotated without forcing an app update, and even then, every user still running the old version can still use the old key.
Do not put private keys, API secrets, or credentials in your binary. This includes:
- Hardcoded signing keys
- Hardcoded API secrets or HMAC keys
- Embedded OAuth client secrets
- Hardcoded database connection strings
If the business logic requires the client to sign requests (as this app did), the signing should happen server-side, or the key should be fetched from a server at runtime and stored only in volatile memory, never persisted to disk.
Where a key must be stored on-device, Android Keystore provides hardware-backed key storage that ties the key to the device and the app signature. Keys generated inside the Keystore cannot be exported. The app can use them but cannot read them out. Note that this does protect against static extraction — but on a rooted device at runtime, the key's output (signed data) is still capturable, even if the raw key material is not.
2. Use Server-Side Authentication, Not Client-Side Signing
The fundamental architectural vulnerability in the researched app was using a client-side signing scheme for API authentication.
The signing key lives in the binary. The signing algorithm is in the binary. The signing message format is in the binary. Every piece of information needed to replicate the authentication scheme is present in the binary, which is distributed to every user. Given time and the right tools, any of those users can reconstruct it.
A more resilient architecture:
- Short-lived server-issued tokens: The client authenticates once (via OAuth, JWT, or a login flow backed by hardware attestation), receives a short-lived token, and presents that token on subsequent requests. The signing key never leaves the server.
- Backend-for-frontend (BFF) pattern: Sensitive API calls go through a server-side proxy that holds credentials. The mobile app never holds long-lived secrets.
- API keys with request scoping: If client-side keys are unavoidable, issue per-device or per-user keys with narrow scopes, monitor usage, and rotate on anomaly detection.
The goal is to ensure that compromising the binary does not compromise the entire API surface permanently.
3. Use Android Keystore Correctly
Android Keystore provides hardware-backed cryptographic operations. Keys generated inside the Keystore:
- Cannot be exported from the secure element
- Are bound to the app signature — a repackaged app cannot use them
- Can require user authentication (biometric or PIN) before use
The researched app did use Keystore for one secret, but used it to store a static value rather than generate it. The value was decryptable at runtime via Frida's Java bridge.
To use Keystore effectively:
Generate keys inside Keystore, do not import them:
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val spec = KeyGenParameterSpec.Builder(
"my_key_alias",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationValidityDurationSeconds(30)
.build()
keyGenerator.init(spec)
keyGenerator.generateKey()
Setting setUserAuthenticationRequired(true) means the key cannot be used without a fresh biometric or PIN confirmation, significantly raising the cost of automated extraction.
Key attestation (available on Android 8+) lets your server verify that a key was generated inside a genuine Keystore implementation on uncompromised hardware. If attestation fails, your server can refuse to issue tokens.
4. Enable All Dart Obfuscation Flags
Flutter's --obfuscate flag in release builds strips class and method names, making Blutter output less useful. But it must be combined with other flags to be effective:
flutter build apk --release \
--obfuscate \
--split-debug-info=build/app/outputs/symbols
The --split-debug-info flag is required alongside --obfuscate. Without it, the obfuscation is incomplete.
What this achieves:
- Class names become
zub,Cub,LA, etc. — they lose semantic meaning - Stack traces in crash reports become unreadable without the split debug symbols (which you keep privately)
- Blutter can still recover object pool entries and function addresses, but the semantic reconstruction is harder and slower
What this does not achieve:
- String constants in the object pool are still readable
- Function behavior is unchanged — only names are obscured
- Blutter users can still map signing message structure from string adjacency in the pool
Obfuscation is a friction-raiser, not a barrier. Use it, but do not rely on it.
5. Implement Root and Integrity Detection
The entire research engagement required a rooted Android device. Without root, frida-server cannot run, APK repackaging with injected gadgets cannot be installed, and the Keystore extraction via Java bridge cannot be executed.
Flutter does not detect root by default. Adding detection requires integrating a library or writing the checks manually. Good options:
- RootBeer (Java/Kotlin): Checks for common root indicators (Superuser APK,
subinary, test keys, dangerous props) - SafetyNet Attestation / Play Integrity API: Google's service verifies the device has not been tampered with and is running genuine, unmodified software. This is server-verified — the app requests an attestation token, sends it to your server, and your server validates it against Google's API. An attacker cannot fake a passing attestation without compromising the Play Integrity service itself.
Important caveat: root detection and integrity checks are bypassable. Frida hooks can intercept the detection functions and return false. The value is not making it impossible but making it cost additional effort — and in many threat models, that additional effort is enough.
For high-value applications, hook detection is worth implementing: detect the presence of Frida, Xposed, or other instrumentation frameworks by checking for known port numbers, library names, or modified memory regions.
6. Implement Certificate Pinning — Correctly
The researched app had some certificate pinning in place, but it was ultimately irrelevant because the signing function was hooked before the TLS layer was involved. However, certificate pinning remains valuable against a different class of attacker: one who controls a network path but cannot run code on the device.
Flutter applications should implement pinning using the http package with a custom SecurityContext, or via the dio package's BadCertificateCallback. The pin should be validated against the certificate's public key hash (SPKI hash), not the full certificate, so that certificate renewals do not break the pin.
final securityContext = SecurityContext()
..setTrustedCertificatesBytes(pinnedCertificateBytes);
final httpClient = HttpClient(context: securityContext);
For defense against reFlutter-style attacks that patch libflutter.so, there is no clean solution at the app level. The best countermeasure is Play Integrity attestation on the server side — if libflutter.so has been modified, the app binary hash changes and attestation fails.
7. Minimize What Is in the Binary
The most effective hardening is also the simplest: do not put sensitive material in the binary.
A practical audit checklist:
- No private keys, HMAC secrets, or API tokens hardcoded anywhere in the Dart code or native libraries
- No internal API base URLs that expose infrastructure details in the production binary (use environment-specific builds or remote config)
- No authentication logic that produces valid credentials from client-side data alone
- Sensitive constants fetched from a server at runtime and held only in memory, not written to any persistent storage
- Security-critical configuration served from a remote endpoint authenticated via a public-key scheme where the public key (not private key) is embedded
The attack surface exposed by the binary is exactly proportional to what you put in it.
8. Use Network Security Config on Android
Android's Network Security Config (res/xml/network_security_config.xml) lets you restrict which certificate authorities are trusted for your app's network connections, independent of the system trust store:
<network-security-config>
<domain-config>
<domain includeSubdomains="true">your-api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">SPKI_HASH_BASE64_HERE</pin>
<pin digest="SHA-256">BACKUP_PIN_BASE64_HERE</pin>
</pin-set>
</domain-config>
</network-security-config>
This is configured at the Android manifest level, not in Flutter/Dart code, which makes it slightly harder to bypass via Dart-level hooks. It does not protect against reFlutter, but it raises the bar against network-based MitM attacks.
9. Secure the API Layer, Not Just the Client
Whatever protections you put in the app, a determined analyst may eventually bypass them. The server needs its own defenses that do not rely on the client being uncompromised:
Anomaly detection: Monitor for unusual request patterns — rate, timing, geographic spread, device fingerprint changes. A standalone API client will not perfectly replicate realistic app usage patterns.
Token binding: Issue tokens that are bound to device attestation results. A token issued after a passing Play Integrity check cannot be reused on a device that fails attestation.
Request fingerprinting: Log and analyze the full request signature, not just authentication headers. Legitimate app requests have consistent User-Agent headers, consistent timing relative to app UI actions, and consistent device metadata. Scripted clients often miss these details.
Short-lived credentials with narrow scope: Any credential that leaks from the binary should be short-lived and scoped to minimal permissions. A leaked signing key that only allows read access to public tracking data is a much smaller problem than one that allows writes, account actions, or access to private data.
Prioritizing the Work
If you are assessing a production Flutter app right now, here is where to start, ordered by impact:
-
Audit for embedded keys and credentials. Run
stringson yourlibapp.soand grep for anything that looks like a key, secret, or credential. If you find any, replace them before anything else. -
Implement Play Integrity attestation. This is the highest-leverage server-side control. It makes the entire class of attacks described in this post much harder because the device's integrity is validated server-side.
-
Enable
--obfuscateand--split-debug-info. Low cost, meaningfully raises friction. -
Review the authentication architecture. Client-side signing with embedded keys is a pattern worth reconsidering. Server-side token issuance with narrow scope is more resilient.
-
Add root and instrumentation detection. Not a barrier on its own, but part of a layered approach.
Security in mobile applications is not a single control — it is a set of layers, each of which raises the cost of attack. No single layer is sufficient. Together, they can make the analysis described in the companion post require significantly more effort and expertise, which is enough to stop most attackers.
The companion post, Reverse Engineering a Flutter Mobile App: A Complete Security Research Methodology, documents exactly what each of these defenses is designed to prevent.