Security
12

API Keys in Flutter: What 'Secret' Means and Where Secrets Belong

Flutter API Keys: Where Secrets Actually Belong

March 23, 2026

Your API key is not hidden. It does not matter how you put it there. If the key is compiled into your Flutter binary, someone can extract it. The question is whether that matters — and for some keys, it genuinely does not. For others, it is a disaster waiting to happen.

This post walks through the difference, because the Flutter ecosystem has a blind spot here. Tutorials routinely show --dart-define as though it were a security measure. It is not. But the answer is not "panic and move everything server-side" either. Some keys are designed to live in client apps. The trick is knowing which ones.

How easy extraction actually is

Let us start with something concrete. Suppose you published an Android app to the Play Store. Someone downloads the APK — either from the store directly or from a mirror site like APKMirror. Here is what happens next:

bash
# Rename the .apk to .zip and extract it
mv app.apk app.zip
unzip app.zip -d extracted/

# The Dart compiled code lives in the shared library
strings extracted/lib/arm64-v8a/libapp.so | grep -i "sk_live"
strings extracted/lib/arm64-v8a/libapp.so | grep -i "api_key"
strings extracted/lib/arm64-v8a/libapp.so | grep -i "Bearer"

That is it. Three commands. The strings utility ships with every Linux and macOS installation. It extracts human-readable text from binary files. Your Dart string constants — including API keys, URLs, and anything else you defined as a const — survive compilation and sit in that binary as plain text.

On iOS, the process is slightly harder because you need a jailbroken device or a decrypted IPA, but the principle is identical. The compiled Dart snapshot contains your string literals.

This is not a theoretical concern. Automated scanners crawl APK repositories and extract credentials at scale. Security researchers have documented bots that find exposed AWS keys within minutes of them appearing in any publicly accessible binary or repository. Your app does not need to be popular to be targeted. The scanning is indiscriminate.

The `--dart-define` misconception

Flutter's --dart-define flag (and the newer --dart-define-from-file) lets you inject values at compile time:

bash
flutter build apk --dart-define=API_KEY=sk_live_abc123
dart
const apiKey = String.fromEnvironment('API_KEY');

This is useful. It keeps secrets out of your source code and your git history, which matters. If your repository is public — or if it becomes public accidentally — the key is not sitting in a committed .dart file for anyone to find.

But that is all it does. It is a source code protection measure, not a binary protection measure.

At compile time, String.fromEnvironment('API_KEY') resolves to a string literal: "sk_live_abc123". That literal is baked into libapp.so exactly as if you had written const apiKey = "sk_live_abc123"; directly in your code. The strings command extracts it just the same.

Developers often assume that because the value is not visible in their source files, it is not visible in the app. This is wrong. The compiler substitutes the value. The binary contains the result of that substitution.

--dart-define protects against:

  • Keys appearing in version control
  • Keys being visible to every developer with repo access
  • Keys leaking through source code scanning tools like GitGuardian or TruffleHog

--dart-define does not protect against:

  • Extraction from the compiled binary
  • Reverse engineering of the APK or IPA
  • Network traffic interception (the key is sent over the wire when you use it)

If the key must remain secret from end users, --dart-define is not the answer. The key simply cannot be in the binary at all.

The taxonomy of keys

Not all API keys carry the same risk. The industry has settled on a rough classification, and understanding it saves you from both complacency and unnecessary paranoia.

Publishable keys: safe in the binary

These keys are designed to be embedded in client applications. The service provider expects them to be publicly visible and has built restrictions around them.

Stripe publishable key (pk_live_...): This key can only create tokens and confirm payments. It cannot charge cards, issue refunds, or access customer data. Stripe's entire client-side SDK is built around the assumption that this key is public.

Google Maps API key (with restrictions): When properly restricted to your Android package name, SHA-1 fingerprint, and iOS bundle ID, this key only works from your app. Someone who extracts it cannot use it from their own application. More on this below.

Firebase configuration: The google-services.json and GoogleService-Info.plist files contain project identifiers, not secrets. Firebase Security Rules control access, not the config file. Google's own documentation states these values are safe to include in client code.

Analytics write keys: Services like Segment, Mixpanel, and Amplitude provide write-only keys. They can send events but cannot read data, modify configuration, or access other users' information.

Secret keys: never in the binary

These keys grant privileged access. If someone obtains them, they can act as you.

Stripe secret key (sk_live_...): Full access to your Stripe account. Create charges, issue refunds, read customer payment details, modify subscriptions. A leaked secret key is a financial emergency.

OpenAI API key: Billed to your account. Someone with your key can run unlimited requests. There is no per-app restriction — the key works from anywhere. Leaked keys have resulted in bills running to thousands of dollars overnight.

Database credentials: Direct access to your database. Read, write, delete. No further explanation needed.

Webhook signing secrets: Used to verify that incoming webhooks genuinely come from the service provider. If leaked, an attacker can forge webhook payloads, potentially triggering actions in your system (like marking unpaid orders as paid).

Any key with write or admin scope: If the key can modify data, access other users' information, or incur charges, it belongs on the server.

Ambiguous keys: publishable but risky without restrictions

Some keys sit in a grey area. They are technically safe for client use, but only if you configure restrictions at the provider level.

A Google Maps API key without restrictions is a good example. It works from any referrer, any IP, any application. Someone who extracts it can use it in their own project, and the API calls bill to your account. The key itself is not secret — but without restrictions, it is an open chequebook.

The same applies to Firebase API keys in projects where Security Rules are misconfigured (or absent). The key is publishable by design, but if your Firestore rules allow read, write: if true, the key effectively grants unrestricted database access.

The rule of thumb: if a key is meant to be publishable, check that the provider-side restrictions are actually in place. Do not assume the defaults are safe.

The backend proxy pattern

When you need functionality that requires a secret key, the solution is straightforward: your Flutter app talks to your backend, and your backend talks to the third-party service. The secret key never leaves the server.

Here is the wrong way — calling the OpenAI API directly from Flutter with a secret key embedded in the app:

dart
// DO NOT DO THIS — secret key in the binary
class ChatService {
  static const _apiKey = String.fromEnvironment('OPENAI_API_KEY');

  Future<String> getCompletion(String prompt) async {
    final response = await http.post(
      Uri.parse('https://api.openai.com/v1/chat/completions'),
      headers: {
        'Authorization': 'Bearer $_apiKey',  // Extractable from binary
        'Content-Type': 'application/json',
      },
      body: jsonEncode({
        'model': 'gpt-4o',
        'messages': [{'role': 'user', 'content': prompt}],
      }),
    );
    final data = jsonDecode(response.body);
    return data['choices'][0]['message']['content'];
  }
}

Here is the right way — the Flutter app calls your backend, which holds the key and proxies the request:

dart
// Flutter app — calls your own backend, no secret key needed
class ChatService {
  final String _backendUrl;
  final String _authToken;

  ChatService({required String backendUrl, required String authToken})
      : _backendUrl = backendUrl,
        _authToken = authToken;

  Future<String> getCompletion(String prompt) async {
    final response = await http.post(
      Uri.parse('$_backendUrl/api/chat/completions'),
      headers: {
        'Authorization': 'Bearer $_authToken',  // User's auth token
        'Content-Type': 'application/json',
      },
      body: jsonEncode({'prompt': prompt}),
    );

    if (response.statusCode != 200) {
      throw ApiException('Chat request failed: ${response.statusCode}');
    }

    final data = jsonDecode(response.body);
    return data['content'];
  }
}

And on the server side (Node.js example):

javascript
// Backend — the ONLY place the OpenAI key exists
const openaiKey = process.env.OPENAI_API_KEY;

app.post('/api/chat/completions', authenticate, rateLimit, async (req, res) => {
  const { prompt } = req.body;

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${openaiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages: [{ role: 'user', content: prompt }],
    }),
  });

  const data = await response.json();
  res.json({ content: data.choices[0].message.content });
});

The backend proxy also gives you control you would not have otherwise. You can add rate limiting per user, log usage, validate inputs before they reach the third-party API, and cut off access instantly by disabling the endpoint — without publishing an app update.

This pattern applies to every secret-key scenario: payment processing, AI services, email sending, SMS, cloud storage with admin credentials. If the key is secret, the request goes through your server.

Provider-side key restrictions

For publishable keys that must live in the binary, your second line of defence is restrictions at the provider level. This ensures that even if someone extracts the key, it is useless outside your app.

Using Google Cloud Console as an example, here is how to restrict a Maps API key:

  1. Open the Google Cloud Console
  2. Select your API key
  3. Under Application restrictions, choose Android apps
  4. Add your package name (e.g., com.yourcompany.yourapp)
  5. Add your SHA-1 certificate fingerprint. Get it with:
bash
# Debug certificate
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android

# Release certificate
keytool -list -v -keystore your-release-key.jks -alias your-alias
  1. For iOS, add a separate restriction with your bundle ID
  2. Under API restrictions, limit the key to only the specific APIs it needs (Maps SDK for Android, Maps SDK for iOS — not every Google API)

With these restrictions in place, a request using your API key is rejected unless it originates from an app signed with your certificate and using your package name. The key is technically extractable, but functionally useless to anyone else.

Apply the same principle to every publishable key. Firebase keys are restricted by default to your registered apps. Stripe publishable keys are scoped to read-only operations by design. For services that do not offer built-in restrictions, consider whether the key belongs in the client at all.

Environment variables and CI/CD

Even publishable keys should stay out of your git history. A clean setup separates key management from source code entirely.

Local development — create a .env file at your project root:

javascript
# .env — never committed to git
GOOGLE_MAPS_KEY=AIzaSyB...
STRIPE_PUBLISHABLE_KEY=pk_live_...
SENTRY_DSN=https://abc@sentry.io/123

Add it to .gitignore:

javascript
# .gitignore
.env
*.env
.env.*

Build integration — use --dart-define-from-file to inject these at compile time:

bash
flutter build apk --dart-define-from-file=.env

In your Dart code, access them as compile-time constants:

dart
abstract class EnvConfig {
  static const googleMapsKey = String.fromEnvironment('GOOGLE_MAPS_KEY');
  static const stripePublishableKey = String.fromEnvironment('STRIPE_PUBLISHABLE_KEY');
  static const sentryDsn = String.fromEnvironment('SENTRY_DSN');
}

CI/CD — store keys as encrypted secrets in your CI provider (GitHub Actions, Codemagic, Bitrise, etc.) and inject them during the build step:

yaml
# GitHub Actions example
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Create env file
        run: |
          echo "GOOGLE_MAPS_KEY=${{ secrets.GOOGLE_MAPS_KEY }}" >> .env
          echo "STRIPE_PUBLISHABLE_KEY=${{ secrets.STRIPE_PUBLISHABLE_KEY }}" >> .env

      - name: Build APK
        run: flutter build apk --dart-define-from-file=.env

      - name: Clean up env file
        if: always()
        run: rm -f .env

The cleanup step matters. Some CI environments persist the workspace between jobs. Explicitly removing the .env file prevents accidental exposure in logs or cached artefacts.

A few things to watch for:

  • Never print environment variables in CI logs. If your build script echoes configuration for debugging, ensure keys are masked.
  • Rotate keys periodically. If a key has been in your binary for a year, anyone who downloaded any version of your app in that time has it. For publishable keys with proper restrictions this is fine. For anything else, it is a reason to rotate.
  • Use separate keys for development, staging, and production. If your development key leaks, your production environment is unaffected.

Real-world consequences

In 2015, researchers from security firm Fallible scanned over 16,000 Android apps and found that roughly 2,500 contained hardcoded AWS credentials. The keys were functional. Some granted access to S3 buckets containing user data, DynamoDB tables, and EC2 instances.

AWS key scanning on GitHub tells a similar story. Within minutes of a secret key being pushed to a public repository — even if deleted in the next commit — automated bots find it and begin spinning up cryptocurrency mining instances. Developers have woken up to AWS bills exceeding $10,000 from a single night of exposure. GitHub now scans for secrets and revokes some key types automatically, but the window between push and detection is still enough for damage.

Stripe secret keys in mobile apps present a particularly direct risk. A leaked sk_live_ key allows anyone to create charges, issue refunds to themselves, and read customer payment information. Stripe's own documentation warns against this in bold text, yet security audits continue to find secret keys in production mobile binaries.

The common thread in all these incidents is not sophistication. Nobody reverse-engineered a custom encryption scheme. Nobody exploited a zero-day. They ran strings on a binary, or they searched GitHub. The tools are trivial. The damage is not.

Summary: the decision framework

When you add a new API key to your Flutter project, ask two questions:

  1. Is this key designed for client-side use? Check the provider's documentation. Look for terms like "publishable", "public", "client-side", or "restricted". If the key is meant for servers only, it goes on the server.
  1. Are provider-side restrictions configured? Even publishable keys should be locked to your app's package name, signing certificate, and bundle ID where possible. An unrestricted publishable key is not a security breach, but it is an unnecessary exposure.

If both answers are yes, the key is fine in your binary via --dart-define-from-file. If either answer is no, route the request through your backend.

There is no middle ground for secret keys. No amount of obfuscation, code splitting, or native platform channel trickery changes the fundamental constraint: if the key is in the binary, it is extractable. The only safe place for a secret key is a server you control.

This is Post 2 in the Flutter Security Beyond the Basics series. Post 1 covered threat modelling for mobile apps. Next up: certificate pinning, network security configuration, and what actually protects data in transit.

Related Topics

flutter api keysflutter secrets managementdart define securityflutter backend proxyapi key restrictionsflutter environment variablesmobile app security

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.