Securing API Keys in Flutter

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:

  1. Flutter Secure Storage - pub.dev

  2. Encrypt Package - pub.dev

  3. Firebase Remote Config - Firebase Docs

  4. Device Info Plus - pub.dev

  5. Flutter dotenv - pub.dev

  6. Injectable for Dependency Injection - pub.dev

  7. Flutter Fire (Firebase Initialization) - Firebase Docs

This ensures your API keys are not exposed in your code or build artifacts, making your Flutter application more secure.

Did you find this article valuable?

Support Flutter Aware by becoming a sponsor. Any amount is appreciated!