“Every Flutter developer has shipped an API key they shouldn’t have. The honest ones admit it.”

I’ll go first. Early in my career I had a Google Maps API key sitting directly in a Dart file. Not in a comment, not in a debug build only — just there, in the source, committed to a private corporate repo. The repo was never public, so the immediate scraping risk wasn’t there — but the key was visible to every developer on the team, sitting in git history permanently even after I removed it, and would have shipped inside the app binary to every device that installed it. A teammate flagged it during review. Private repo or not, that’s not where a secret should live.
That specific mistake is easy to avoid now. What’s less obvious is that the “better” approaches most Flutter developers reach for still have real problems — and envied, now at version 1.3.9 with 251k downloads and 948 likes on pub.dev, is the package that actually gets this right. Let me walk through why the obvious solutions fall short, what envied does differently, and every feature worth knowing about.
Why the Obvious Approaches Don’t Work 🤔
“A .env file you load at runtime is better than a hardcoded key. It’s not safe.”
The most common “I’m being responsible” approach in Flutter looks like this: create a .env file, add it to .gitignore, use a package like flutter_dotenv to load it at runtime, read values from it as strings during the app lifecycle.
This feels like the right solution. It separates secrets from code. The .env file doesn't get committed. Done.
Except at runtime, your .env file ships inside the app bundle. Anyone who downloads your APK or IPA and runs apktool or just inspects the asset bundle will find the file in plain text. The secret that never touched your git history is sitting unprotected in your distributed binary.
The --dart-define flag approach has the same problem from a different angle. Dart defines get baked into the binary as string constants. They're slightly harder to find than a raw .env file, but strings on the binary or a basic decompile surfaces them without difficulty. You've made the job marginally harder, not actually secure.
What envied Does Differently ⚙️
“Instead of shipping a file, it generates compiled Dart. Instead of runtime loading, it’s build time. That’s the actual change that matters.”
envied is a code generation package. It reads your .env file at build time, generates a Dart file containing the values, and from that point forward your app never ships an .env file at all. The values live in compiled Dart code, not in a discoverable asset file.
That alone moves the security bar considerably. But the obfuscation feature is what takes it further.
The Three-Package Setup 📦
envied splits itself across three packages, which trips people up the first time:
dart pub add envied dev:envied_generator dev:build_runner
- envied — contains the annotations (@Envied, @EnviedField)
- envied_generator — the build_runner code generator that does the actual work
- build_runner — the tool that runs the generator
The dev: prefix on the last two matters. They're development dependencies — they do their job at build time and have no presence in your final app.
The Basic Setup, Properly Explained 🛠️
Create your .env file at the project root:
API_KEY=your_secret_key_here
BASE_URL=https://api.yourservice.com
DEBUG_MODE=false
Create your Env class at lib/env/env.dart:
import 'package:envied/envied.dart';
part 'env.g.dart';
@Envied(path: '.env')
abstract class Env {
@EnviedField(varName: 'API_KEY')
static const String apiKey = _Env.apiKey;
@EnviedField()
static const String BASE_URL = _Env.BASE_URL;
@EnviedField(defaultValue: 'false')
static const String debugMode = _Env.debugMode;
}
Run the generator:
dart run build_runner build --delete-conflicting-outputs
Two critical things to add to your .gitignore immediately:
.env
lib/env/env.g.dart
The first keeps your secrets out of source control. The second keeps the generated file — which contains the actual values — out too. Both need to be regenerated on every machine that builds the project, which means your CI pipeline needs the real .env file available at build time (through secrets management, not committed files).
The Obfuscation Feature — What It Actually Does 🔒
“Compiled Dart is better than a plain text file. Obfuscated compiled Dart makes a determined attacker work noticeably harder.”
Add obfuscate: true to any field, and note that it requires static final instead of static const:
@Envied(path: '.env')
abstract class Env {
@EnviedField(obfuscate: true)
static final String apiKey = _Env.apiKey; // final, not const
@EnviedField()
static const String baseUrl = _Env.baseUrl; // non-sensitive, const is fine
}
Without obfuscation, the generated code looks roughly like:
// What the generator produces without obfuscation
class _Env {
static const String apiKey = 'your_secret_key_here';
}
That string is in your binary, and while it’s in compiled form rather than a plain text file, it’s still extractable with basic tools.
With obfuscation, the generator XOR-encrypts the value with a randomly generated key and stores both the encrypted bytes and the key as integer arrays. The actual string only exists for a moment at runtime when it’s decrypted in memory — never as a plain string constant anywhere in the binary. The generated code looks more like:
// Conceptual representation of what obfuscated output looks like
class _Env {
static final String apiKey = String.fromCharCodes(
List<int>.generate(
_encryptedApiKey.length,
(i) => _encryptedApiKey[i] ^ _apiKeyKey[i % _apiKeyKey.length],
)
);
static const List<int> _encryptedApiKey = [/* encrypted bytes */];
static const List<int> _apiKeyKey = [/* XOR key bytes */];
}
Worth being completely honest about what this does and doesn’t provide, because envied’s own README is admirably direct about it: obfuscation makes reverse engineering significantly harder, not impossible. A determined person with the right tools can still extract the value. What it does is raise the effort bar from “run strings on the binary and grep" to "write custom code to find and execute the XOR routine." For most threat models — app store releases, public-facing apps, anything short of a high-value target — that's a meaningful practical security improvement.
Multiple Environments — Dev, Staging, Production 🌍
“The single most underused feature of envied, and the one that solves the problem every Flutter team eventually hits.”
Most teams start with one .env file and eventually discover they need three: one for local development, one for staging, one for production. The naive solution is swapping the .env file manually before each build. The actual solution is envied's multiple environment support.
Set up your files:
.env.dev ← development
.env.staging ← staging
.env.prod ← production
Create a single class handling all three:
import 'package:envied/envied.dart';
part 'multi_env.g.dart';
@Envied(path: '.env.dev', name: 'DevEnv')
@Envied(path: '.env.staging', name: 'StagingEnv')
@Envied(path: '.env.prod', name: 'ProdEnv')
final class AppEnv {
factory AppEnv() => _instance;
static final AppEnv _instance = _resolveEnvironment();
static AppEnv _resolveEnvironment() {
const env = String.fromEnvironment('ENV', defaultValue: 'dev');
return switch (env) {
'prod' => _ProdEnv(),
'staging' => _StagingEnv(),
_ => _DevEnv(),
};
}
@EnviedField(varName: 'API_KEY')
final String apiKey = _instance.apiKey;
@EnviedField(varName: 'BASE_URL')
final String baseUrl = _instance.baseUrl;
}
Then in CI/CD, flip environments by passing a define at build time:
# Development build
flutter build apk
# Staging build
flutter build apk --dart-define=ENV=staging
# Production build
flutter build apk --dart-define=ENV=prod
You can also override which .env file gets used from the CLI without changing any code:
dart run build_runner build \
--define=envied_generator:envied=path=.env.staging
Or set per-class path overrides in build.yaml:
targets:
$default:
builders:
envied_generator|envied:
options:
override: true
pathOverrides:
ProdEnv: .env.prod
DevEnv: .env.dev
Environment Inheritance — Stop Duplicating Shared Values 📋
“The feature that removes the main reason people give up on multiple env files.”
The most common complaint about multiple .env files is duplication. Your three environments probably share ninety percent of values — only the API endpoint, keys, and a handful of flags actually differ between dev and prod. Copying all the shared values across every file is tedious and creates drift.
inheritFrom solves this:
# .env.defaults — shared across everything
APP_NAME=MyApp
TIMEOUT=30
SUPPORT_EMAIL=support@myapp.com
# .env.dev — only what differs
API_URL=https://dev-api.myapp.com
API_KEY=dev_key_here
# .env.prod — only what differs
API_URL=https://api.myapp.com
API_KEY=prod_key_here
@Envied(path: '.env.dev', inheritFrom: ['.env.defaults'])
abstract class Env {
@EnviedField()
static const String apiUrl = _Env.apiUrl; // from .env.dev
@EnviedField()
static const String appName = _Env.appName; // from .env.defaults
@EnviedField()
static const int timeout = _Env.timeout; // from .env.defaults
}
The inheritance model is explicit and predictable: files listed in inheritFrom load left to right, then the primary path loads on top, with later values overriding earlier ones. No magic, no ambiguity about which value wins.
Optional Fields — For Open Source Apps 🔓
“The feature I didn’t think I needed until I looked at how many open source Flutter apps break because a contributor doesn’t have the API keys.”
If you maintain an open source Flutter app that uses an analytics service or a non-essential API, forcing every contributor to have valid API keys before the app compiles is friction that kills contributions. allowOptionalFields handles this cleanly:
@Envied(allowOptionalFields: true)
abstract class Env {
@EnviedField()
static const String? analyticsKey = _Env.analyticsKey; // null if not provided
@EnviedField()
static const String apiKey = _Env.apiKey; // still required, throws if missing
}
Missing values for nullable fields produce null instead of a build error. You can also enable this per-field rather than globally if you want to be selective about which fields are truly optional versus required.
The System Environment Variables Approach — For CI/CD Purists 🏗️
“The cleanest CI/CD setup: no .env files at all in the pipeline, just system environment variables.”
Some teams prefer not to manage .env files in their CI/CD pipelines at all, instead injecting values directly as system environment variables through the CI platform's secrets management. envied supports this:
@Envied(environment: true)
final class Env {
@EnviedField(varName: 'API_KEY')
static const String apiKey = _Env.apiKey;
}
With environment: true, the generator reads Platform.environment['API_KEY'] at build time rather than looking for a .env file. Your .env file can be committed to source control safely — it becomes a key mapping file, not a secrets file, because the values come from the CI environment at generation time.
The Known Issue Worth Knowing About 🐛
envied’s README is honest about one rough edge: when you modify your .env file, build_runner sometimes doesn't pick up the change because the .env file isn't in its default tracked source set. The fix is a build.yaml entry:
targets:
$default:
sources:
- $package$
- lib/**
- .env*
- .env/**
Adding .env* and .env/** to the sources list tells build_runner to watch those files and trigger regeneration when they change. Without this, you'll occasionally make a .env change and wonder why the generated code didn't update — clean the build cache and regenerate:
dart run build_runner clean
dart run build_runner build --delete-conflicting-outputs
Where This Fits in a Real Production Flutter App 💭
envied doesn’t solve every secret management problem. For truly sensitive values that must never exist in any binary — payment processing keys, health data encryption keys, credentials for services where a compromise would be catastrophic — the right answer is to not put them in the client at all, and proxy through a backend you control. No client-side secret management scheme protects against a determined, well-resourced attacker who has physical or logical access to the device.
What envied solves is the large, practical middle ground: API keys for third-party services, configuration values that differ by environment, analytics and logging tokens, feature flags. The category of secrets that need to be in the client to function, that shouldn’t be in plain text in your binary, and that you want kept entirely out of source control. For that use case, envied’s build-time generation plus optional obfuscation is the best approach I’ve seen in the Dart ecosystem.
The --dart-define approach is still fine for values that aren't actually secret, just configurable by environment. envied is for the things that genuinely shouldn't be discoverable.
Tags: Flutter, Dart, Security, Environment Variables, Mobile Development, pub.dev, Flutter Packages
Before you go
- Please take a moment to like the post and follow the writer!
- Did you know that over 400,000 developers share what they’re building, learning, and discovering across our platforms every month? Learn how you can contribute here
Stop Hardcoding API Keys in Your Flutter App — envied Does It Right 🔐 was originally published in Stackademic on Medium, where people are continuing the conversation by highlighting and responding to this story.
