Keeping Your API Keys Safe in Flutter: A Comprehensive Guide
When building a Flutter app that interacts with a third-party API requiring an API key, it's crucial to handle this key securely, especially for production apps. Here are the key steps to ensure maximum security:
Store the API Key on Your Server: Never store the API key directly in your Flutter app. Instead, keep it on a secure server you control.
Avoid Transmitting the API Key to the Client: To prevent man-in-the-middle attacks, the API key should never be sent back to the client. This keeps it safe from interception.
Use Your Server as a Proxy: Have your Flutter app communicate with your server instead of directly with the third-party API. Your server can then make the API requests on behalf of your app. This way, the API key remains securely on your server.
What This Guide Covers
In this guide, we'll explore three different methods for storing API keys on the client side in your Flutter app. Each method comes with its own set of advantages and drawbacks:
Embedding keys in a .dart file: We'll examine how to hard-code API keys directly into your Dart files.
Using command line arguments: Learn how to pass keys via the command line using
--dart-define
or--dart-define-from-file
.Utilizing a .env file with the ENVied package: Discover how to load API keys from a .env file using the ENVied package.
Throughout this guide, we'll adhere to these principles:
Never commit your API keys to version control.
If you must store API keys on the client, ensure they are obfuscated.
By the end of this guide, you'll have a clearer understanding of how to securely store API keys in your Flutter applications.
Are you ready? Let’s dive in!
1. Embedding the Key in a Dart File
A straightforward method to store your API key is to include it directly in a Dart file:
// config.dart
const apiSecret = '12345xyz67890abc';
To ensure the key is not tracked by version control, add a .gitignore
file in the same directory with the following content:
// Exclude config file from version control
config.dart
To use this key in another file, first import the Dart file, then access the key as needed:
// main.dart
import 'config.dart';
void main() {
print('API Key: $apiSecret');
}
While this approach is straightforward, it comes with several disadvantages:
Managing different API keys for various flavors or environments becomes difficult.
The key is stored in plaintext within the
config.dart
file, making it easier for attackers to access.Hardcoding API keys in your source code is risky. If you accidentally add them to version control, they will be preserved in the git history, even if you later add them to the
.gitignore
file.
So let's look at the second option. 👇
2. Using --dart-define flag
Another method is to pass the API key using the --dart-define
flag at compile time.
To run the app with the API key, use the following command:
flutter run --dart-define API_SECRET=12345xyz67890abc
Then, inside your Dart code, you can access the key like this:
const apiSecret = String.fromEnvironment('API_SECRET');
if (apiSecret.isEmpty) {
throw AssertionError('API_SECRET is not set');
}
Compiling and Running the App with --dart-define
The main advantage of using --dart-define
is that sensitive keys are no longer hardcoded in the source code. However, when we compile the app, the keys will still be embedded in the release binary.
To mitigate risks, we can obfuscate our Dart code when creating a release build
However, using many keys --dart-define
can become impractical. For example, running the app with multiple keys might look like this:
flutter run \
--dart-define KEY1=abc123xyz456def789 \
--dart-define KEY2=pk_test_abcdef1234567890ghijkl \
--dart-define KEY3=https://randomstring123@anotherexample.io/1234567
This approach can be cumbersome when dealing with numerous keys.
Is There a Better Way?
New in Flutter 3.7: use --dart-define-from-file
Since Flutter 3.7, we can store all the API keys inside a JSON file and pass it using the new --dart-define-from-file
flag from the command line.
This way, we can do:
flutter run --dart-define-from-file=secrets.json
Then, we can add all the keys inside secrets.json
(which should be .gitignored
):
{
"API_SECRET_1": "dummyApiKey123456",
"API_SECRET_2": "dummyKeyTest890abc",
"API_SECRET_3": "https://dummyKey123@service.example.com/098765"
}
This solution is quite convenient.
And if we want, we can even combine it with launch configurations. 👇
Using Dart Defines Inside launch.json in VSCode
If we use VSCode, we can edit the .vscode/launch.json
file and add some arguments to our launch configuration:
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": [
"--dart-define-from-file",
"secrets.json"
]
}
]
}
Moreover, we can define multiple launch configurations with different sets of API keys if needed (secrets.dev
.json
, secrets.prod
.json
, etc.).
If you use IntelliJ or Android Studio, you can use run/debug configurations to achieve the same result.
However, this leads to a potential issue:
If we hardcode the API keys inside
secrets.json
, we need to add it to.gitignore
because keys should not be added to version control.If
secrets.json
is gitignored and we perform a new checkout of the project, we won't be able to run it until we createsecrets.json
again and set the API key(s).
Overall, this is a manageable issue.
3. Retrieving API Keys from a .env File
The .env file is a widely used format designed to provide developers with a secure, centralized location for storing sensitive application data, such as API keys.
To utilize this in a Flutter project, you can create a .env
file at the root of your project directory:
# Sample .env file
MOVIE_API_KEY=abc123def456ghi789jkl
Since this file contains sensitive information like your API key, it's important to include it in your .gitignore
file to prevent it from being committed to version control:
# Ignore all .env files in version control
*.env
Introducing ENVied
The ENVied package is designed to generate a Dart class that holds the values from your .env
file.
For instance, if your .env
file contains an API key like this:
# Sample .env file
MOVIE_API_KEY=abc123def456ghi789jkl
You can create a Dart file, config.dart
, with the following content:
import 'package:envied/envied.dart';
part 'config.g.dart';
@Envied(path: '.env')
class Config {
@EnviedField(varName: 'MOVIE_API_KEY')
static const String movieApiKey = _Config.movieApiKey;
}
Afterward, run the following command:
dart run build_runner build -d
This command uses build_runner
to generate the config.g.dart
file, which will look something like this:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'config.dart';
// **************************************************************************
// EnviedGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: type=lint
class _Config {
static const String movieApiKey = 'abc123def456ghi789jkl';
}
Now, you can import config.dart
in your code and access the movieApiKey
whenever needed.
What About Obfuscation?
So far, we’ve successfully created a movieApiKey
constant derived from our .env
file.
However, this key is still stored in plain text, which could be vulnerable if an attacker attempts to reverse-engineer the application and retrieve the key.
To enhance the security of our API key, we can apply obfuscation.
This can be done by adding the obfuscate: true
flag within the @EnviedField
annotation:
import 'package:envied/envied.dart';
part 'config.g.dart';
@Envied(path: '.env')
class Config {
@EnviedField(varName: 'MOVIE_API_KEY', obfuscate: true)
static final String movieApiKey = _Config.movieApiKey;
}
It's important to note that any variables marked with the obfuscate flag should be declared as final
, rather than const
.
Next, re-run the code generation process with the following command:
dart run build_runner build -d
If we examine the newly generated config.g.dart
file, we’ll notice that the API key is now obfuscated:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'config.dart';
// **************************************************************************
// EnviedGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: type=lint
class _Config {
static const List<int> _enviedKeyMovieApiKey = <int>[
3083777460,
1730462941,
// other obfuscated data
];
static const List<int> _enviedDataMovieApiKey = [
3083777414,
1730462956,
// other obfuscated data
];
static final String movieApiKey = String.fromCharCodes(List<int>.generate(
_enviedDataMovieApiKey.length,
(int i) => i,
growable: false,
).map((int i) => _enviedDataMovieApiKey[i] ^ _enviedKeyMovieApiKey[i]));
}
Great! The API key is no longer hardcoded, making it significantly more difficult for attackers to extract, even if they decompile the app. 🚀