🌙
☀️ Dark

Volume 8: Security & Production

Advanced ⏱ 20 min read

Volume 8: Security & Production Engineering

Learning Objectives

Why Does This Exist?

If you build an app that accesses a paid API (like OpenAI or Stripe) and you hardcode the API key in your Dart file, you have made a catastrophic mistake. When you publish the app to the Google Play Store, malicious actors can download your APK, decompile it, extract your API keys, and rack up a $50,000 bill on your credit card in a matter of hours.

The Problem Before the Solution

Beginners often think that because Dart compiles to binary, human-readable code is gone forever. This is false. Reverse-engineering tools can easily inspect the strings embedded in your compiled binary. Any string you hardcode (const apiKey = 'sk-12345';) is completely visible to an attacker.

Mental Model: The Locked Safe and The Vault

If you need to store the user's preferred theme (Dark/Light), you can leave a sticky note on the fridge (SharedPreferences). If an attacker breaks in and reads the sticky note, who cares?

But if you need to store the user's JWT authentication token, you must put it in the Titanium Vault (flutter_secure_storage). This package leverages Android's EncryptedSharedPreferences (KeyStore) and iOS's Keychain. Even if an attacker roots the phone and steals the physical storage file, they cannot decrypt it without the hardware-backed encryption keys physically fused into the phone's processor.

Internal Working: Code Obfuscation

When you build for release, you should use code obfuscation. Obfuscation takes your beautifully named classes like CreditCardProcessor and renames them to meaningless symbols like a1, b2, etc.

It does not encrypt your code, but it makes it incredibly difficult for a human attacker to reverse-engineer your business logic.

Syntax: Securing API Keys via Environment Variables

Never commit API keys to GitHub. Use a .env file (which is ignored by git) and inject the variables at compile time using the --dart-define flag, or use packages like flutter_dotenv or envied.

dart
// Using the envied package to obfuscate the key into the binary
import 'package:envied/envied.dart';

part 'env.g.dart';

@Envied(path: '.env')
abstract class Env {
  @EnviedField(varName: 'STRIPE_API_KEY', obfuscate: true)
  static final String stripeKey = _Env.stripeKey;
}

// Usage in code:
final key = Env.stripeKey; // Safe from simple string extraction!

Production Engineering: Observability

When the app is on your user's phone, and it crashes, the screen goes black or closes. The user doesn't get a stack trace. They just leave a 1-star review.

You must implement Crash Reporting (e.g., Firebase Crashlytics or Sentry). When an uncaught exception occurs, these tools automatically catch the error, gather device data (OS version, RAM state), and quietly upload the stack trace to your dashboard.

dart
// Catching errors in Flutter
void main() {
  // Catch Flutter UI framework errors
  FlutterError.onError = (errorDetails) {
    FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
  };

  // Catch all other asynchronous Dart errors
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(MyApp());
}

Break It & Debug It

The Bug: You release the app. Users report crashes. You open Firebase Crashlytics, but the stack traces look like gibberish: Exception at a.b.c(d) line 42.

The Reason: You successfully obfuscated your code before releasing it! But now Crashlytics doesn't know what a.b.c is either.

The Fix: When you compile with obfuscation, Flutter generates a mapping file (symbols). You must upload this mapping file to Crashlytics so it can "de-obfuscate" the stack trace back into readable Dart code for your eyes only.

🔧 Mini Project ⏱ 20 min

Goal: The Secure Storage Vault

Use the flutter_secure_storage package to store sensitive data (API token, user ID) encrypted in the device keychain (iOS) / keystore (Android). Build a UI: TextField to enter a token, Save button, Display Saved Token button (retrieves and shows), Delete button. Show what happens when you uninstall and reinstall (data is gone on Android, may persist on iOS keychain).

💡 See One Approach (Mini Project)

One valid solution — yours may differ.

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

void main() => runApp(const MaterialApp(home: SecureVaultScreen()));

class SecureVaultScreen extends StatefulWidget {
  const SecureVaultScreen({super.key});
  @override
  State createState() => _SecureVaultScreenState();
}

class _SecureVaultScreenState extends State {
  // iOS: uses Keychain. Android: uses EncryptedSharedPreferences (AES-256)
  final _storage = const FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );
  final _tokenCtrl = TextEditingController();
  String? _displayedToken;
  static const _kTokenKey = 'auth_token';

  Future _save() async {
    final token = _tokenCtrl.text.trim();
    if (token.isEmpty) return;
    await _storage.write(key: _kTokenKey, value: token);
    _tokenCtrl.clear();
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Token saved securely ✅'), backgroundColor: Colors.green),
    );
  }

  Future _read() async {
    final token = await _storage.read(key: _kTokenKey);
    setState(() => _displayedToken = token ?? '(no token stored)');
  }

  Future _delete() async {
    await _storage.delete(key: _kTokenKey);
    setState(() => _displayedToken = null);
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Token deleted')),
    );
  }

  @override
  void dispose() { _tokenCtrl.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Secure Vault')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
          TextField(
            controller: _tokenCtrl,
            decoration: const InputDecoration(labelText: 'API Token', border: OutlineInputBorder()),
            obscureText: true,
          ),
          const SizedBox(height: 12),
          FilledButton(onPressed: _save, child: const Text('Save Securely')),
          const SizedBox(height: 8),
          OutlinedButton(onPressed: _read, child: const Text('Retrieve Token')),
          const SizedBox(height: 8),
          TextButton(onPressed: _delete, child: const Text('Delete Token', style: TextStyle(color: Colors.red))),
          if (_displayedToken != null) ...[
            const Divider(height: 32),
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(color: Colors.green.shade50, borderRadius: BorderRadius.circular(8)),
              child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
                const Text('Retrieved (would hide in production):', style: TextStyle(fontWeight: FontWeight.bold)),
                const SizedBox(height: 8),
                Text(_displayedToken!, style: const TextStyle(fontFamily: 'monospace')),
              ]),
            ),
          ],
        ]),
      ),
    );
  }
}

🏗 Bigger Project ⏱ 1.5 hrs

Goal: The Obfuscated Release Build

Configure your Flutter app for production release. Create a config/ directory with environment configs. Hide all API keys using --dart-define (NOT .env files). Set up code obfuscation in the build command. Create a release_checklist.md that documents: obfuscation command, ProGuard rules for Android, ATS (App Transport Security) config for iOS, certificate pinning concept, and how to verify the built binary doesn't contain plaintext secrets using strings command.

💡 See One Approach (Bigger Project)

One valid solution — yours may differ.

bash
// lib/config/app_config.dart — Reads compile-time constants
// Build with: flutter build apk --dart-define=API_KEY=abc123 --dart-define=BASE_URL=https://api.example.com

class AppConfig {
  // These are injected at COMPILE TIME — not readable from the binary
  // (unlike .env files which are bundled as plaintext assets)
  static const apiKey = String.fromEnvironment('API_KEY', defaultValue: '');
  static const baseUrl = String.fromEnvironment('BASE_URL', defaultValue: 'http://localhost:3000');
  static const isProduction = bool.fromEnvironment('PRODUCTION', defaultValue: false);

  static void validate() {
    assert(apiKey.isNotEmpty, 'API_KEY must be provided via --dart-define');
    assert(baseUrl.startsWith('https://') || !isProduction,
        'Production must use HTTPS');
  }
}

// Release build commands:
// Android:
// flutter build appbundle \\
//   --dart-define=API_KEY=secret123 \\
//   --dart-define=BASE_URL=https://api.example.com \\
//   --dart-define=PRODUCTION=true \\
//   --obfuscate \\
//   --split-debug-info=./debug_symbols
//
// iOS:
// flutter build ipa \\
//   --dart-define=API_KEY=secret123 \\
//   --obfuscate \\
//   --split-debug-info=./debug_symbols
//
// To verify no secrets in binary:
// strings build/app/outputs/apk/release/app-release.apk | grep -i secret
// (should return nothing)

class CertificatePinner {
  // Certificate pinning: only accept connections to servers
  // with a specific certificate, preventing MITM attacks.
  // In production, use `dio_certificate_pinning` package.
  static const List pinnedCertHashes = [
    'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // Replace with real hash
  ];
  // Generate hash: openssl s_client -connect api.example.com:443 | openssl x509 -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
}

🎯 Interview Questions

Answer these before revealing. These appear in real Flutter/Dart interviews.

🔍 Easy: Why should API keys never be stored in Flutter's assets/ folder or in a .env file bundled with the app?

Assets and bundled files are trivially extractable from an APK/IPA. Any attacker can run apktool d app.apk or use a file explorer on a rooted device to read all bundled files as plaintext. API keys found this way can be used to drain your API quota, access your backend, or incur cloud costs. The correct approach: use --dart-define to inject constants at compile time (they are embedded in compiled Dart bytecode, much harder to extract), or better yet, never put secrets in the mobile app — use a backend proxy that authenticates users before calling sensitive APIs.

🔍 Medium: Explain Flutter's code obfuscation (--obfuscate) and what --split-debug-info does. What are its limitations?

--obfuscate renames Dart symbols (class names, method names, variable names) to meaningless short names in the compiled binary. --split-debug-info=<path> saves the mapping from obfuscated → original names to a separate file on your machine. This makes reverse engineering harder — a decompiled binary shows a.b() instead of UserAuthService.authenticate(). Limitations: it doesn't encrypt code, doesn't prevent runtime inspection on rooted devices, doesn't protect hardcoded strings (those remain in plaintext), and it BREAKS crash reporting (stack traces show obfuscated names). Fix: upload the debug symbols to Crashlytics/Sentry so they can de-obfuscate crash reports automatically.

🔍 Hard: Explain how you would implement biometric authentication in a Flutter app and the security considerations involved.

Use the local_auth package: call auth.authenticate(localizedReason: '...') which triggers Face ID/Touch ID/fingerprint. Important security considerations: 1. Biometrics authenticate locally — the biometric template never leaves the device and is never sent to your server. 2. After successful biometric auth, retrieve a secret from flutter_secure_storage (the actual token) — don't use biometrics as the token itself. 3. Handle PlatformException for: not enrolled (no fingerprint set up), lockout (too many failures), passcode not set. 4. On Android, distinguish between strongBiometrics (hardware-backed) and weak (face recognition on budget devices that can be spoofed by a photo). 5. Always provide a fallback (PIN/password) since biometrics can fail (wet fingers, Face ID in the dark). 6. On rooted devices, biometric APIs can be bypassed — if your app handles financial data, check for root (root_jailbreak_checker) and refuse to run.

✅ I can obfuscate a Flutter app, secure local storage, manage certificates, and understand the App Store review process.