Skip to content

Security: mfazrinizar/flutter_secure_dotenv

Security

SECURITY.md

Security Policy

Reporting Vulnerabilities

If you discover a major security vulnerability, please report it responsibly by emailing mfazrinizar@gmail.com instead of creating a public GitHub issue. We will respond within 72 hours and work toward a fix.

Security Advisory: Encryption Key Provisioning

The Problem

flutter_secure_dotenv encrypts your .env values at build time. At runtime the app needs the same key to decrypt. The critical question is: how does the key reach the running app securely?

Security Ranking (Best → Worst)

# Approach Key in binary? Key in source control? Notes
1 Server-fetched key + flutter_secure_storage Only approach where the key never exists in the binary.
2 Hardcoded key in gitignored env.dart + --obfuscate ⚠️ obfuscated ❌ (gitignored) Key is in the binary but protected from source control leaks.
3 --dart-define / String.fromEnvironment ✅ base64 string ⚠️ in CI scripts/git Same binary risk as #2 but key also leaks into source control.
4 JSON file shipped in app bundle ✅ plaintext file Extractable without decompiling — just unzip. Worst option.

Note on flutter_secure_storage without a server: Writing a hardcoded key to flutter_secure_storage on first launch does not improve security. The static const is already compiled into the binary — an attacker extracts it from the binary before the app ever runs. Copying it to secure storage at runtime doesn't remove it from the compiled code. flutter_secure_storage is only meaningful when the key comes from an external source (a server) and never exists in the binary.

Why --dart-define Is Insecure

  1. Build-time embedding--dart-define values are compiled directly into the binary (libapp.so on Android, Mach-O on iOS) as base64-encoded strings.
  2. Binary decompilation — Anyone with the APK/IPA can extract them using apktool, jadx, or Hopper Disassembler.
  3. Memory exposureString.fromEnvironment() values exist as plain text in process memory.
  4. Security paradox — An insecure channel for the key undermines the entire encryption layer.

Why a JSON File Is Even Worse

Shipping encryption_key.json as a Flutter asset or alongside the binary means the key is plaintext on disk inside the APK/IPA. An attacker can extract it with a simple unzip — no decompilation needed. Never bundle the key file in your release.

The JSON file generated by build_runner (OUTPUT_FILE) is a temporary transfer mechanism to move the key from the build tool to your gitignored env.dart. Delete it immediately after copying the values.


Recommended Approaches

Approach 1 — Hardcoded Key in Gitignored env.dart (No Server Required)

The simplest working approach. The key lives in a source file that is never committed. Combined with --obfuscate, this is the best you can do without a server.

NOTE: The key IS in the compiled binary. --obfuscate makes it harder to find but not impossible for a determined attacker. This approach protects against source control leaks and raises the bar for reverse engineering, but it is not bulletproof.

project/
├── lib/
│   ├── env.dart            ← GITIGNORED (contains real key)
│   ├── env.example.dart    ← Committed (template with placeholders)
│   └── env.g.dart          ← Generated by build_runner
├── .env                    ← Your secrets
└── .gitignore              ← Must include: .env, env.dart, encryption_key.json

env.example.dart (committed):

import 'package:flutter_secure_dotenv/flutter_secure_dotenv.dart';

part 'env.g.dart';

@DotEnvGen(filename: '.env', fieldRename: FieldRename.screamingSnake)
abstract class Env {
  // Replace with real values from encryption_key.json, then delete the JSON.
  static const _encryptionKey = 'PASTE_BASE64_ENCRYPTION_KEY_HERE';
  static const _iv = 'PASTE_BASE64_IV_HERE';

  static Env create() => Env(_encryptionKey, _iv);

  const factory Env(String encryptionKey, String iv) = _$Env;
  const Env._();

  String get apiKey;
}

Setup steps:

# 1. Copy the template
cp lib/env.example.dart lib/env.dart

# 2. Generate encrypted env + random key
dart run build_runner build \
  --define flutter_secure_dotenv_generator:flutter_secure_dotenv=OUTPUT_FILE=encryption_key.json

# 3. Copy ENCRYPTION_KEY and IV from encryption_key.json into env.dart

# 4. Delete the JSON file — it was only a transfer mechanism
rm encryption_key.json

# 5. Build release with obfuscation
flutter build apk --obfuscate --split-debug-info=build/debug-info

What this protects:

  • ✅ Key never enters source control or git history
  • .env values are encrypted — not plaintext in the binary
  • ⚠️ Key is in the binary but obfuscated — raises the difficulty bar for reverse engineering

What this does NOT protect:

  • ❌ A determined attacker with decompilation skills can still find the key in the binary
  • ❌ No key rotation without rebuilding and re-deploying

Approach 2 — Server-Fetched Key + flutter_secure_storage (Most Secure)

The only approach where the key never exists in the app binary. It is fetched from a backend secrets manager on first launch and cached in platform secure storage (Android Keystore / iOS Keychain).

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

static Future<Env> create() async {
  const storage = FlutterSecureStorage();

  var key = await storage.read(key: 'env_encryption_key');
  var iv = await storage.read(key: 'env_iv');

  if (key == null || iv == null) {
    // Fetch from your backend over HTTPS
    final response = await http.get(
      Uri.parse('https://your-api.com/env-keys'),
      headers: {'Authorization': 'Bearer $token'},
    );
    final keys = json.decode(response.body) as Map<String, dynamic>;
    key = keys['ENCRYPTION_KEY'] as String;
    iv = keys['IV'] as String;

    await storage.write(key: 'env_encryption_key', value: key);
    await storage.write(key: 'env_iv', value: iv);
  }

  return Env(key, iv);
}

Use a secrets manager such as:

What this protects:

  • ✅ Key never in the binary — cannot be extracted by decompilation
  • ✅ Key never in source control
  • ✅ Supports key rotation without rebuilding the app
  • ✅ After first launch, key lives in OS-encrypted hardware-backed storage

What this does NOT protect:

  • ❌ Memory dump on rooted/jailbroken device (decrypted values in RAM)
  • ❌ Requires a backend server (needs to be secure) and internet on first launch

What This Package Does and Does NOT Protect

Threat Protected? Notes
Source code leak of .env file ✅ Yes Values are encrypted at build time
Source control leak of encryption key ✅ Yes Key is in gitignored file, never committed
Reverse-engineering APK/IPA for secrets ⚠️ Partial Encrypted values are safe; key security depends on approach + --obfuscate
Reverse-engineering APK/IPA for the key ⚠️ Depends Approach 2 (server): ✅. Approach 1 (hardcoded): ⚠️ obfuscated but in binary
Memory dump on rooted device ⚠️ Mitigable See Memory Protection below
Man-in-the-middle attacks ❌ No Use HTTPS + certificate pinning separately

Memory Protection on Rooted/Jailbroken Devices

Decrypted values must exist in app memory to be used — a root-level attacker who can dump memory at the right moment can read them. This is a fundamental limitation of every app on every platform, not specific to this package.

Note on flutter_secure_storage: The data at rest in Android Keystore or iOS Keychain is hardware-encrypted and safe even on rooted devices. However, the moment you call storage.read(), the returned String is a plain Dart object in app memory — and that is dumpable. The storage protects the persisted data; the vulnerability is the read-into-memory step, which is unavoidable if you need to actually use the value.

Available mitigations (none are bulletproof, but they stack):

Mitigation Effectiveness Notes
Root/jailbreak detection ⚠️ Refuse to run on compromised devices. Bypassable via Frida hooks or patched binaries. Packages: flutter_jailbreak_detection.
Hardware-backed crypto ⚠️ Use Android Keystore TEE/StrongBox or iOS Secure Enclave to decrypt in hardware — the key never enters app memory. But the decrypted values still must.
Minimize exposure window ⚠️ Decrypt lazily (on-demand), don't cache, null-out references after use. Limited in Dart — strings are immutable and the GC controls when memory is freed, so you can't reliably zero it.
Anti-tampering / anti-debugging ⚠️ Detect Frida, debuggers, and memory inspection tools at runtime. Bypassable by skilled attackers.
Short-lived tokens Design your backend so secrets are short-lived tokens that expire quickly. Even if dumped, they become useless. This is an architectural solution, not a client-side one.

Bottom line: The only robust defense against memory inspection is making stolen secrets useless — short-lived tokens, server-side rate limiting, and device attestation. No amount of client-side hardening fully solves this.

The Hard Truth About Client-Side Key Storage

No purely client-side approach can fully protect an encryption key from a determined attacker. If the key must be in the binary (because there's no server), it can be extracted — --obfuscate raises the bar but doesn't eliminate the risk.

This is a fundamental limitation of all client-side secret management, not specific to this package.

What Should Go in .env?

Your .env file should contain semi-secrets — values that need to be in the client at build time but are already protected by server-side rules. The encryption adds a layer that makes casual extraction and automated scraping harder, but it is not the primary security boundary.

Good candidates for .env (semi-secrets with server-side protection):

Value Why it's okay Real protection layer
Firebase API key Must be in client; public by design Firestore Security Rules, App Check
API base URL Not truly secret, but shouldn't be trivially scraped Rate limiting, authentication
Sentry DSN Needed at build time Server-side rate limiting, project access controls
Third-party SDK keys (Maps, Analytics) Must be in client Usage quotas, domain/bundle restrictions
Feature flags / config Non-sensitive but shouldn't be plaintext Server-side validation

Bad candidates for .env (truly secret — do NOT embed in client):

Value Why it's dangerous What to do instead
Database credentials Full data access if leaked Server-side only — never in client
Admin API keys Unrestricted access Server-side only
Payment processor secret keys Financial access Server-side only
JWT signing secrets Can forge tokens Server-side only

The Right Mental Model

Think of flutter_secure_dotenv encryption as a speed bump, not a wall:

  1. It prevents casual leaks.env values aren't plaintext in your repo or binary
  2. It raises the effort required — an attacker needs decompilation skills + obfuscation bypassing, not just unzip or strings
  3. It defeats automated scanning — secret-scanning bots and scrapers won't find encrypted values
  4. It reduces spam/abuse potential — even semi-public keys like Firebase API keys benefit from not being trivially extractable, because it reduces the pool of people who can abuse them

The real security comes from the server side — rules, quotas, authentication, rate limiting, and App Check. The encryption just makes it harder for someone to reach the point of attempting abuse.

Summary

  • Never pass encryption keys via --dart-define.
  • Never ship encryption_key.json or any key file in your app bundle — it is plaintext.
  • Never commit env.dart to source control — commit env.example.dart as a template.
  • Always build release builds with --obfuscate --split-debug-info=....
  • Always add env.dart, encryption_key.json, and .env* to .gitignore.
  • For maximum security, use a server to provision the key (Approach 2).
  • Without a server, use the gitignored env.dart + --obfuscate pattern (Approach 1) and accept the trade-off.

Supported Versions

Version Supported
2.x ✅ Current
1.x ⚠️ Security fixes only

There aren’t any published security advisories