If your Flutter app talks to a 3rd party API that requires an API key, where should you store it?
According to various sources, for production apps that need maximum security:
- the API key should be stored on your own secure server (and never on the client)
- it should never be transmitted back to the client (to prevent man-in-the-middle attacks)
- the client should only communicate with your server, which acts as a proxy for the 3rd party API you intend to use
This is because storing API keys on the client is insecure and can cause various issues if they become compromised.
But not all keys are created equal: some keys can be accessed by the client while others must be secret and stored securely on the server (the Stripe documentation does a good job explaining this).
In fact, this answer on StackOverflow offers a good summary:
In the end, it's an economic trade-off that you have to make: how important are the keys, how much time or software can you afford, how sophisticated are the hackers who are interested in the keys, how much time will they want to spend, how much worth is a delay before the keys are hacked, on what scale will any successful hackers distribute the keys, etc. Small pieces of information like keys are more difficult to protect than entire applications. Intrinsically, nothing on the client-side is unbreakable, but you can certainly raise the bar.
Without a doubt, there is much to learn about mobile app security (entire books could be written about it).
So let me offer some context about what we will cover here. π
Some Context
If you're like me and have many open source demo apps that may never make it to production π , you may be tempted to store less sensitive API keys on the client (at least early on in the development cycle).
And when it comes to API keys and security, you should avoid two major mistakes:
- Committing a secret key to version control, making it visible to everyone on the Internet π€―
- Forgetting to obfuscate your API keys, making it easier for attackers to reverse engineer your app and extract the keys π
As part of this guide, we'll learn how to avoid these mistakes.
What is Covered in This Guide
We'll take a look at three different techniques for storing API keys on the client (your Flutter app), along with their trade-offs:
- Hard-coding keys inside a
.dart
file - Passing keys as command line arguments with
--dart-define
or--dart-define-from-file
- Loading keys from a
.env
file with the ENVied package
Along the way, we'll keep these rules in mind:
- Never add your API keys to version control
- If you store API keys on the client, make sure to obfuscate them
By the end, you'll better understand how to store API keys safely.
And I'll also include a security checklist that you can follow in your Flutter projects.
β οΈ These techniques are not fail-proof. If you have an API key that you can't afford to lose, you should store it on the server (and if you're using Firebase, check out my guide about securing API Keys with 2nd-Gen Cloud Functions). Secure client-server communication involves many considerations beyond the scope of this article (see the links at the bottom for more details).
Ready? Let's get started! π
1. Hard-coding the key inside a Dart file
A simple and effective way to store our API key is to save it into a dart file like this:
// api_key.dart
final tmdbApiKey = 'a1b2c33d4e5f6g7h8i9jakblc';
To ensure the key is not added to git, we can add a .gitignore
file in the same folder with these contents:
# Hide key from version control
api_key.dart
If we have done this correctly, the file should appear like this in the explorer:
And if we need to use the key anywhere, we can import api_key.dart
and read it.
Here's an example that uses the dio package to fetch data from the TMDB API:
import 'api_key.dart'; // import it here
import 'package:dio/dio.dart';
Future<TMDBMoviesResponse> fetchMovies() async {
final url = Uri(
scheme: 'https',
host: 'api.themoviedb.org',
path: '3/movie/now_playing',
queryParameters: {
'api_key': tmdbApiKey, // read it here
'include_adult': 'false',
'page': '$page',
},
).toString();
final response = await Dio().get(url);
return TMDBMoviesResponse.fromJson(response.data);
}
This approach is very simple, but it has some drawbacks:
- It's hard to manage different API keys for different flavors/environments
- The key is stored in plaintext in the
api_key.dart
file, making the attacker's job easier
We should never hardcode API keys in our source code. If we add them to version control by mistake, they will remain in the git history, even if we gitignore them later.
So let's look at the second option. π
2. Passing the key using --dart-define
An alternative approach is to pass the API key with the --dart-define
flag at compile time.
This means that we can run the app like this:
flutter run --dart-define TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc
Then, inside our Dart code, we can do:
const tmdbApiKey = String.fromEnvironment('TMDB_KEY');
if (tmdbApiKey.isEmpty) {
throw AssertionError('TMDB_KEY is not set');
}
// TODO: use api key
The
String.fromEnvironment
method allows us to specify an optionaldefaultValue
, which acts as a fallback if the key is not set. But as we said, we shouldn't hardcode the API key inside our code (whether it's gitignored or not), so it's not a good idea to usedefaultValue
here.
Compiling and running the app with --dart-define
The main advantage of using --dart-define
is that we're no longer hardcoding sensitive keys in the source code.
But when we compile our app, the keys are still going to be baked in the release binary:
To mitigate risk, we can obfuscate our Dart code when we make a release build (more on this below).
Also, it becomes impractical to run the app if we have many keys:
flutter run \
--dart-define TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc \
--dart-define STRIPE_PUBLISHABLE_KEY=pk_test_aposdjpa309u2n230ibt23908g \
--dart-define SENTRY_KEY=https://aoifhboisd934y2fhfe@a093qhq4.ingest.sentry.io/2130923
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 to a new --dart-define-from-file
flag from the command line.
This way, we can do:
flutter run --dart-define-from-file=api-keys.json
Then, we can add all the keys inside api-keys.json
(which should be .gitignored):
{
"TMDB_KEY": "a1b2c33d4e5f6g7h8i9jakblc",
"STRIPE_PUBLISHABLE_KEY": "pk_test_aposdjpa309u2n230ibt23908g",
"SENTRY_KEY": "https://aoifhboisd934y2fhfe@a093qhq4.ingest.sentry.io/2130923"
}
This solution is quite nice.
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 args
to our launch configuration:
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": [
"--dart-define-from-file",
"api-keys.json"
]
}
]
}
Moreover, we can define multiple launch configurations with different sets of API keys if needed (api-keys.dev.json
, api-keys.prod.json
, etc.).
If you use IntelliJ or Android Studio, you can use run/debug configurations to achieve the same result.
But as it turns out, this leads to a chicken and egg problem. π£
- If we hardcode the API keys inside
api-keys.json
, we have to add it to.gitignore
(because keys should not be added to version control). - If
api-keys.json
is gitignored and we do a new checkout of the project, we won't be able to run it until we createapi-keys.json
again and set the API key(s).
Overall, this is not a big issue.
Note that any variables defined with
--dart-define
and--dart-define-from-file
are on the Dart side, but not yet on the native side. To learn more, read: Allow to provide compile-time variables for native platforms from a file
Next up, let's try a different approach that uses .env
files. π
3. Loading the key from a .env file
.env
is a popular file format that was introduced to give developers a single secure place to store sensitive application secrets, such as API keys.
To use this with Flutter, we can add a .env
file at the root of the project:
# example .env file
TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc
# add more keys here if needed
And since this file contains our API key, we should add it to .gitignore
:
# exclude all .env files from source control
*.env
Then, we can go to pub.dev and find a package that helps us deal with .env
files.
Enter ENVied
The ENVied package helps us generate a Dart class that contains the values from our .env
file.
For example, given this .env
file that contains our API key:
# example .env file
TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc
We can create an env.dart
file that looks like this:
import 'package:envied/envied.dart';
part 'env.g.dart';
@Envied(path: '.env')
final class Env {
@EnviedField(varName: 'TMDB_KEY')
static const String tmdbApiKey = _Env.tmdbApiKey;
}
Then, we can run this command:
dart run build_runner build -d
And this will use build_runner to generate an env.g.dart
file that looks like this:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'env.dart';
// **************************************************************************
// EnviedGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: type=lint
final class _Env {
static const String tmdbApiKey = 'a1b2c33d4e5f6g7h8i9jakblc';
}
As a result, we can import env.dart
and access the tmdbApiKey
as needed.
And since our precious API key is hardcoded inside env.g.dart
, we should add it to .gitignore
:
# exclude the API key from version control
env.g.dart
As a result, we should see the following files in the project explorer:
What about Obfuscation?
So far, we've managed to generate a tmdbApiKey
constant from our .env
file.
But this is still stored in plaintext, and if an attacker tries to reverse engineer our app, they may be able to extract the key.
To make our API key more secure, we can use obfuscation.
This is done by adding the obfuscate: true
flag in the @EnviedField
annotation:
import 'package:envied/envied.dart';
part 'env.g.dart';
@Envied(path: '.env')
final class Env {
@EnviedField(varName: 'TMDB_KEY', obfuscate: true)
static final String tmdbApiKey = _Env.tmdbApiKey;
}
Any variables annotated with the
obfuscate
flag should be declared asfinal
(notconst
).
Then, we can re-run the code generation step:
dart run build_runner build -d
And if we inspect the generated env.g.dart
file, we'll see that the API key has been obfuscated:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'env.dart';
// **************************************************************************
// EnviedGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: type=lint
final class _Env {
static const List<int> _enviedkeytmdbApiKey = <int>[
3083777460,
1730462941,
// many other lines
];
static const List<int> _envieddatatmdbApiKey = [
3083777414,
1730462956,
// many other lines
];
static final String tmdbApiKey = String.fromCharCodes(List<int>.generate(
_envieddatatmdbApiKey.length,
(int i) => i,
growable: false,
).map((int i) => _envieddatatmdbApiKey[i] ^ _enviedkeytmdbApiKey[i]));
}
Excellent! The API key is no longer hardcoded, making it much harder to extract if an attacker decompiles our app. π
See the Usage section of the ENVied package to learn how to use it with multiple environments/flavors.
API Keys: Code Generation vs Reading at Runtime
Using code generation and obfuscation makes our API keys more secure (while not 100% fail-proof) against reverse engineering attempts.
In contrast, packages such as flutter_dotenv work by adding the .env
file to the assets folder and reading its contents at runtime. This is very insecure because any asset file can easily be extracted by unzipping the release APK, thus exposing the environment variables.
So don't make the mistake of using flutter_dotenv for your API keys. Instead, use the ENVied package and enable obfuscation.
API Keys Security Checklist
If you choose to use .env
files with the ENVied package, follow these steps to secure your API keys:
- create a
.env
file to store your API keys in plaintext - add that
.env.
file to.gitignore
- install the ENVied package
- create an
env.dart
file and define theEnv
class with one field for each API key, usingobfuscate: true
- run the code generator
- add the
env.g.dart
file to.gitignore
- import
env.dart
and read the API key as needed
For an example that uses this approach, check my movies app on GitHub:
Which option to choose?
We have now explored three techniques for storing API keys on the client:
- Hard-coding keys inside a
.dart
file (not recommended) - Passing keys as command line arguments with
--dart-define
or--dart-define-from-file
- Loading keys from a
.env
file with the ENVied package
So which one should we choose?
Options 1 and 3 are similar as they both require the API key(s) to be stored in a Dart file. For this reason, the file must not be added to version control at any point (as the entire git history can be searched).
One advantage of option 3 is that the keys can be obfuscated during the code generation step, adding one extra layer of protection.
Option 2 is the official way of defining custom environment variables, and the introduction of --dart-define-from-file
makes it more practical.
For most apps, I prefer option 3 because it's convenient to store all the keys inside a single .env
file.
However, there is one use case where option 2 is preferable. π
Accessing the keys on the native Android and iOS side
Certain Flutter plugins such as Google Maps require that the key is stored inside AndroidManifest.xml
or AppDelegate.swift
.
Prior to Flutter 3.16, it was possible to define the keys using --dart-define
and access them on the native side by following the steps in this article:
However, this functionality was removed on Flutter 3.16, and this proposal aims to bring it back in a future release:
While we wait for official support in the Flutter SDK, we can use the flutterenvnative package to provide access to make any environment variables available to the native platforms. For more info, read this:
Note about Obfuscation
No matter which option you choose, it's a good idea to obfuscate your entire code when you build a release version of your app, and the official documentation explains how to do this:
And while we can use obfuscation to mitigate risk when storing API keys on the client, highly sensitive keys should be kept on the server.
So make sure you read the documentation of your API provider of choice and follow the recommended guidelines.
How to securely share API keys with other team members?
There is one last question that we need to answer:
If we don't store our API keys in version control, how can we share them with other team members or retrieve them when we checkout our project on a different machine?
The solution is to store them in a secure vault load them into our Flutter project using a CLI tool.
Tools such as 1Password CLI are very well suited for this, and I managed to integrate it into my workflow in just a few minutes by following the docs.
Alternatively, services such as Doppler take a completely different approach to app security and eliminate .env
files altogether. However, I haven't tried using Doppler with Flutter projects yet.
Additional Resources
For more details about mobile application security, check out these resources:
- Why OAuth API Keys and Secrets Aren't Safe in Mobile Apps
- Best practice for storing and protecting private API keys in applications
- How to secure API keys? | r/FlutterDev
- Are compile-time variables secure in flutter?
- How Developers Secure Flutterβ’ Mobile Apps in 2022
- Compiler-Based Mobile App Security vs. App Shielding and No-Code Mobile App Security
- What to do when you canβt protect mobile app secret keys?
- Learn about using and managing API keys for Firebase
And if you don't want to miss my next articles, sign up for my newsletter: π