← All posts

7 min read

A Practical Mobile App Security Checklist for Flutter Developers

A practical mobile app security checklist for Flutter: keeping secrets off the device, secure storage, pinning trade-offs, obfuscation limits and server-side checks.

  • Flutter
  • Security
  • Mobile
Cover illustration for A Practical Mobile App Security Checklist for Flutter Developers

Here’s the uncomfortable starting point for mobile security: your app runs on a device you don’t control, owned by someone who might be actively trying to take it apart. Every file in the bundle, every string in the binary and every request on the wire can be inspected by a motivated person with free tools and a spare afternoon.

That doesn’t mean security is hopeless. It means you have to be honest about what the app can protect and what only the server can protect. Most of the security problems I see in Flutter projects come from getting that line wrong.

This is the checklist I go through on apps I build or take over.

1. Secrets don’t belong in the app

If it ships in the app, assume it’s public. That includes:

  • Hardcoded API keys in Dart files.
  • Values in .env files bundled as assets.
  • Values passed with --dart-define or --dart-define-from-file.

--dart-define is a great way to keep config out of source control and switch between environments. It is not a way to hide secrets. The values are compiled into the binary, and a strings dump will find them.

So what about keys the app genuinely needs?

  • Publishable keys are fine. A Stripe publishable key, a Firebase config, a Google Maps key restricted to your app’s package name and signing certificate: these are designed to live on clients. Restrict them in each provider’s console.
  • Secret keys never leave the server. Stripe secret keys, database credentials, admin tokens, third-party API keys with billing attached. If the app needs something from that service, the app calls your backend and the backend calls the service.

If you find a secret in an app that’s already shipped, removing it from the next release isn’t enough. Old builds are still out there. Rotate the key.

2. Store sensitive data in secure storage

Tokens and other sensitive values go in flutter_secure_storage (Keychain on iOS, Keystore-backed encryption on Android), not SharedPreferences, which is a plain file.

Also think about what you store at all. Does the app really need to cache the user’s full profile, including their address, in a local database? Data you don’t keep can’t leak. For local databases holding genuinely sensitive data, consider an encrypted option such as SQLCipher via the relevant packages.

I cover the token side in detail in production-ready authentication in Flutter.

3. HTTPS only, and think carefully about pinning

All traffic goes over HTTPS. Both platforms push you this way already: iOS App Transport Security and Android’s default network security config block cleartext HTTP on modern targets. Don’t add exceptions to “fix” a dev server and forget to remove them.

Certificate pinning goes a step further: the app only trusts specific certificates or public keys, not any certificate signed by a trusted CA. It defends against interception on compromised networks and makes casual traffic inspection harder.

In Dart you can pin by creating an HttpClient with a SecurityContext that trusts only your certificate:

Future<HttpClient> pinnedClient() async {
  final cert = await rootBundle.load('assets/certs/api.pem');
  final context = SecurityContext(withTrustedRoots: false)
    ..setTrustedCertificatesBytes(cert.buffer.asUint8List());
  return HttpClient(context: context);
}

The trade-offs are real, though:

  • Certificate rotation can brick your app. If the server certificate changes and the app only trusts the old one, every installed copy stops working until users update. Pin to a key you control, include a backup pin, and coordinate rotations with your backend team.
  • It doesn’t stop a determined attacker on their own rooted device. They can patch the check out.
  • It complicates debugging with proxy tools.

My default: HTTPS everywhere, and pinning only for apps where the threat model justifies the operational cost (finance, health, high-value accounts).

4. Obfuscate, but know what it buys you

Flutter release builds compile Dart to native machine code, which is already harder to read than JavaScript or Java bytecode. You can go further:

flutter build appbundle --obfuscate --split-debug-info=build/symbols
flutter build ipa --obfuscate --split-debug-info=build/symbols

This renames classes and functions in the output and moves debug symbols out of the binary. Keep the build/symbols folder for every release, because you need it to de-obfuscate crash stack traces. I explain how that fits into crash tooling in crash reporting and monitoring in Flutter.

What obfuscation doesn’t do: hide string literals, hide your API endpoints, or stop someone determined from understanding your logic. It raises the cost of reverse engineering. It doesn’t make anything secret.

5. Server-side authorization is the real protection

This is the most important item on the list, and it isn’t in the app at all.

Anything the app enforces can be bypassed. Hiding the “Delete” button for non-admins is UX, not security. If the API endpoint behind that button doesn’t check the caller’s role, anyone can call it directly.

For every endpoint, the server should check:

  • Who is calling? A valid, unexpired token.
  • Are they allowed to do this? Role and permission checks.
  • Are they allowed to do this to this record? The bug I see most often is an endpoint that checks the user is logged in, then happily returns /orders/1234 regardless of who owns order 1234. Filter by owner or tenant on every query.
  • Are the values sane? Prices, quantities and amounts come from the server’s own data, never from the request body.

If the backend gets this right, a fully compromised app can only do what that user was already allowed to do.

6. Root and jailbreak detection: a signal, not a wall

Packages exist that check for rooted or jailbroken devices. They’re useful for raising friction or flagging risk, but every check runs on the attacker’s device, so every check can be hooked or patched.

I treat them as one input. A banking app might refuse to run on a rooted device as policy. Most apps should not block legitimate users who happen to root their phones, and none should rely on detection for actual protection.

7. Use platform attestation as a signal for your server

Play Integrity on Android and App Attest on iOS let your server ask the platform, roughly, “is this request coming from my genuine app on a real device?” The important part is that the verdict is checked on the server, not in the app.

If you’re on Firebase, App Check wraps both and enforces them for Firebase services and your own endpoints. It’s a good way to cut down on scripted abuse of your APIs. It still isn’t a substitute for authorization, because a genuine app on a genuine device can still be used by a malicious user.

8. Logging hygiene

Logs are a surprisingly common leak:

  • Don’t log tokens, passwords, full request bodies or personal data. Turn off verbose HTTP logging interceptors in release builds.
  • Remember that print output is readable via system logs on connected devices.
  • Scrub breadcrumbs and custom keys sent to crash reporting tools. A crash report with an auth header attached is now a secret sitting in a third-party dashboard.
if (kDebugMode) {
  dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
}

9. Review your dependencies

Every package you add runs with your app’s full permissions. Before adding one:

  • Check its maintenance activity, publisher and popularity on pub.dev.
  • Look at what native permissions and SDKs it pulls in, especially ad and analytics SDKs, which affect your store privacy declarations too.
  • Commit pubspec.lock for apps so builds are reproducible.
  • Run flutter pub outdated regularly and update deliberately rather than all at once before a release.

10. Use OWASP MASVS as the reference

You don’t need to invent a security standard. The OWASP Mobile Application Security Verification Standard (MASVS) covers storage, cryptography, authentication, network, platform interaction, code quality and resilience. The companion testing guide (MASTG) explains how to verify each one. For most apps, I use it as a checklist to find gaps, not as a certification target.

Takeaways

  • Anything shipped in the app, including --dart-define values, is extractable. Keep secrets on the server.
  • Use secure storage for tokens, and store less sensitive data overall.
  • Use HTTPS everywhere; pin certificates only when the threat model justifies the rotation risk.
  • Obfuscation and root detection raise the cost of attack; they don’t prevent it.
  • Server-side authorization, checked per record, is what actually protects user data.
  • Keep logs clean, review dependencies, and use OWASP MASVS to find gaps.

If you’d like a security-minded review of a Flutter app before launch, or one you’ve just inherited, get in touch.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).