FLUTTER SECURITY
How to test a Flutter app for security vulnerabilities
Flutter gives you one codebase for iOS and Android — and one set of security mistakes shipped to both. Most of what goes wrong in a Flutter release is not exotic: a token in the wrong storage bucket, an unobfuscated release build, a backend endpoint that never checks who is asking. This guide walks the checks in the order that finds the most problems fastest.
1. Start where the secrets live
The single most common Flutter finding is sensitive data in SharedPreferences.
It is convenient, it is the first thing most tutorials reach for, and it is
unencrypted plain text on both platforms. Anyone with filesystem access to the
device — including any app running as the same user on a rooted or jailbroken phone — can read it.
What to check
- Auth tokens, refresh tokens, and session identifiers use
flutter_secure_storage(which maps to the iOS Keychain and Android Keystore), notSharedPreferences. - No credentials or API keys are hard-coded as Dart string constants.
- Logout actually clears stored tokens — not just the in-memory copy.
- If you use a local SQLite database for anything personal, it is encrypted
(
sqflite_sqlcipher) rather than a plain.dbfile.
How to verify it rather than assume it
Read the app's own data directory on a debug build and look for your own token. On Android:
adb shell run-as com.example.yourapp \
ls -R /data/data/com.example.yourapp
# Then dump anything promising and grep for a token you know:
adb shell run-as com.example.yourapp \
cat /data/data/com.example.yourapp/shared_prefs/FlutterSharedPreferences.xml
If you can see your own session token in that output, so can an attacker with device access. That is the finding — write it down and move on, there will be others.
2. Put a proxy in the middle
Next, watch what the app actually sends. Route the device through an intercepting proxy (Burp Suite, mitmproxy, or Charles) with its CA certificate installed, then exercise every screen that talks to the network — login, password reset, payments, file upload, profile edit.
What you are looking for
- Every request is HTTPS. A single
http://call is a finding, even to a "harmless" analytics or config endpoint. - No passwords, tokens, or personal data appear in URL query strings — those land in server logs, proxy logs, and browser history.
- Error responses do not return stack traces, SQL fragments, or internal hostnames.
- Tokens are sent in an
Authorizationheader, not a custom header or a cookie without theSecureandHttpOnlyflags.
Then test the opposite
Here is the check most teams get backwards. If your proxy can read the traffic with its CA installed, that is expected. The real question is whether certificate pinning stops it. Remove the proxy CA from the device's trust store and try again: a properly pinned app should fail to connect. If requests still succeed, you have no effective pinning, and anyone who can get a certificate onto the device can read everything.
3. Check what your release build is actually doing
Debug builds leak by design. The question is whether your release pipeline strips that back out. Build the way you ship, then verify:
flutter build apk --release \
--obfuscate --split-debug-info=build/symbols
flutter build ipa --release \
--obfuscate --split-debug-info=build/symbols
The --obfuscate flag is not on by default, and a surprising number of shipped
Flutter apps do not use it. Keep the --split-debug-info output somewhere safe —
you need those symbol files to read production crash reports.
--obfuscateis part of your actual release command, not just documentation.debuggableis false in the production Android manifest.- Debug
print()calls that log tokens, request bodies, or PII are gone. - No test or staging URLs remain reachable in the production binary.
4. Read your own binary
Unzip the APK and run strings over the Flutter library. You are not doing a deep
reverse-engineering exercise — you are checking whether your secrets are sitting in plain sight:
unzip -o app-release.apk -d apk-out
strings apk-out/lib/arm64-v8a/libapp.so | \
grep -iE 'api[_-]?key|secret|password|bearer'
Any API key you find here is compromised the moment you publish. This matters more in Flutter than people expect: a key compiled into Dart feels "inside the app", but an APK is just a zip file that anyone can download from the store and open.
If you find one, the fix is architectural, not cosmetic — obfuscation will not save a key that has to be sent to a server anyway. Move the privileged call behind your own backend and let the server hold the credential.
5. Test the backend, because that is where the real risk is
This is the step that gets skipped, and it is the one that matters most. Your Flutter app is a
client. An attacker does not have to use it — they can read your API from the proxy logs in step 2
and then call it directly with curl, with no app, no UI, and none of your
client-side validation.
So every rule you enforce in Dart has to be enforced again on the server. Two checks find most of the damage:
Broken access control
Log in as user A, capture a request that fetches your own data, then change the identifier to user B's and replay it with user A's token:
# Your own record — expected 200
curl -H "Authorization: Bearer $TOKEN_A" \
https://api.example.com/v1/users/1001/profile
# Someone else's record — this MUST be 403, not 200
curl -H "Authorization: Bearer $TOKEN_A" \
https://api.example.com/v1/users/1002/profile
A 200 on the second request is broken object-level authorization, and it is the
most commonly exploited API flaw there is. Try the same trick on admin-only routes with a regular
user's token.
Missing authentication
Replay the same requests with the Authorization header removed entirely. Endpoints
that return data to an unauthenticated caller are usually an oversight in routing or middleware,
and they are trivial to find — which means someone else will find them too.
6. Close out the platform settings
- Android: the manifest requests only permissions you actually use,
allowBackupis false if you store anything sensitive, and no components are exported unintentionally. - iOS: App Transport Security is not disabled with a blanket
NSAllowsArbitraryLoadsexception, and credentials go to the Keychain. - Both: run
flutter pub outdatedand update packages with known advisories. Your dependency tree is part of your attack surface.
A realistic order of operations
If you only have an afternoon: read your own storage (step 1), proxy your traffic (step 2), and test your backend for broken access control (step 5). Those three find the large majority of real, exploitable problems. Build hardening and binary inspection matter, but they raise the cost of an attack rather than closing an open door.
The harder part is not running the checks once — it is re-running them every release and keeping track of what you already fixed. That is the gap the toolkit below is built to fill.