// Flutter · Security · Android

Your Flutter App's Secrets
Are Exposed — And You
Might Not Know It

👤 Rohan Kumar Chaudhary
📅 June 2025
10 min read
🏷 Flutter · Security · JADX · dart-define
"While auditing a production Flutter project, I discovered that anyone could download the APK from the Play Store, open it in a free decompiler, and read every API key, base URL, and credential — in under 60 seconds. No hacking skills required."

This is not a theoretical vulnerability. It's a pattern that ships in real production apps every day, used by teams who believe their secrets are safe because they added a package and followed its README. The package is flutter_dotenv. The README isn't lying — but it doesn't warn you about what ends up inside your APK.

In this post I'll show you exactly how the exposure happens, why the common fix (obfuscation) doesn't help, and the proper Flutter-native solution: --dart-define-from-file.

The Problem — What Ships in Your APK

If you're using flutter_dotenv and your pubspec.yaml looks like this:

pubspec.yaml — ⚠ VULNERABLE
flutter: assets: - assets/images/ - assets/svg_images/ - .env.dev # ← ships inside APK as plaintext - .env.prod # ← ships inside APK as plaintext
⚠ Critical
Every file listed under flutter: assets: is bundled as-is into the APK's assets folder. Your .env.prod file — with its API keys, base URLs, and credentials — is sitting in a readable directory inside your published app package.

And .env.prod typically looks like this:

.env.prod
APP_ENV=prod BASE_URL=https://api.yourservice.com API_KEY=sk-live-abc123secretkey FIREBASE_KEY=AIzaSyXXXXXXXXXXX

How the Attack Works — 60 Seconds

JADX is a free, open-source decompiler that converts APK/AAB/AAR files into readable Java/Kotlin source code. It's widely used by security researchers — and equally available to anyone with bad intent.

📥
Step 1
Download APK from Play Store
🔓
Step 2
Open in JADX GUI (drag & drop)
📁
Step 3
Navigate: Resources → assets → .env.prod
🗝
Step 4
Read every secret in plaintext

JADX supports APK, AAB, AAR, DEX, and ZIP files. It includes a GUI with syntax highlighting, full-text search, and a deobfuscator. The assets folder is not compiled code — it's a directory of files. JADX doesn't even need to decompile anything to read it.

Why Obfuscation Doesn't Help

The first instinct is to add --obfuscate to your build command. This renames classes, methods, and variables in the compiled Dart/Java bytecode. It makes decompiled code hard to read. But it has zero effect on asset files.

✗ What obfuscation affects
Dart class names
Method names
Variable names
Kotlin/Java bytecode

→ Code is harder to read
✗ What obfuscation does NOT affect
.env files in assets/
JSON files in assets/
Images in assets/
Any bundled file

→ Still fully readable

The .env file is not code — it's a data file. Obfuscation doesn't touch it. The file sits in the APK's assets/ directory exactly as you wrote it.

Why flutter_dotenv Requires This Pattern

flutter_dotenv loads environment variables at runtime from a file. For a file to be readable at runtime, it must be bundled as a Flutter asset. That's not a bug in the package — it's the only mechanism available. The package works exactly as designed.

main.dart — runtime loading = file must be in APK
// This line requires the file to already be bundled in the APK await dotenv.load(fileName: '.env.prod');

The root issue is the difference between runtime and build-time loading. Anything loaded at runtime must be present in the package. Anything injected at build-time is compiled into the binary and never exists as a separate readable file.

The Right Mental Model

Concern Runtime (flutter_dotenv) Build-time (dart-define)
When loaded App launch APK compilation
Location in APK assets/ folder — readable file Compiled into binary — no file
JADX extractable? ✅ Yes — plaintext ❌ No
Obfuscation helps? No N/A — not a file
Async init needed? Yes — await dotenv.load() No — compile-time const

The Fix — dart-define-from-file

Flutter has had first-class support for this since Flutter 3.7. The approach: your secrets live in JSON files on your machine and in your CI vault. They are injected at build time and compiled into the binary. The JSON file never touches the APK.

1 Create per-flavor config files

Create an env/ folder at your project root. These files are never committed to git.

project structure
project_root/ ├── env/ │ ├── dev.json ← gitignored, never committed │ ├── prod.json ← gitignored, never committed │ └── example.json ← commit this as reference for team ├── .vscode/ │ └── launch.json └── lib/ └── config/ └── get_env_config.dart
env/dev.json
{ "APP_ENV": "dev", "BASE_URL": "https://dev.api.yourservice.com" }
env/prod.json
{ "APP_ENV": "prod", "BASE_URL": "https://api.yourservice.com" }

2 Replace GetEnvConfig

✗ Before — flutter_dotenv
import 'package:flutter_dotenv/flutter_dotenv.dart';

class GetEnvConfig {
  GetEnvConfig._();

  static final String appEnvironment
    = dotenv.get('APP_ENV');

  static final String baseUrl
    = dotenv.get('BASE_URL');
}
✓ After — dart-define
// no import needed

class GetEnvConfig {
  GetEnvConfig._();

  static const String appEnvironment =
    String.fromEnvironment('APP_ENV',
      defaultValue: 'dev');

  static const String baseUrl =
    String.fromEnvironment('BASE_URL',
      defaultValue: 'http://localhost:3000');
}

Key differences: final becomes const — these are compile-time constants. No import. No async loading. The values are already baked into the binary at startup.

3 Remove _loadEnvironmentFile from MainScreen

abs_main_impl.dart — remove the dotenv init step
Future<void> init({required Flavor appFlavor}) async { await WidgetsFlutterBinding.ensureInitialized(); await _lockDeviceOrientation(); await _initializeFirebase(); // await _loadEnvironmentFile(appFlavor); ← DELETE — no longer needed await _configureDependencies(); await _initializeLocalDatabase(); await _initializeLocalization(); runApp(const MyApp()); }

4 Clean up pubspec.yaml

pubspec.yaml — before and after
# REMOVE these lines dependencies: flutter_dotenv: ^5.2.1 # ← remove flutter: assets: - assets/images/ - assets/svg_images/ # - .env.dev ← remove # - .env.prod ← remove
terminal
flutter pub remove flutter_dotenv flutter pub get

5 Update .gitignore

.gitignore
# Flavor secrets — never commit env/dev.json env/prod.json # iOS xcconfig after inject script runs (contain real secrets) ios/Flutter/devDebug.xcconfig ios/Flutter/devProfile.xcconfig ios/Flutter/devRelease.xcconfig ios/Flutter/prodDebug.xcconfig ios/Flutter/prodProfile.xcconfig ios/Flutter/prodRelease.xcconfig # Old dotenv files .env .env.dev .env.prod

6 Update VSCode launch.json

Pass --dart-define-from-file inside args alongside --flavor. When using flutter_flavorizr this is the correct pattern — not a separate dartDefinesFile key:

.vscode/launch.json
{ "version": "0.2.0", "configurations": [ { "name": "dev Debug", "request": "launch", "type": "dart", "flutterMode": "debug", "args": [ "--flavor", "dev", "--dart-define-from-file=env/dev.json" ], "program": "lib/main.dart" }, { "name": "dev Profile", "request": "launch", "type": "dart", "flutterMode": "profile", "args": [ "--flavor", "dev", "--dart-define-from-file=env/dev.json" ], "program": "lib/main.dart" }, { "name": "dev Release", "request": "launch", "type": "dart", "flutterMode": "release", "args": [ "--flavor", "dev", "--dart-define-from-file=env/dev.json" ], "program": "lib/main.dart" }, { "name": "prod Debug", "request": "launch", "type": "dart", "flutterMode": "debug", "args": [ "--flavor", "prod", "--dart-define-from-file=env/prod.json" ], "program": "lib/main.dart" }, { "name": "prod Profile", "request": "launch", "type": "dart", "flutterMode": "profile", "args": [ "--flavor", "prod", "--dart-define-from-file=env/prod.json" ], "program": "lib/main.dart" }, { "name": "prod Release", "request": "launch", "type": "dart", "flutterMode": "release", "args": [ "--flavor", "prod", "--dart-define-from-file=env/prod.json" ], "program": "lib/main.dart" } ] }
✓ Note
--dart-define-from-file goes inside args — not as a separate dartDefinesFile key. This is the correct pattern when flutter_flavorizr is managing your flavor args. Both --flavor and --dart-define-from-file travel together in the same args array.

Select your flavor from the Run & Debug panel (Ctrl+Shift+D) in VSCode.

7 CLI build commands

terminal — Android + iOS build commands per flavor
# ── RUN ─────────────────────────────────────────── flutter run --flavor dev flutter run --debug --flavor dev flutter run --release --flavor dev --dart-define-from-file=env/dev.json flutter run --release --flavor prod --dart-define-from-file=env/prod.json # ── ANDROID ─────────────────────────────────────── # AAB dev (Play Store internal testing) flutter build appbundle --flavor dev \ --dart-define-from-file=env/dev.json # AAB prod (Play Store release) flutter build appbundle --release --flavor prod \ --obfuscate \ --split-debug-info=build/debug-info \ --dart-define-from-file=env/prod.json # APK dev flutter build apk --flavor dev \ --dart-define-from-file=env/dev.json # APK prod flutter build apk --release --flavor prod \ --dart-define-from-file=env/prod.json # ── IOS ─────────────────────────────────────────── # iOS dev (debug, simulator or device) flutter build ios --debug --flavor dev \ --dart-define-from-file=env/dev.json # IPA prod (TestFlight / App Store) flutter build ipa --release --flavor prod \ --obfuscate \ --split-debug-info=build/debug-info \ --dart-define-from-file=env/prod.json

CI/CD Integration

For GitHub Actions or any CI pipeline, store secrets in your provider's encrypted vault and write them to the config file during the build step:

.github/workflows/release.yml
- name: Create prod config run: | echo '{ "APP_ENV": "prod", "BASE_URL": "${{ secrets.PROD_BASE_URL }}" }' > env/prod.json - name: Build AAB run: | flutter build appbundle \ --release \ --flavor prod \ --obfuscate \ --split-debug-info=build/debug-info \ --dart-define-from-file=env/prod.json

The secrets live in GitHub's encrypted vault. The env/prod.json file is written only during the build step and is never stored in the repository.

iOS — Building Directly From Xcode

When you build from VSCode or the Flutter CLI, --dart-define-from-file injects your secrets before Xcode ever sees the build. But when you open Xcode and hit Cmd+R or Archive, Flutter's tool never runs — so dart-define is never injected.

⚠ Xcode Direct Build Gap
Xcode builds bypass flutter run entirely. Your String.fromEnvironment() calls will fall back to their defaultValue — meaning prod builds from Xcode silently use dev/empty values unless you handle this separately.

The fix: inject DART_DEFINES directly into the xcconfig files that flutter_flavorizr generates. Flutter's Xcode build phase reads this value and passes it to the engine — identical result to --dart-define on the CLI.

flavorizr xcconfig Structure

After running dart run flutter_flavorizr, your ios/Flutter/ directory contains one file per flavor per mode:

ios/Flutter/ — flavorizr generated
ios/Flutter/ ├── devDebug.xcconfig ├── devProfile.xcconfig ├── devRelease.xcconfig ├── prodDebug.xcconfig ├── prodProfile.xcconfig └── prodRelease.xcconfig

Each file already contains flavorizr's branding config. You append DART_DEFINES — a comma-separated list of individually base64-encoded key=value pairs:

ios/Flutter/devDebug.xcconfig — after injection
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" ASSET_PREFIX=dev BUNDLE_NAME=DEV Your App Name BUNDLE_DISPLAY_NAME=DEV Your App Name DART_DEFINES=QVBQX0VOVj1kZXY=,QkFTRV9VUkw9aHR0cHM6Ly9kZXYuYXBpLmV4YW1wbGUuY29t,...

The Inject Script

Create scripts/inject_dart_defines.sh at your project root. It base64-encodes each value and appends DART_DEFINES to all six xcconfig files. It's idempotent — safe to run multiple times:

scripts/inject_dart_defines.sh
#!/bin/bash # Run after every: dart run flutter_flavorizr set -e # ── YOUR VALUES (use your real secrets locally, never commit) ── DEV_APP_ENV="dev" DEV_BASE_URL="https://dev.api.example.com" DEV_CACHE_KEY="your-dev-encryption-key" PROD_APP_ENV="prod" PROD_BASE_URL="https://api.example.com" PROD_CACHE_KEY="your-prod-encryption-key" # ─────────────────────────────────────────────────────────────── encode() { echo -n "$1=$2" | base64; } DEV_DEFINES="$(encode APP_ENV $DEV_APP_ENV),$(encode BASE_URL $DEV_BASE_URL),$(encode CACHE_ENCRYPTION_KEY $DEV_CACHE_KEY)" PROD_DEFINES="$(encode APP_ENV $PROD_APP_ENV),$(encode BASE_URL $PROD_BASE_URL),$(encode CACHE_ENCRYPTION_KEY $PROD_CACHE_KEY)" XCCONFIG_DIR="ios/Flutter" inject() { local FILE="$1" DEFINES="$2" sed -i '' '/^DART_DEFINES=/d' "$FILE" 2>/dev/null || true echo "DART_DEFINES=$DEFINES" >> "$FILE" echo " ✅ $FILE" } echo "── DEV ──────────────────────" inject "$XCCONFIG_DIR/devDebug.xcconfig" "$DEV_DEFINES" inject "$XCCONFIG_DIR/devProfile.xcconfig" "$DEV_DEFINES" inject "$XCCONFIG_DIR/devRelease.xcconfig" "$DEV_DEFINES" echo "── PROD ─────────────────────" inject "$XCCONFIG_DIR/prodDebug.xcconfig" "$PROD_DEFINES" inject "$XCCONFIG_DIR/prodProfile.xcconfig" "$PROD_DEFINES" inject "$XCCONFIG_DIR/prodRelease.xcconfig" "$PROD_DEFINES" echo "✅ Done — select your scheme in Xcode (dev/prod) before building."
terminal — setup and run
# First time only mkdir -p scripts chmod +x scripts/inject_dart_defines.sh # Run it (and after every dart run flutter_flavorizr) ./scripts/inject_dart_defines.sh

Xcode Workflow

After the script runs, open Xcode and select the correct scheme before building:

Xcode — scheme selector (top bar)
Scheme dropdown: ├── Runner (dev) ← Cmd+R for dev builds └── Runner (prod) ← Product → Archive for App Store

Xcode reads the matching xcconfig (devRelease or prodRelease), finds DART_DEFINES, and passes the values to Flutter's build engine. Your String.fromEnvironment() constants compile correctly — same result as the CLI.

✓ Workflow After flavorizr Runs
dart run flutter_flavorizr regenerates xcconfig files and wipes DART_DEFINES. Always re-inject immediately after:

dart run flutter_flavorizr && ./scripts/inject_dart_defines.sh

All Four Build Paths

Build path Flavor Env injection
VSCode Run & Debug --flavor dev/prod in args --dart-define-from-file=env/dev.json in args
Flutter CLI run --flavor dev/prod --dart-define-from-file=env/dev.json
Flutter CLI build --flavor prod --dart-define-from-file=env/prod.json
Xcode Cmd+R / Archive Scheme selector → dev/prod DART_DEFINES in xcconfig via inject script

Works Perfectly With flutter_flavorizr

If you're already using flutter_flavorizr, this approach complements it cleanly. The responsibilities stay clearly separated:

Tool Responsibility
flutter_flavorizr App name, applicationId/bundleId, Firebase config routing, icon/splash per flavor, xcconfig base structure
--dart-define-from-file / xcconfig DART_DEFINES API keys, base URLs, encryption keys, runtime env constants — for all build paths including Xcode

Your flavors.dart, F class, and Flavor enum remain exactly as generated. The only change is that dotenv is no longer involved — String.fromEnvironment() handles values at the compiled level.

If You've Already Published With .env in Assets

⚠ Action Required
If a release build with .env files listed as assets has ever been published to the Play Store or App Store — assume those keys are compromised. You cannot know if someone has already extracted them.
  1. Rotate all API keys exposed in the .env files — go to each service and regenerate immediately
  2. Revoke old keys after new ones are deployed to production
  3. Migrate to dart-define before your next release build
  4. Remove .env files from git history if they were ever committed — use git filter-repo or BFG Repo Cleaner
  5. Audit all published apps — check every project for the same pattern

Summary

✓ The secure setup
env/dev.json and env/prod.json live on your machine and in your CI vault. They are injected at build time via --dart-define-from-file for CLI/VSCode builds, and via DART_DEFINES in xcconfig for Xcode direct builds. The values are compiled into the binary. No file enters the APK or IPA. JADX finds nothing. flutter_flavorizr still owns branding. Everyone is happy.
Concern flutter_dotenv + assets dart-define-from-file
Secrets in APK/IPA Yes — plaintext file No — compiled binary
JADX readable Yes No
Obfuscation helps No N/A
Async init needed Yes No
Works with flavorizr Yes, but insecure Yes, cleanly
Works from Xcode Yes, but insecure Yes, via xcconfig inject
CI/CD friendly Yes Yes
Flutter version required Any Flutter 3.7+
The change is small. The security improvement is significant. Secrets should be compiled into your binary — not bundled as readable files in your APK or IPA.

By Rohan Kumar Chaudhary | Flutter · iOS · Mobile Developer 💙
Also published on Medium · LinkedIn
#Flutter #iOS #Security #AndroidDev #FlutterDev #JADX #Xcode #DartLang #MobileSecurity #AppSecurity #flutter_dotenv #dart-define
Rohan Kumar Chaudhary
Rohan Kumar Chaudhary
Flutter · iOS · Mobile Developer at YenyaSoft