How to Store API Keys in Flutter: --dart-define vs .env files

Source code on GitHub

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:

  1. Hard-coding keys inside a .dart file
  2. Passing keys as command line arguments with --dart-define or --dart-define-from-file
  3. 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:

File explorer after adding api_key.dart to .gitignore
File explorer after adding api_key.dart to .gitignore

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 optional defaultValue, 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 use defaultValue 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:

API keys and source code are combined to produce the release binary
API keys and source code are combined to produce 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 create api-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:

File explorer after adding api_key.dart to .gitignore
File explorer after adding api_key.dart to .gitignore

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 as final (not const).

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 the Env class with one field for each API key, using obfuscate: 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:

  1. Hard-coding keys inside a .dart file (not recommended)
  2. Passing keys as command line arguments with --dart-define or --dart-define-from-file
  3. 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:

And if you don't want to miss my next articles, sign up for my newsletter: πŸ‘‡

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter In Production

Flutter In Production

Learn about flavors, environments, error monitoring, analytics, release management, CI/CD, and finally ship your Flutter apps to the stores. πŸš€

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Last updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.