"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 — ⚠ VULNERABLEflutter: assets: - assets/images/ - assets/svg_images/ - .env.dev # ← ships inside APK as plaintext - .env.prod # ← ships inside APK as plaintext
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.prodAPP_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.
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.
Method names
Variable names
Kotlin/Java bytecode
→ Code is harder to read
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 structureproject_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
class GetEnvConfig {
GetEnvConfig._();
static final String appEnvironment
= dotenv.get('APP_ENV');
static final String baseUrl
= dotenv.get('BASE_URL');
}
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 stepFuture<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
terminalflutter 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" } ] }
--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.
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 generatedios/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.
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
.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.
- Rotate all API keys exposed in the .env files — go to each service and regenerate immediately
- Revoke old keys after new ones are deployed to production
- Migrate to dart-define before your next release build
- Remove .env files from git history if they were ever committed —
use
git filter-repoor BFG Repo Cleaner - Audit all published apps — check every project for the same pattern
Summary
--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