Stop Committing Your API Keys! Here's How to Actually Secure Them in Flutter (Part 1)

Stop Committing Your API Keys! Here's How to Actually Secure Them in Flutter (Part 1)

FlutterPulse

This article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!πŸš€

Learn to securely store API keys and secrets in Flutter apps using environment variables, obfuscation, and native keychain storage.

Your API keys are probably exposed right now. Let's fix that before it's too late.

The complete guide to managing secrets per environment without exposing them in your codebase (yes, even for beginners!)

Hey friend! πŸ‘‹

So you're building a Flutter app, and you need to connect to some APIs. Cool! But then you realize you need to store API keys, database URLs, authentication secrets… and suddenly you're Googling "where to put API keys Flutter" at 2 AM.

And what do you find? A bunch of people saying "just don't commit them to Git!" Gee, thanks Captain Obvious. But where exactly should they go? And how do you manage different secrets for dev, staging, and production?

Today, I'm gonna show you exactly how to do this right. No hand-waving, no "figure it out yourself" β€” just a complete, practical guide that actually works.

The Problem: Why Hardcoding Secrets is a Disaster

First, let's talk about what NOT to do:

// ❌ NEVER DO THIS
class ApiConfig {
static const String apiKey = 'sk_live_abc123xyz789';
static const String databaseUrl = 'postgresql://user:password@host';
}

Why is this bad? Let me count the ways:

  • It's in your Git history forever (even if you delete it later)
  • Anyone with access to your repo can see it (including that intern who just joined)
  • It'll end up on GitHub/GitLab where bots scrape for exposed secrets 24/7
  • You can't have different secrets per environment (dev vs prod)
  • Changing secrets means recompiling and redeploying your entire app

True story: A friend once committed their AWS keys to GitHub. Within 30 minutes, someone spun up $5,000 worth of Bitcoin mining instances. Don't be that person.

The Solution: Multiple Layers of Security

Here's our game plan. We're going to use THREE layers of protection:

Layer 1: Environment Variables (Keep secrets out of code) Layer 2: Obfuscation (Make reverse engineering harder) Layer 3: Runtime Decryption (Add an extra security barrier)

Plus, for super sensitive stuff like user tokens, we'll use native secure storage (iOS Keychain and Android Keystore).

Let's dive in! πŸŠβ€β™‚οΈ

Layer 1: Using dart-define for Environment Variables

This is your first line of defense. Instead of hardcoding secrets, we'll pass them at build time using --dart-define.

Step 1: Create Your Environment Config

// lib/config/app_secrets.dart

class AppSecrets {
// These are compile-time constants injected via --dart-define
static const String apiKey = String.fromEnvironment(
'API_KEY',
defaultValue: '',
);

static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.example.com',
);

static const String googleMapsKey = String.fromEnvironment(
'GOOGLE_MAPS_KEY',
defaultValue: '',
);

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

// Validation helper
static bool get isConfigured {
return apiKey.isNotEmpty &&
apiBaseUrl.isNotEmpty;
}

// Debug helper (never log actual secrets!)
static void validateSecrets() {
assert(apiKey.isNotEmpty, '❌ API_KEY is not set!');
assert(googleMapsKey.isNotEmpty, '❌ GOOGLE_MAPS_KEY is not set!');

if (!isConfigured) {
throw Exception('App secrets are not properly configured!');
}
}
}

Step 2: Create a Riverpod Provider

// lib/config/secrets_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_secrets.dart';

// Provider for API key
final apiKeyProvider = Provider<String>((ref) {
return AppSecrets.apiKey;
});

// Provider for base URL
final apiBaseUrlProvider = Provider<String>((ref) {
return AppSecrets.apiBaseUrl;
});

// Provider for Google Maps key
final googleMapsKeyProvider = Provider<String>((ref) {
return AppSecrets.googleMapsKey;
});

// Provider for checking if secrets are configured
final secretsConfiguredProvider = Provider<bool>((ref) {
return AppSecrets.isConfigured;
});

Step 3: Update Your Main Entry Point

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'config/app_secrets.dart';
import 'app.dart';

void main() {
// Validate secrets on startup (in debug mode)
assert(() {
AppSecrets.validateSecrets();
return true;
}());

runApp(
const ProviderScope(
child: MyApp(),
),
);
}

Step 4: Running with Secrets

Now you run your app like this:

# Development
flutter run \
--dart-define=API_KEY=dev_key_12345 \
--dart-define=API_BASE_URL=https://dev-api.example.com \
--dart-define=GOOGLE_MAPS_KEY=dev_maps_key

# Staging
flutter run \
--dart-define=API_KEY=staging_key_67890 \
--dart-define=API_BASE_URL=https://staging-api.example.com \
--dart-define=GOOGLE_MAPS_KEY=staging_maps_key

# Production
flutter run \
--dart-define=API_KEY=prod_key_abcdef \
--dart-define=API_BASE_URL=https://api.example.com \
--dart-define=GOOGLE_MAPS_KEY=prod_maps_key
```

Yeah, that's a lot to type. Don't worryβ€”we'll fix that in a minute!

---

## Making Your Life Easier: Configuration Files

Typing all those `--dart-define` flags is annoying. Let's create configuration files!

### Step 1: Create Environment Config Files

Create a new directory `config/` in your project root (NOT in lib/):
```
your_project/
β”œβ”€β”€ config/
β”‚ β”œβ”€β”€ dev.json
β”‚ β”œβ”€β”€ staging.json
β”‚ └── prod.json
β”œβ”€β”€ lib/
└── pubspec.yaml

config/dev.json:

{
"API_KEY": "dev_key_12345",
"API_BASE_URL": "https://dev-api.example.com",
"GOOGLE_MAPS_KEY": "AIzaSyDev...",
"STRIPE_KEY": "pk_test_dev..."
}

config/staging.json:

{
"API_KEY": "staging_key_67890",
"API_BASE_URL": "https://staging-api.example.com",
"GOOGLE_MAPS_KEY": "AIzaSyStaging...",
"STRIPE_KEY": "pk_test_staging..."
}

config/prod.json:

{
"API_KEY": "prod_key_abcdef",
"API_BASE_URL": "https://api.example.com",
"GOOGLE_MAPS_KEY": "AIzaSyProd...",
"STRIPE_KEY": "pk_live_prod..."
}

Step 2: Add Config Files to .gitignore

SUPER IMPORTANT: Never commit these files!

Add to your .gitignore:

# Secret configuration files
config/*.json
!config/*.example.json

# Also ignore any .env files
.env
.env.*

Step 3: Create Example Files

Create example files so your team knows what secrets are needed:

config/dev.example.json:

{
"API_KEY": "your_dev_api_key_here",
"API_BASE_URL": "https://dev-api.example.com",
"GOOGLE_MAPS_KEY": "your_google_maps_key_here",
"STRIPE_KEY": "your_stripe_test_key_here"
}

Commit the .example.json files, but NOT the actual .json files!

Step 4: Create a Launcher Script

Create a file scripts/run.sh:

#!/bin/bash

# Usage: ./scripts/run.sh dev
# or: ./scripts/run.sh staging
# or: ./scripts/run.sh prod

ENV=${1:-dev}
CONFIG_FILE="config/${ENV}.json"

if [ ! -f "$CONFIG_FILE" ]; then
echo "❌ Config file not found: $CONFIG_FILE"
echo "πŸ’‘ Copy config/${ENV}.example.json to $CONFIG_FILE and fill in your secrets"
exit 1
fi

echo "πŸš€ Running with $ENV environment..."

# Read JSON and convert to --dart-define flags
DART_DEFINES=$(cat "$CONFIG_FILE" | jq -r 'to_entries | map("--dart-define=\(.key)=\(.value)") | join(" ")')

# Run flutter with the defines
flutter run $DART_DEFINES

Make it executable:

chmod +x scripts/run.sh

Now running is easy:

# Development
./scripts/run.sh dev

# Staging
./scripts/run.sh staging

# Production
./scripts/run.sh prod

For Windows users, create scripts/run.bat:

@echo off
set ENV=%1
if "%ENV%"=="" set ENV=dev

set CONFIG_FILE=config\%ENV%.json

if not exist %CONFIG_FILE% (
echo ❌ Config file not found: %CONFIG_FILE%
exit /b 1
)

echo πŸš€ Running with %ENV% environment...

REM You'll need jq for Windows or use PowerShell
flutter run --dart-define-from-file=%CONFIG_FILE%

Even Better: Use dart-define-from-file (Flutter 3.7+)

If you're on Flutter 3.7 or later, there's an even cleaner way!

Just run:

flutter run --dart-define-from-file=config/dev.json

Flutter will automatically read all the key-value pairs from your JSON file. No script needed! πŸŽ‰

Update Your VS Code Launch Configuration

Edit .vscode/launch.json:

{
"version": "0.2.0",
"configurations": [
{
"name": "Development",
"request": "launch",
"type": "dart",
"args": [
"--dart-define-from-file=config/dev.json"
]
},
{
"name": "Staging",
"request": "launch",
"type": "dart",
"args": [
"--dart-define-from-file=config/staging.json"
]
},
{
"name": "Production",
"request": "launch",
"type": "dart",
"args": [
"--dart-define-from-file=config/prod.json"
]
}
]
}

Now you can just click and run from VS Code! πŸš€

Layer 2: Obfuscation (Making Reverse Engineering Harder)

Okay, so your secrets aren't in your source code anymore. Great! But here's the thing: when you compile your app, those --dart-define values are still embedded in the binary.

A determined hacker can decompile your APK or IPA and extract them. So let's make their life harder with obfuscation.

Step 1: Install envied Package

Add to your pubspec.yaml:

dependencies:
flutter_riverpod: ^2.4.0
envied: ^0.5.3

dev_dependencies:
envied_generator: ^0.5.3
build_runner: ^2.4.6

Run:

flutter pub get

Step 2: Create Environment Class with Obfuscation

// lib/config/env.dart

import 'package:envied/envied.dart';

part 'env.g.dart';

@Envied(path: 'config/dev.json', obfuscate: true)
abstract class Env {
@EnviedField(varName: 'API_KEY')
static final String apiKey = _Env.apiKey;

@EnviedField(varName: 'API_BASE_URL')
static final String apiBaseUrl = _Env.apiBaseUrl;

@EnviedField(varName: 'GOOGLE_MAPS_KEY')
static final String googleMapsKey = _Env.googleMapsKey;

@EnviedField(varName: 'STRIPE_KEY')
static final String stripeKey = _Env.stripeKey;
}

Step 3: Generate Obfuscated Code

Run this command:

flutter pub run build_runner build --delete-conflicting-outputs

This generates env.g.dart which contains your secrets in an obfuscated form. They're split into multiple integer arrays and reconstructed at runtime.

If you peek at env.g.dart, you'll see something like:

class _Env {
static final String apiKey = String.fromCharCodes([
115, 107, 95, 108, 105, 118, 101, 95, 97, 98, 99, 49, 50, 51
]);
// ... more obfuscated values
}

Much harder to extract!

Step 4: Update Your Riverpod Providers

// lib/config/secrets_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'env.dart';

final apiKeyProvider = Provider<String>((ref) => Env.apiKey);
final apiBaseUrlProvider = Provider<String>((ref) => Env.apiBaseUrl);
final googleMapsKeyProvider = Provider<String>((ref) => Env.googleMapsKey);
final stripeKeyProvider = Provider<String>((ref) => Env.stripeKey);

Step 5: Set Up for Multiple Environments

To support multiple environments with envied, create separate env classes:

// lib/config/env_dev.dart
@Envied(path: 'config/dev.json', obfuscate: true)
abstract class EnvDev {
@EnviedField(varName: 'API_KEY')
static final String apiKey = _EnvDev.apiKey;
// ... other fields
}

// lib/config/env_staging.dart
@Envied(path: 'config/staging.json', obfuscate: true)
abstract class EnvStaging {
@EnviedField(varName: 'API_KEY')
static final String apiKey = _EnvStaging.apiKey;
// ... other fields
}

// lib/config/env_prod.dart
@Envied(path: 'config/prod.json', obfuscate: true)
abstract class EnvProd {
@EnviedField(varName: 'API_KEY')
static final String apiKey = _EnvProd.apiKey;
// ... other fields
}

Then select the right one at runtime:

// lib/config/env_selector.dart

import 'env_dev.dart';
import 'env_staging.dart';
import 'env_prod.dart';

class EnvSelector {
static String get apiKey {
const env = String.fromEnvironment('ENV', defaultValue: 'dev');
switch (env) {
case 'staging':
return EnvStaging.apiKey;
case 'prod':
return EnvProd.apiKey;
default:
return EnvDev.apiKey;
}
}

static String get apiBaseUrl {
const env = String.fromEnvironment('ENV', defaultValue: 'dev');
switch (env) {
case 'staging':
return EnvStaging.apiBaseUrl;
case 'prod':
return EnvProd.apiBaseUrl;
default:
return EnvDev.apiBaseUrl;
}
}
}

Layer 3: Native Secure Storage (For Runtime Secrets)

For secrets that are obtained at runtime (like user auth tokens, session keys, etc.), use the device's native secure storage.

iOS: Uses Keychain

Android: Uses Keystore (encrypted SharedPreferences)

Step 1: Add flutter_secure_storage

dependencies:
flutter_secure_storage: ^9.0.0

Step 2: Android Configuration

Edit android/app/build.gradle:

android {
defaultConfig {
// ... existing config
minSdkVersion 18 // Minimum for secure storage
}
}

Step 3: iOS Configuration (Important!)

For iOS, you need to add keychain sharing entitlements.

Open ios/Runner/Runner.entitlements (create it if it doesn't exist):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.yourcompany.yourapp</string>
</array>
</dict>
</plist>

Then in Xcode:

  • Open ios/Runner.xcworkspace
  • Select Runner β†’ Signing & Capabilities
  • Click "+ Capability"
  • Add "Keychain Sharing"
  • Add your bundle identifier to the list

Step 4: Create a Secure Storage Service

// lib/services/secure_storage_service.dart

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class SecureStorageService {
final FlutterSecureStorage _storage;

SecureStorageService(this._storage);

// Storage keys
static const String _userTokenKey = 'user_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userIdKey = 'user_id';

// Store user authentication token
Future<void> saveAuthToken(String token) async {
await _storage.write(key: _userTokenKey, value: token);
}

// Retrieve user authentication token
Future<String?> getAuthToken() async {
return await _storage.read(key: _userTokenKey);
}

// Store refresh token
Future<void> saveRefreshToken(String token) async {
await _storage.write(key: _refreshTokenKey, value: token);
}

// Retrieve refresh token
Future<String?> getRefreshToken() async {
return await _storage.read(key: _refreshTokenKey);
}

// Store user ID
Future<void> saveUserId(String userId) async {
await _storage.write(key: _userIdKey, value: userId);
}

// Retrieve user ID
Future<String?> getUserId() async {
return await _storage.read(key: _userIdKey);
}

// Check if user is logged in
Future<bool> isLoggedIn() async {
final token = await getAuthToken();
return token != null && token.isNotEmpty;
}

// Clear all stored data (on logout)
Future<void> clearAll() async {
await _storage.deleteAll();
}

// Store any custom key-value pair
Future<void> writeSecure(String key, String value) async {
await _storage.write(key: key, value: value);
}

// Read any custom key
Future<String?> readSecure(String key) async {
return await _storage.read(key: key);
}

// Delete specific key
Future<void> deleteSecure(String key) async {
await _storage.delete(key: key);
}
}

// Riverpod provider for secure storage
final secureStorageProvider = Provider<SecureStorageService>((ref) {
const storage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
),
);

return SecureStorageService(storage);
});

Step 5: Using Secure Storage

// lib/services/auth_service.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'secure_storage_service.dart';
import '../config/secrets_provider.dart';

class AuthService {
final SecureStorageService _secureStorage;
final String _apiKey;
final String _apiBaseUrl;

AuthService({
required SecureStorageService secureStorage,
required String apiKey,
required String apiBaseUrl,
}) : _secureStorage = secureStorage,
_apiKey = apiKey,
_apiBaseUrl = apiBaseUrl;

Future<void> login(String email, String password) async {
// Make API call with your API key (from compile-time secrets)
// This is just a simulation
final response = await _makeLoginRequest(email, password);

// Store the user token securely (runtime secret)
await _secureStorage.saveAuthToken(response['token']);
await _secureStorage.saveRefreshToken(response['refreshToken']);
await _secureStorage.saveUserId(response['userId']);
}

Future<Map<String, dynamic>> _makeLoginRequest(
String email,
String password,
) async {
// Simulate API call
// In reality, use http or dio with _apiBaseUrl and _apiKey
return {
'token': 'user_auth_token_xyz',
'refreshToken': 'refresh_token_abc',
'userId': '12345',
};
}

Future<void> logout() async {
await _secureStorage.clearAll();
}

Future<bool> isLoggedIn() async {
return await _secureStorage.isLoggedIn();
}

Future<String?> getAuthToken() async {
return await _secureStorage.getAuthToken();
}
}

// Provider for auth service
final authServiceProvider = Provider<AuthService>((ref) {
final secureStorage = ref.watch(secureStorageProvider);
final apiKey = ref.watch(apiKeyProvider);
final apiBaseUrl = ref.watch(apiBaseUrlProvider);

return AuthService(
secureStorage: secureStorage,
apiKey: apiKey,
apiBaseUrl: apiBaseUrl,
);
});

Step 6: Using in Your App

// lib/screens/login_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/auth_service.dart';

class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({Key? key}) : super(key: key);

@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends ConsumerState<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;

Future<void> _handleLogin() async {
setState(() => _isLoading = true);

try {
final authService = ref.read(authServiceProvider);
await authService.login(
_emailController.text,
_passwordController.text,
);

if (mounted) {
// Navigate to home screen
Navigator.of(context).pushReplacementNamed('/home');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login failed: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const CircularProgressIndicator()
: const Text('Login'),
),
],
),
),
);
}
}

Android-Specific Security Configuration

Let's dive deep into Android security settings:

1. Enable ProGuard/R8 Obfuscation

Edit android/app/build.gradle:

android {
buildTypes {
release {
// Enable code shrinking and obfuscation
minifyEnabled true
shrinkResources true

proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'

// Enable security features
signingConfig signingConfigs.release
}
}
}

2. Create ProGuard Rules

Create/edit android/app/proguard-rules.pro:

# Flutter wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }

# Riverpod
-keep class * extends com.riverpod.* { *; }

# Flutter Secure Storage
-keep class com.it_nomads.fluttersecurestorage.** { *; }

# Don't obfuscate anything with @Keep annotation
-keep @androidx.annotation.Keep class * { *; }

# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}

# Remove logging in production
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}

3. Network Security Configuration

Create android/app/src/main/res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Restrict to HTTPS only in production -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>

<!-- Allow HTTP for localhost (debugging only) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>

Reference it in android/app/src/main/AndroidManifest.xml:

<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
<!-- ... -->
</application>

4. Prevent Screenshots (Optional, for sensitive apps)

In android/app/src/main/AndroidManifest.xml:

<application>
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme">

<!-- Add this to prevent screenshots -->
<meta-data
android:name="io.flutter.embedding.android.EnableScreenshots"
android:value="false" />
</activity>
</application>

Or do it in Dart:

import 'package:flutter/services.dart';

void main() {
WidgetsFlutterBinding.ensureInitialized();

// Prevent screenshots on Android
if (Platform.isAndroid) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
}

runApp(const MyApp());
}

5. Root Detection (Optional)

For highly sensitive apps, detect rooted devices:

Add package:

dependencies:
flutter_jailbreak_detection: ^1.10.0

Use it:

import 'package:flutter_jailbreak_detection/flutter_jailbreak_detection.dart';

Future<void> checkDeviceSecurity() async {
bool isJailBroken = await FlutterJailbreakDetection.jailbroken;
bool isDeveloperMode = await FlutterJailbreakDetection.developerMode;

if (isJailBroken || isDeveloperMode) {
// Show warning or disable sensitive features
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Security Warning'),
content: const Text('This device appears to be rooted/jailbroken. Some features may be disabled.'),
),
);
}
}

iOS-Specific Security Configuration

Now let's lock down iOS:

1. Enable Bitcode (if not using third-party libraries that don't support it)

In Xcode:

  • Open ios/Runner.xcworkspace
  • Select Runner project
  • Build Settings β†’ Search "Bitcode"
  • Set "Enable Bitcode" to "Yes"

2. Configure App Transport Security

Edit ios/Runner/Info.plist:

<dict>
<!-- Allow HTTPS only -->
<key>NSAppTransportSecurity</key>
<dict>
<!-- Allow localhost for debugging -->
<key>NSAllowsLocalNetworking</key>
<true/>

<!-- Require HTTPS for all other connections -->
<key>NSAllowsArbitraryLoads</key>
<false/>

<!-- If you need to allow specific HTTP domains (avoid if possible) -->
<key>NSExceptionDomains</key>
<dict>
<key>dev-api.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict>

3. Keychain Access Configuration

We already did this, but to recap β€” ios/Runner/Runner.entitlements:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Keychain access -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.yourcompany.yourapp</string>
</array>

<!-- Optional: If you need iCloud keychain sync -->
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.yourcompany.yourapp</string>
</dict>
</plist>

4. Prevent Screenshots (for sensitive apps)

Unfortunately, iOS doesn't provide a native way to fully prevent screenshots, but you can blur the screen when the app goes to background:

// lib/main.dart

import 'package:flutter/material.dart';

class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);

@override
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
bool _isAppInBackground = false;

@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}

@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
setState(() {
_isAppInBackground = state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive;
});
}

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Stack(
children: [
// Your app content
const HomeScreen(),

// Blur overlay when in background
if (_isAppInBackground)
Container(
color: Colors.white,
child: const Center(
child: Icon(Icons.lock, size: 100, color: Colors.grey),
),
),
],
),
);
}
}

5. Code Obfuscation for iOS

When building for production:

flutter build ios --obfuscate --split-debug-info=build/ios/symbols

This makes reverse engineering much harder.

6. Jailbreak Detection

Using the same package as Android:

import 'package:flutter_jailbreak_detection/flutter_jailbreak_detection.dart';

Future<void> checkiOSSecurity() async {
bool isJailBroken = await FlutterJailbreakDetection.jailbroken;

if (isJailBroken) {
// Handle jailbroken device
}
}

Building for Production with All Security Features

Here's how to build your app with all security features enabled:

Android Release Build

flutter build apk \
--release \
--obfuscate \
--split-debug-info=build/app/outputs/symbols \
--dart-define-from-file=config/prod.json

Or for App Bundle (recommended for Play Store):

flutter build appbundle \
--release \
--obfuscate \
--split-debug-info=build/app/outputs/symbols \
--dart-define-from-file=config/prod.json

iOS Release Build

flutter build ios \
--release \
--obfuscate \
--split-debug-info=build/ios/symbols \
--dart-define-from-file=config/prod.json

Then create archive in Xcode:

  • Open ios/Runner.xcworkspace
  • Select "Any iOS Device" as target
  • Product β†’ Archive
  • Upload to App Store

CI/CD: Keeping Secrets Out of Your Pipeline

When using CI/CD (GitHub Actions, GitLab CI, Bitrise, etc.), you need to inject secrets securely.

GitHub Actions Example

# .github/workflows/build.yml

name: Build Flutter App

on:
push:
branches: [ main ]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- uses: subosito/flutter-action@v2
with:
flutter-version: '3.16.0'

- name: Create config file
run: |
mkdir -p config
echo '${{ secrets.PROD_CONFIG }}' > config/prod.json

- name: Build APK
run: |
flutter build apk \
--release \
--obfuscate \
--split-debug-info=build/symbols \
--dart-define-from-file=config/prod.json

- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: release-apk
path: build/app/outputs/flutter-apk/app-release.apk

Important: Store your config JSON as a GitHub Secret:

  • Go to Repository Settings β†’ Secrets and variables β†’ Actions
  • Add a new secret named PROD_CONFIG
  • Paste your entire prod.json content

Environment Variables in CI/CD

Alternatively, you can pass secrets as individual environment variables:

- name: Build with secrets
env:
API_KEY: ${{ secrets.API_KEY }}
API_BASE_URL: ${{ secrets.API_BASE_URL }}
GOOGLE_MAPS_KEY: ${{ secrets.GOOGLE_MAPS_KEY }}
run: |
flutter build apk \
--release \
--dart-define=API_KEY=$API_KEY \
--dart-define=API_BASE_URL=$API_BASE_URL \
--dart-define=GOOGLE_MAPS_KEY=$GOOGLE_MAPS_KEY

Complete Example: Secure API Client

Let's put it all together with a secure API client:

// lib/services/api_client.dart

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/secrets_provider.dart';
import 'secure_storage_service.dart';

class ApiClient {
final Dio _dio;
final SecureStorageService _secureStorage;
final String _apiKey;

ApiClient({
required String baseUrl,
required String apiKey,
required SecureStorageService secureStorage,
}) : _apiKey = apiKey,
_secureStorage = secureStorage,
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
)) {
_setupInterceptors();
}

void _setupInterceptors() {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Add API key to headers
options.headers['X-API-Key'] = _apiKey;

// Add auth token if available
final token = await _secureStorage.getAuthToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}

return handler.next(options);
},
onError: (error, handler) async {
// Handle 401 (unauthorized) - refresh token logic
if (error.response?.statusCode == 401) {
final refreshToken = await _secureStorage.getRefreshToken();
if (refreshToken != null) {
try {
// Attempt to refresh the token
final newToken = await _refreshToken(refreshToken);
await _secureStorage.saveAuthToken(newToken);

// Retry the original request

Report Page