Securing API Keys in Flutter
Implementing Secure Storage and Encryption for API Keys in Flutter
Introduction
Securing API keys in a Flutter application is essential to prevent unauthorized access to sensitive resources. API keys are often used for authentication with external services, but if exposed, they can lead to security vulnerabilities. In this guide, we will discuss how to securely store and manage API keys using Firebase Remote Config, Flutter Secure Storage, AES encryption, and device-specific identifiers.
There are several ways to manage API keys securely, including:
CI/CD Solutions: Services like Codemagic, CircleCI, and GitHub Actions allow you to store API keys as environment variables to keep them out of your codebase.
Backend Storage: Keeping API keys on a backend server and fetching them dynamically is another secure approach.
Keystore & Keychain: On Android and iOS, API keys can be securely stored using the device's built-in keystore mechanisms.
Encrypted Storage: Using encrypted local storage solutions to save API keys on the device.
For this guide, we will focus on using Firebase Remote Config to securely retrieve API keys, encrypt them before storing them locally, and decrypt them when needed.
Why Secure API Keys?
Publicly exposing API keys in your Flutter application can lead to unauthorized access and potential abuse. This can result in quota exhaustion, service disruptions, or even security breaches. Using Firebase Remote Config, encryption, and secure local storage, we can keep API keys safe.
Project Structure
We will structure our implementation as follows:
remote_config.dart
: Handles fetching and encrypting API keys.global_config.dart
: Initializes Firebase, loads environment variables, and ensures API keys are available.main.dart
: Starts the application and initializes configurations.app_strings.dart
: Stores constant values used throughout the project.
Step 1: Setting Up Environment Variables
Create a .env
file in your Flutter project root directory and define your encryption key:
ENCRYPTION_KEY=32-character-secure-key-here
Add flutter_dotenv
to your pubspec.yaml
:
dependencies:
flutter:
sdk: flutter
encrypt: ^5.0.3
flutter_dotenv: ^5.2.1
device_info_plus: ^11.3.0
firebase_remote_config: ^5.4.0
flutter_secure_storage: ^9.0.0
Run:
flutter pub get
Step 2: Secure Storage and Encryption
app_strings.dart
Define constants used throughout the project:
class AppStrings {
static const String ENCRYPTION_KEY = "ENCRYPTION_KEY";
static const String DEVICE_ID = "DEVICE_ID";
static const String YOU_VERIFY_API_KEY = "YOU_VERIFY_API_KEY";
static const String GEMINI_API_KEY = "GEMINI_API_KEY";
}
remote_config.dart
Handles secure retrieval and storage of API keys using AES encryption:
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:firebase_remote_config/firebase_remote_config.dart';
import '../constants/app_strings.dart';
import '../../../domain/models/custom_error/custom_error.dart';
import 'package:encrypt/encrypt.dart' as encrypt;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class RemoteConfig {
static final FlutterSecureStorage _storage = FlutterSecureStorage();
static encrypt.Encrypter? _encrypter;
// Initialize AES encryption
static Future<void> initializeEncrypter() async {
encrypt.Key key = await _generateEncryptionKey();
_encrypter = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.cbc));
}
static encrypt.Encrypter getEncrypter() {
if (_encrypter == null) {
initializeEncrypter();
}
return _encrypter!;
}
// Generate a secure encryption key using env variable and device ID
static Future<encrypt.Key> _generateEncryptionKey() async {
String envKey = dotenv.env[AppStrings.ENCRYPTION_KEY] ?? "default_secure_key";
String deviceId = await _getDeviceId();
String combinedKey = (envKey + deviceId).substring(0, 32);
return encrypt.Key.fromUtf8(combinedKey);
}
// Fetch device ID and store it securely
static Future<String> _getDeviceId() async {
String? storedDeviceId = await _storage.read(key: AppStrings.DEVICE_ID);
if (storedDeviceId != null) {
return storedDeviceId;
}
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
String deviceId;
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
deviceId = androidInfo.id;
} else if (Platform.isIOS) {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
deviceId = iosInfo.identifierForVendor ?? "fallbackDeviceId";
} else {
deviceId = "fallbackDeviceId";
}
await _storage.write(key: AppStrings.DEVICE_ID, value: deviceId);
return deviceId;
}
// Fetch and encrypt API keys
static Future<void> fetchApiKey({required String apiKeyName}) async {
String key = '';
try {
final remoteConfig = FirebaseRemoteConfig.instance;
await remoteConfig.setConfigSettings(
RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 10),
minimumFetchInterval: const Duration(seconds: 10),
),
);
await remoteConfig.fetchAndActivate();
key = remoteConfig.getString(apiKeyName);
} catch (e) {
if (kDebugMode) {
print(e);
}
throw CustomError(
errorMsg: "ERROR Retrieving $apiKeyName (${e.toString()})",
code: "configuration_error",
plugin: "",
);
}
final iv = encrypt.IV.fromSecureRandom(16);
final encryptedKey = _encrypter?.encrypt(key, iv: iv).base64;
await _storage.write(key: apiKeyName, value: encryptedKey);
await _storage.write(key: "${apiKeyName}_iv", value: iv.base64);
}
static final Map<String, String> _decryptedKeysCache = {};
// Retrieve and decrypt stored API keys
static Future<String?> getApiKey({required String key}) async {
if (_decryptedKeysCache.containsKey(key)) {
return _decryptedKeysCache[key];
}
try {
final encryptedKey = await _storage.read(key: key);
final ivString = await _storage.read(key: "${key}_iv");
if (encryptedKey != null && ivString != null) {
final iv = encrypt.IV.fromBase64(ivString);
final encrypted = encrypt.Encrypted.fromBase64(encryptedKey);
final decryptedKey = _encrypter?.decrypt(encrypted, iv: iv);
_decryptedKeysCache[key] = decryptedKey!;
return decryptedKey;
}
} catch (e) {
throw CustomError(
errorMsg: "ERROR Retrieving $key (${e.toString()})",
code: "configuration_error",
plugin: "",
);
}
return null;
}
}
Step 3: Global Initialization
global_config.dart
Handles Firebase initialization, dependency injection, and API key retrieval:
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:injectable/injectable.dart';
import 'remote_config.dart';
import 'app_strings.dart';
class GlobalConfig {
static Future<void> fetchRequiredApiKeys() async {
final apiKeys = [
AppStrings.YOU_VERIFY_API_KEY,
AppStrings.GEMINI_API_KEY,
];
for (final keyName in apiKeys) {
await RemoteConfig.fetchApiKey(apiKeyName: keyName);
}
}
static Future<void> initConfig() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await dotenv.load(fileName: ".env");
await RemoteConfig.initializeEncrypter();
await fetchRequiredApiKeys();
}
}
Step 4: Utilizing the API Key in UI
main.dart
Initializes the application:
import 'package:flutter/material.dart';
import 'global_config.dart';
Future<void> main() async {
await GlobalConfig.initConfig();
runApp(MyApp());
}
Fetching API Key in Widget
String apiKey = "";
@override
void initState() {
super.initState();
fetchAPIKey();
}
void fetchAPIKey() async {
try {
final key = await RemoteConfig.getApiKey(key: AppStrings.GEMINI_API_KEY) ?? "";
setState(() {
apiKey = key;
});
} catch (e) {
print("Error fetching API key: $e");
}
}
Conclusion
By following this approach, API keys are:
Fetched dynamically from Firebase Remote Config.
Encrypted before storing in secure storage.
Decrypted only when needed.
Protected using device-specific encryption keys.
References:
This ensures your API keys are not exposed in your code or build artifacts, making your Flutter application more secure.