Skip to content
ExponaBLOG
FLUTTER · DAY 092 / 100

Mobile app security: Flutter best practices explained

Protect users and your backend.

Expona Admin8 min read

By day 92 of your Flutter journey, you've likely built features users love. But if you haven't hardened your app against security threats, you're putting user data at risk. Mobile app security: Flutter best practices explained is not optional—it's foundational. We'll walk you through the concrete patterns your team should implement before shipping to production.

Why mobile security matters in Flutter

Flutter apps run on devices you don't control. Unlike backend services protected by firewalls and access controls, your mobile app exists in an environment where attackers can inspect network traffic, reverse engineer code, and manipulate local storage. The risk multiplies when your app handles authentication tokens, payment data, or personal information.

The good news: Flutter gives you access to platform-native security APIs. The challenge: you must consciously use them. Many teams skip security during rapid development, then retrofit it later—a costly mistake. We'll show you how to bake these practices in from the start.

Secure storage: Tokens in Keychain and Keystore

Your first critical decision: where do you store authentication tokens? Storing them in SharedPreferences or plain files is a common rookie error. These are readable by anyone with device access or via backup tools.

Instead, use platform-native secure storage:

  • iOS: Keychain stores encrypted data, accessible only to your app (or apps sharing the same keychain group).
  • Android: Keystore provides hardware-backed encryption when available, software-backed on older devices.

Flutter doesn't have a built-in secure storage API, so we rely on plugins. The industry standard is flutter_secure_storage:

dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const storage = FlutterSecureStorage();

// Store token
await storage.write(
  key: 'auth_token',
  value: token,
  aOptions: _getAndroidOptions(),
  iOptions: _getIOSOptions(),
);

// Retrieve token
final token = await storage.read(key: 'auth_token');

// Delete on logout
await storage.delete(key: 'auth_token');

AndroidOptions _getAndroidOptions() => const AndroidOptions(
  keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_OAEPwithSHA_256andMGF1Padding,
  storageCipherAlgorithm: StorageCipherAlgorithm.AES_GCM_NoPadding,
  resetOnError: true,
);

IOSOptions _getIOSOptions() => const IOSOptions(
  accessibility: KeychainAccessibility.first_available_when_unlocked_this_device_only,
);

The accessibility setting is crucial on iOS. first_available_when_unlocked_this_device_only ensures the token is unavailable when the device is locked, protecting against unauthorized access if the device is lost.

On Android, the key cipher algorithm uses RSA asymmetric encryption to wrap a symmetric key, which then encrypts your data. This leverages the secure enclave when available.

Real-world scenario: Your ride-hailing app stores user location tokens. A malicious actor gains temporary physical access to a user's unlocked Android phone. Without encryption, they extract the token and impersonate the user. With Keystore, the token remains encrypted and unusable without the device's master key.

Certificate pinning: Prevent man-in-the-middle attacks for sensitive apps

Network traffic between your app and backend travels over HTTPS. But attackers on the same WiFi network can intercept and inspect it—or worse, inject malicious responses—if they compromise the certificate chain. Certificate pinning locks your app to a specific certificate or public key, making such attacks far harder.

There are two approaches:

  • Certificate pinning: Pin the full certificate or its public key.
  • Public key pinning: Pin only the public key, allowing certificate renewal without app updates.

For most teams, public key pinning is preferable because certificates expire. Use the dio package with dio_smart_retry or http_certificate_pinning:

dart
import 'package:dio/dio.dart';
import 'package:http_certificate_pinning/http_certificate_pinning.dart';

Future<void> setupHttpClient() async {
  final dio = Dio();
  
  // Extract public key from certificate
  final certificates = [
    'your-certificate-public-key-in-pem-format',
  ];
  
  dio.httpClientAdapter = HttpClientAdapter()
    ..onHttpClientCreate = (client) {
      client.badCertificateCallback = (cert, host, port) {
        // Use the certificate pinning library
        return validateCertificateChain(
          certificates: certificates,
          host: host,
        );
      };
      return client;
    };
}

Future<bool> validateCertificateChain({
  required List<String> certificates,
  required String host,
}) async {
  try {
    await HttpCertificatePinning.check(
      serverURL: 'https://$host',
      headerClient: 'dio',
      allowedSHAFingerprints: certificates,
    );
    return true;
  } catch (e) {
    return false;
  }
}

Store your certificate fingerprints as constants. Update them before they expire—set calendar reminders for 30 days before expiration. If a certificate is compromised, you can force an app update to pin the new certificate.

Real-world scenario: Your fintech app communicates with a payment processor. An attacker on a corporate WiFi network tries to intercept authentication calls. Without pinning, they might forge a certificate and redirect traffic to a fake server. With pinning, the TLS handshake fails immediately because the public key doesn't match.

Obfuscation: Make reverse engineering harder

Flutter compiles to native code, but it's not immune to reverse engineering. The Dart snapshot contains type information and method names that reveal your app's logic. Obfuscation renames classes, methods, and variables to meaningless symbols, making the reverse-engineered code harder to understand.

Enable obfuscation for release builds:

dart
flutter build apk --obfuscate --split-debug-info=build/app/outputs/symbols
flutter build ios --obfuscate --split-debug-info=build/app/outputs/symbols

The --split-debug-info flag separates symbol information into a separate directory, so you keep source maps for crash reporting without shipping them in the app.

For Play Store uploads, use app signing to ensure Google Play signs your APK. This proves to Android that only you can update your app:

dart
flutter build appbundle --obfuscate --split-debug-info=build/app/outputs/symbols

However, obfuscation is not encryption. A determined attacker can still decompile your app. Use it as one layer in defense-in-depth, not as your only protection. Never hardcode API keys, encryption secrets, or sensitive URLs—always fetch them from your backend or use environment-specific configurations.

Real-world scenario: Your e-commerce app applies a 50% discount via a hidden code in the checkout flow. Without obfuscation, reverse engineering reveals the logic instantly. With obfuscation, the effort to uncover it increases enough to deter casual attackers. Combined with server-side validation (see next section), you prevent discount abuse entirely.

Never trust the client: Validate everything on the server

This is the golden rule. No matter how secure your client is, never assume user input is honest. A determined attacker can modify your app, send custom requests, or replay tokens. Your backend must validate every decision.

Examples:

  • Authentication: Verify the token signature and expiry server-side, every request. Don't trust the decoded JWT claims from the client.
  • Authorization: Check that the authenticated user has permission to access the resource, every time.
  • Business logic: If a discount applies, recalculate it server-side instead of trusting the client's calculated price.
  • Rate limiting: Apply server-side rate limits, not client-side ones that can be easily bypassed.

Here's a pattern for a sensitive payment endpoint:

dart
// Client sends request
Future<void> processPayment({
  required String orderId,
  required double amount,
  required String token,
}) async {
  final response = await dio.post(
    'https://api.yourapp.com/payments',
    data: {
      'order_id': orderId,
      'amount': amount,
      'token': token,
    },
  );
  
  if (response.statusCode != 200) {
    throw PaymentException('Payment failed');
  }
}

// Server validates
app.post('/payments', authenticate, (req, res) => {
  const orderId = req.body.order_id;
  const clientAmount = req.body.amount;
  const userId = req.user.id; // From authenticated token
  
  // 1. Verify token signature (in authenticate middleware)
  // 2. Fetch order from database
  const order = db.orders.findById(orderId);
  if (!order || order.user_id !== userId) {
    return res.status(403).json({ error: 'Unauthorized' });
  }
  
  // 3. Recalculate the correct price server-side
  const correctAmount = calculateOrderTotal(order);
  if (clientAmount !== correctAmount) {
    return res.status(400).json({ error: 'Amount mismatch' });
  }
  
  // 4. Check order status hasn't changed
  if (order.status !== 'pending') {
    return res.status(409).json({ error: 'Order already processed' });
  }
  
  // 5. Proceed with payment
  processPaymentGateway(order, correctAmount);
});

The principle applies everywhere: geolocation for ride-hailing, item inventory for e-commerce, user rankings in games. Always recompute on the server.

Common mistakes

  • Storing tokens in SharedPreferences: SharedPreferences is not encrypted. Use flutter_secure_storage exclusively for sensitive data.
  • Trusting client-calculated totals or discounts: Always recalculate on the server. Attacker-modified APKs can send fake values.
  • Skipping certificate pinning because "HTTPS is enough": HTTPS prevents passive eavesdropping but not active man-in-the-middle attacks on compromised networks. Pinning adds a critical layer.
  • Hardcoding API keys in code: Even obfuscated, reverse engineering can extract them. Use environment-specific config files or backend-provided secrets.
  • Not rotating certificates before expiry: Plan certificate updates well in advance. Expired pins will break your app in production.
  • Assuming obfuscation is encryption: It's not. It's a speed bump. Implement it, but don't rely on it alone.
  • Logging sensitive data in release builds: Strip logging before shipping. Use debug-only log statements via conditional compilation.

Key takeaways

  • Use platform-native secure storage (Keychain on iOS, Keystore on Android) via flutter_secure_storage for authentication tokens. Never use SharedPreferences.
  • Implement public key pinning for sensitive apps to prevent man-in-the-middle attacks on compromised networks.
  • Enable obfuscation in release builds to make reverse engineering harder, but treat it as one layer in defense-in-depth, not a complete solution.
  • Never trust client input. Validate, recalculate, and re-authorize everything on the server for every request.
  • Plan certificate rotation before expiry and set reminders 30 days in advance to avoid production outages.
  • Extract debug symbols separately so crash reporting works without shipping source maps in production.
  • Review your app's permissions in the manifest; request only what you need and explain why to users.

Security is not a feature you ship once—it's a practice you maintain. On day 92, commit to these patterns now, and your users will stay safe as you scale.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles