Authentication & Security

Protecting API Keys & Environment Secrets

16 min Lesson 9 of 12

Protecting API Keys & Environment Secrets

Every Flutter app that communicates with external services — maps, analytics, payment gateways, push-notification providers — requires some kind of credential. Handling those credentials carelessly is one of the most common and most consequential security mistakes in mobile development. This lesson explains why secrets are hard to protect in compiled Dart and shows the two canonical techniques for keeping them out of version control and away from attackers.

Why Dart Source and Assets Are Extractable

A Flutter release build compiles Dart to native ARM/x86 machine code via the Dart AOT compiler, but that does not make secrets safe. Several vectors allow an attacker to recover strings you embed directly in code:

  • String table extraction: Dart strings that appear as literals are stored in the compiled snapshot. Tools like strings, radare2, or jadx can dump the string table of any APK or IPA in seconds.
  • Asset extraction: Any file placed in the assets/ folder — including .env files added to pubspec.yaml — is bundled unencrypted inside the app archive. An attacker simply renames the APK to .zip and reads the file directly.
  • Network traffic inspection: Even if a key is obfuscated in the binary, it will appear in plain text in network requests unless you also enforce certificate pinning.
  • Version control leaks: Committing a file that contains a secret exposes it to anyone who has read access to the repository, including CI runners, third-party integrations, and future contributors.
Warning: There is no 100% secure way to embed a secret in a client-side app. The goal of this lesson is to reduce risk: keep secrets out of version control, out of the plaintext string table, and behind server-side validation whenever possible. For truly sensitive credentials (payment keys, admin tokens), proxy calls through your own backend and never ship the raw secret in the app at all.

Technique 1 — --dart-define at Build Time

The --dart-define flag passes a key-value pair into the Dart compilation. The value is baked into the compiled binary but is never written to any source file, so it cannot be accidentally committed to version control. The value is also slightly less discoverable than a plain string literal because it does not sit in the obvious string table location, though a determined attacker can still find it with binary analysis tools.

Passing and Reading a --dart-define Value

// Build command (CI, Fastlane, or terminal):
// flutter build apk --dart-define=MAPS_API_KEY=AIzaSyXXXXXXXXXX
// flutter build ios --dart-define=MAPS_API_KEY=AIzaSyXXXXXXXXXX

// Reading the value in Dart source:
class AppSecrets {
  // fromEnvironment is resolved at COMPILE time, not at runtime.
  // The default ('') is used when no --dart-define was supplied,
  // e.g. during `flutter run` without the flag.
  static const String mapsApiKey =
      String.fromEnvironment('MAPS_API_KEY', defaultValue: '');

  static const String stripePublishableKey =
      String.fromEnvironment('STRIPE_PK', defaultValue: '');
}

// Usage anywhere in the app:
void initMaps() {
  if (AppSecrets.mapsApiKey.isEmpty) {
    throw StateError('MAPS_API_KEY was not provided at build time');
  }
  GoogleMapsFlutter.init(AppSecrets.mapsApiKey);
}
Tip: Store your --dart-define values as encrypted secrets in your CI/CD provider (GitHub Actions Secrets, Bitrise Secrets, Codemagic Environment variables). Your pipeline injects them at build time and no developer ever sees the raw value in a file on disk.

For projects with many defines, pass a JSON file using --dart-define-from-file=secrets.json (available since Flutter 3.7). Keep that file in .gitignore — commit only a secrets.example.json with placeholder values.

Using --dart-define-from-file (Flutter 3.7+)

// secrets.json  (NEVER commit this file — add it to .gitignore)
// {
//   "MAPS_API_KEY": "AIzaSyXXXXXXXXXX",
//   "STRIPE_PK": "pk_live_XXXXXXXXXX",
//   "SENTRY_DSN": "https://abc@o123.ingest.sentry.io/456"
// }

// secrets.example.json  (DO commit this as a template)
// {
//   "MAPS_API_KEY": "YOUR_MAPS_API_KEY_HERE",
//   "STRIPE_PK": "YOUR_STRIPE_KEY_HERE",
//   "SENTRY_DSN": "YOUR_SENTRY_DSN_HERE"
// }

// Build command:
// flutter run --dart-define-from-file=secrets.json
// flutter build apk --dart-define-from-file=secrets.json

// Reading is identical to individual --dart-define:
static const String sentryDsn =
    String.fromEnvironment('SENTRY_DSN', defaultValue: '');

Technique 2 — flutter_dotenv at Runtime

The flutter_dotenv package reads a standard .env file from the app's asset bundle at runtime. This approach is familiar to web and backend developers but carries an important caveat: the .env file is a plain-text asset inside the APK/IPA. It keeps secrets out of your source code and version control, but an attacker who extracts the archive can read the file directly.

Use flutter_dotenv when you need different values per environment (dev / staging / prod) without rebuilding, or when your team is already accustomed to the dotenv workflow. For production secrets that must not be exposed, prefer --dart-define injected by CI, or move the secret to your backend entirely.

Setting Up flutter_dotenv

// 1. Add dependency: flutter pub add flutter_dotenv

// 2. Create .env in the project root (add to .gitignore!)
//    MAPS_API_KEY=AIzaSyXXXXXXXXXX
//    BASE_URL=https://api.myapp.com
//    FEATURE_FLAG_CHAT=true

// 3. Register the asset in pubspec.yaml:
//    flutter:
//      assets:
//        - .env

// 4. Load in main.dart before runApp:
import 'package:flutter_dotenv/flutter_dotenv.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: '.env');
  runApp(const MyApp());
}

// 5. Read values anywhere:
final apiKey = dotenv.env['MAPS_API_KEY'] ?? '';
final baseUrl = dotenv.env['BASE_URL'] ?? 'https://localhost';
final chatEnabled = dotenv.env['FEATURE_FLAG_CHAT'] == 'true';
Note: Always add .env to .gitignore before you write any secrets into it. Create a committed .env.example file that lists every key with a blank or placeholder value so new developers know what to populate.

Choosing Between the Two Approaches

  • Use --dart-define when secrets must never be in any file on disk — value lives only in CI secrets and the compiled binary.
  • Use flutter_dotenv when you need easy per-environment switching without rebuilding, or for non-sensitive configuration (feature flags, base URLs, log levels).
  • Use neither for truly sensitive credentials such as server-side API keys, OAuth client secrets, or payment processing keys — proxy those through your own backend and authenticate the app with a short-lived token instead.

Summary

Dart AOT compilation does not meaningfully protect string literals from extraction. The two primary defenses are --dart-define (compile-time injection, keeps secrets in CI) and flutter_dotenv (runtime asset loading, keeps secrets out of source control but not out of the APK). For truly sensitive credentials, the only safe approach is a backend proxy. Always commit .env.example and secrets.example.json so teammates can onboard without seeing real secrets.