Flutter API keys: keep test keys out of production builds

Flutter API keys from one env file put a test-store key into release builds. How I scoped it to staging and proved it: 0 hits in production, 1 in staging.

By

A common way to handle Flutter API keys is one env file passed at build time, and most of the time that is fine. The trouble starts when the file holds keys for different environments and every build gets all of them. On a Flutter subscription app I work on, that is how a test-store key for in-app purchases ended up compiled into production binaries. This post covers how it happened, how I moved the key so production builds never contain it, how I proved that with a canary build, and the gotchas around staging release builds.

How one env file put a test key into production

The app uses RevenueCat for purchases, which means three SDK keys: a test-store key for staging, and App Store and Play keys for production. All of them come from one env file passed with --dart-define-from-file. An example file with dummy values is committed, and the real file is gitignored. That part was right.

The problem was the next step. That env file went into every build, staging and production alike, and a shared service read the staging key. Because the code that referenced the test key was compiled into every build, its value was in every binary. I found it in a production APK, a production AAB and an iOS archive.

This is fixed. Production builds no longer contain the test key, and I explain below how I checked.

Why a test-store key in a release build is a security hole

Mobile SDK keys for purchases are public by design. They identify your app to the provider, and anyone can pull them out of a binary. That is expected for the App Store and Play keys.

A test-store key is different. Purchases made through the test store cost nothing, and RevenueCat's Test Store documentation says they behave like real purchases: they update customer info and trigger entitlements. So anyone who extracted the test key from a production binary could make fake purchases that the provider reports as real entitlements. The same docs are blunt about it: never submit an app with a Test Store API key.

The server side matters too. On the backend of a subscription app I work on, production now ignores sandbox purchases unless a flag allows them, which I wrote up in securing an in-app purchase webhook. Defence on both sides is what I want for anything that grants paid access.

Scope Flutter API keys to the entry point that ships them

The fix was to make the staging entry point the only code that reads the test key. A production build never compiles main_staging.dart, so the reference to that define is never in the program, and the value is never in the binary even though the same env file is passed in.

The shape looks like this (define names are examples):

// lib/main_staging.dart
import 'bootstrap.dart';

void main() {
  bootstrap(purchasesKey: const String.fromEnvironment('RC_TEST_STORE_KEY'));
}

// lib/main_production.dart
import 'dart:io' show Platform;

import 'bootstrap.dart';

void main() {
  bootstrap(
    purchasesKey: Platform.isIOS
        ? const String.fromEnvironment('RC_APP_STORE_KEY')
        : const String.fromEnvironment('RC_PLAY_STORE_KEY'),
  );
}

Shared code receives the key as a parameter and never reads a define itself. That rule is easy to check in review: search for fromEnvironment and make sure each environment-specific name appears only in its own entry point. Builds pick the entry point with -t lib/main_staging.dart or -t lib/main_production.dart.

Prove the key is gone with a canary build

Reading the code and deciding the key "should" be gone is how the original bug shipped. I wanted a test that fails loudly if it ever comes back.

So I built with a canary: an env file where the test-key slot holds a unique string that appears nowhere else. Then I searched the built APKs for it. The production APK built with it had 0 occurrences of the canary string. The staging APK had 1. That pair is the proof: the search works (staging finds it) and production is clean.

flutter build apk --release -t lib/main_production.dart \
  --dart-define-from-file=env.canary.json

unzip -p build/app/outputs/flutter-apk/app-release.apk \
  | grep -a -o 'CANARY_TEST_KEY_7f3a' | wc -l

Run the same thing with the staging entry point and expect 1. If your app uses flavors, the output path changes, so adjust it. I like this kind of check because it tests the artifact you ship instead of the code you meant to write. I use the same idea for refactors in verifying a refactor by diffing old and new output.

Refuse a test key in any production release

Scoping the key to staging fixes the build. It does not stop a person from pasting a test key into a production slot of the env file by mistake.

So there is a second guard: a production release never accepts a key with the test prefix, even if one is put in a live slot. Mistakes in env files are ordinary. A guard like this turns one into a build that refuses the key, which someone notices long before a store reviewer or an attacker does.

Staging release builds need their own handling

There is a related gotcha. The provider SDK closes release builds that are configured with a test key. That is a sensible safeguard for production, and RevenueCat documents it: on iOS any build compiled without the DEBUG flag counts as release, and on Android any build that is not debuggable does.

But a staging build you hand to testers is often a release build, and it legitimately uses the test key. So staging release builds needed specific handling on each platform:

  • Android: the staging variant is debuggable, so the SDK treats it as a test build.
  • iOS: the SDK comes in through Swift Package Manager, and its compile-time check can only be turned off from the xcodebuild command line, so that is where the staging iOS build turns it off.

Whatever you do here, keep it confined to the staging configuration. The point of the SDK check is to protect production, and you want it fully on there.

Should secrets live in a Flutter app at all?

No real secret should be compiled into a mobile app, because anything in the binary can be extracted. Keys that are public by design, like purchase SDK keys, are fine to ship as long as each build contains only the ones it needs. Anything that grants access or costs money, such as an AI provider key or a server credential, belongs on your backend, as I describe in adding AI features to Flutter apps without leaking API keys.

The short version

  • Pass keys with --dart-define-from-file, commit an example file and gitignore the real one.
  • Read each environment's keys only in that environment's entry point.
  • Prove it with a canary build: 0 in production, 1 in staging.
  • Refuse test keys in production releases, and confine any SDK override to staging.

If you want a review of how your mobile app handles keys and builds, that is part of the services I offer.

Want this kind of work on your product?

Get in touch