iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔑

Do Not Embed API Keys in Your App Binary

に公開

School Song of "Don't Embed API Keys in Binaries High School"

High rise the peaks of the server, verify purely and rightly.
Even if you apply obfuscation, the wind of strings carries it all away.
Ah, "Don't Embed API Keys in Binaries High School," we carve our pride here.

Decoding the APK, the morning dew of jadx, secrets hidden in the IPA scatter.
As the mist of Frida gathers, the dreams of ProGuard vanish fleetingly.
Ah, "Don't Embed API Keys in Binaries High School," we inherit the tears of our predecessors in our hearts.

Oh token, burn short, hold an expiration, and pass away gracefully.
The key belongs to the server, the app is but the entrance; guard this teaching and stand for the world.
Ah, "Don't Embed API Keys in Binaries High School," our vow shall last forever.


With the spread of AI coding tools, we live in an era where even non-engineers can easily build iOS and Android apps.
Claude, ChatGPT, Gemini... you need an API key to build apps that utilize AI.

"Implement a feature to do X using AI!" ...what is written in that code?

let apiKey = "sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

This is hardcoding. And this must be fixed immediately.


A Compendium of Anti-Patterns

First, let's look at common "pitfalls."

Pattern 1: Plain Hardcoding (iOS / Swift)

// Code that hits the OpenAI API
func fetchResponse(prompt: String) async {
    let apiKey = "sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz123456"
    var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
    request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
    // ...
}

When you ask an AI to "write code to call the OpenAI API," it might output code like this.
I understand the desire to "just make it work." But danger begins the moment it works.

Pattern 2: Extracting to a Constant for a "Sense of Management" (Android / Kotlin)

object ApiConfig {
    const val OPENAI_API_KEY = "sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz123456"
    const val BASE_URL = "https://api.openai.com"
}

// Usage side
val response = apiClient.call(ApiConfig.OPENAI_API_KEY, prompt)

Even if you extract it to a constant class, the key still lives inside the code.
"Clean code" and "safe code" are different things.

Pattern 3: Writing in Info.plist (The iOS-specific Trap)

<!-- Info.plist -->
<key>OpenAIAPIKey</key>
<string>sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz123456</string>
// Reading in Swift
let apiKey = Bundle.main.infoDictionary?["OpenAIAPIKey"] as? String

I understand the idea (though I shouldn't) of "separating it into a config file rather than the source code."
However, Info.plist is included as-is in the app binary (.ipa file) during the build.
If someone unpacks the ipa, anyone can read the Info.plist inside.

--This will be explained in detail later.--

Pattern 4: Writing in strings.xml or local.properties (Android)

<!-- res/values/strings.xml -->
<resources>
    <string name="openai_api_key">sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz123456</string>
</resources>
// Reading in Kotlin
val apiKey = getString(R.string.openai_api_key)

Or the pattern of using local.properties + BuildConfig to "avoid writing in the source":

# local.properties (Gitignored)
OPENAI_API_KEY=sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz123456
// build.gradle
def localProps = new Properties()
localProps.load(new FileInputStream(rootProject.file("local.properties")))
buildConfigField("String", "OPENAI_API_KEY", "\"${localProps['OPENAI_API_KEY']}\"")
// In code
val apiKey = BuildConfig.OPENAI_API_KEY

It is true that local.properties can be Gitignored, so it "doesn't go up to Git."
However, when you build, BuildConfig.java is generated and baked into the binary.
If you decompile the APK, you can extract it.

Pattern 5: Writing in a .env file (Flutter)

# .env
OPENAI_API_KEY=sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz123456
// Code using flutter_dotenv
await dotenv.load(fileName: ".env");
final apiKey = dotenv.env['OPENAI_API_KEY'];

"It's safe because I made it an environment variable, right?"

--This will also be explained in detail later.--


What actually happens

If you think "it's just being seen, right?", you're naive. Massive charges are looming right around the corner.

Case 1: API Key is automatically scanned and credit usage explodes

If you push an API key to GitHub, it will be scanned by bots within minutes.
This is a fact confirmed repeatedly in experiments by security researchers, and "I accidentally pushed it and then deleted it" might already be too late.

Everything remains in the Git history. Even if you delete the file, the key is still alive unless you rewrite the commit history.

Scanned bots start automatically calling the API. LLM-based APIs have high costs per request. Stories of waking up to a bill for hundreds of thousands of yen are not jokes.

Case 2: Even though the repository was "Private"

Some say, "It's a Private repository so it's fine."

You can't let your guard down even with a Private repository.

  • You accidentally changed it to Public
  • A collaborator you added had malicious intent
  • The GitHub account itself was compromised

"Private" is access control, not a "secret vault."
Key management and access control are separate things.

Case 3: You can extract it by analyzing the app binary

If you embed an API key in a mobile app, you can extract it by analyzing the binary.

iOS IPA files are essentially ZIPs. If you change the extension and extract, you can see the contents. Info.plist can be read as-is. Even if you obfuscate, you can extract strings using specialized tools (Hopper Disassembler, Ghidra, etc.).

Android APKs are the same; if you decompile them with apktool or jadx, you can read the string constants baked into BuildConfig and the resource files almost in their original form.


Misconceptions of "It's safe because I separated files" or "I escaped to .env"

This is the important part.

iOS: Info.plist goes into the binary

"I didn't write it in the source code; I separated it into Info.plist. I didn't push it to Git either."

Info.plist is copied as-is into the app bundle (.app → .ipa) during the Xcode build process.
It can be read simply by expanding the distributed IPA. If it's an app distributed on the App Store, anyone in the world can download the IPA and check its contents.

# Check the contents of the IPA (anyone can do this)
cp MyApp.ipa MyApp.zip
unzip MyApp.zip
cat Payload/MyApp.app/Info.plist
# → OpenAIAPIKey: sk-proj-... is clearly visible

Android: BuildConfig is baked into the binary

The method of Gitignoring local.properties and reading via BuildConfig is correct in the sense that "it doesn't go to Git."
However, the built APK/AAB contains the compiled result of BuildConfig.java.

# Decompile APK (example using jadx)
jadx -d output/ MyApp.apk
grep -r "OPENAI_API_KEY" output/
# → public static final String OPENAI_API_KEY = "sk-proj-..." will appear

Thinking "it's fine because it's obfuscated (ProGuard/R8)" is pure fantasy.
Obfuscation of string constants is not performed by default. Even if explicitly configured, the effect is only to make it "slightly harder to read."

Flutter: .env file is included in assets

When using packages like flutter_dotenv, the .env file must be registered in the assets of pubspec.yaml.

# pubspec.yaml
flutter:
  assets:
    - .env

Files registered this way are packaged as-is as assets of the app bundle.
In an APK, it can be extracted as assets/flutter_assets/.env. Even if you Gitignore the .env file, it's the same thing once you build it.

The Common Essence

All methods are "the same once you build it."

What was done Compared to source code Included in binary?
Write in Info.plist No history if not pushed to Git ✅ Yes
Write in BuildConfig No history if not pushed to Git ✅ Yes
Write in Flutter assets/.env No history if not pushed to Git ✅ Yes
Direct hardcode + push to Git ✅ Yes + in history

Not pushing to Git has the effect of "reducing the risk of leakage."
But it can be extracted from the distributed binary.
So what should you do? I will write about that in detail later.


"It's fine because I have domain restrictions" can also be broken

Google Maps API, Firebase, and some API services allow you to set "only allow requests from this domain."

"It can only be used from https://myapp.com. So even if the API key leaks, it can't be abused."

Unfortunately, this can be broken.

The Referer header can be spoofed

Most domain restrictions judge based on the Referer header (or Origin header).
These headers are just strings contained in HTTP requests. They can be set freely.

# Example of bypassing domain restrictions of Google Maps API
curl "https://maps.googleapis.com/maps/api/geocode/json?address=Tokyo&key=AIzaXXXXXXXXXXXXXXXXXX" \
  -H "Referer: https://myapp.com"

Browsers restrict tampering with the Referer header for security reasons, but curl and Python scripts have no such restrictions.
In other words, a human who knows the API key can bypass the restriction by means other than a browser.

What about IP address restrictions?

IP address restrictions are stronger than domain restrictions, but they cannot be used for frontend apps.
Because you cannot control which IP a user comes from.

IP address restrictions are effective for server-to-server communication (when hitting APIs from a server with a fixed IP).

Conclusion: It's game over the moment you put the API key on the client side

No matter what restrictions you set, the risk that it will be acquired by malicious users will not disappear as long as the API key exists on the client side (mobile app or browser).
Restrictions lower the risk, but do not make it zero.


Best Practices

School Song of "So what should I do High School"

The app is the entrance, don't know the key; entrust it to the server beyond the Proxy.
Firebase, Lambda, Cloudflare; build a fortress within the free tier.
Ah, "So what should I do High School," we carve our answer here.

Oh Secret Manager, guard it silently; in that one line of defineSecret.
Dwelling in neither IPA nor APK, not even showing a shadow in Git history.
Ah, "So what should I do High School," we inherit the wisdom of our predecessors in our hearts.

Set up a Proxy, don't feel at ease; without authentication, it's just an open pipe.
App Check, rate limiting, Auth required; don't just shift the problem.
Ah, "So what should I do High School," show the correct architecture to the world.


Principle: Do not put API keys on the client, put them behind a Proxy

Do not put API keys on the client (mobile apps/browsers).
Instead, put them somewhere on the "server side that you manage." Then the mobile app sends requests there.

Mobile App → Proxy (where you manage) → OpenAI / Claude / etc.

Practical solution for individual developers: Use Serverless as a Proxy

Even if told to "insert a Proxy," it is unrealistic for individual developers to operate their own servers 24/7.
Fortunately, there are many serverless Proxies that are compatible with mobile apps.

Service Best cases
Firebase Cloud Functions Apps already using Firebase (Firestore/Auth)
AWS Lambda + API Gateway When focused on AWS or want fine control
Cloudflare Workers When prioritizing latency or running at the edge
Vercel / Netlify Functions When wanting a common backend with Web

All have free tiers and can be operated practically for free at the individual development level.
Below, I will use Firebase Cloud Functions as an example.

Setting up a Proxy with Firebase Cloud Functions

// functions/src/index.ts
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";

// Refer to the key saved in Firebase Secret Manager
const openaiApiKey = defineSecret("OPENAI_API_KEY");

export const chat = onCall(
  { secrets: [openaiApiKey] },
  async (request) => {
    // If you require login via Firebase Auth, you can prevent stray calls
    if (!request.auth) {
      throw new HttpsError("unauthenticated", "Login required");
    }

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

    return await response.json();
  }
);

Register the API key in Firebase Secret Manager:

firebase functions:secrets:set OPENAI_API_KEY
# Enter the key value interactively

The iOS side just calls from the Firebase Functions SDK. There is no need to know the API key.

import FirebaseFunctions

func chat(prompt: String) async throws {
    let functions = Functions.functions()
    let result = try await functions
        .httpsCallable("chat")
        .call(["prompt": prompt])
    // result.data contains the OpenAI response
}

The Android side is the same:

import com.google.firebase.functions.ktx.functions
import com.google.firebase.ktx.Firebase

suspend fun chat(prompt: String): Any? {
    val data = hashMapOf("prompt" to prompt)
    return Firebase.functions
        .getHttpsCallable("chat")
        .call(data)
        .await()
        .data
}

The OpenAI key exists only within Firebase Secret Manager.
It is not included in the IPA or APK, and not in the Git repository.

Note that "just inserting a Proxy" is not enough

Even if you set up a Proxy, if you leave it in a state where anyone can hit it, then the API will be hit via the Proxy, and your billing will explode.
At a minimum, limit calls with one of the following:

  • Require authentication (Firebase Auth / Cognito, etc.)
  • Rate limiting (call frequency, per-user limit)
  • Enable App Check (In the case of Firebase. A mechanism to verify if it's a call from your own app)

Be careful not to just shift the "client-side API key problem" to a "Proxy unauthorized call problem."

Revoke leaked keys immediately

If you realize you pushed it by mistake, first revoke the API key. You can fix the code later.

Even if you delete a live key, the commit remains in the Git history.
The priority is:

  1. Immediately revoke/delete the key in the provider's dashboard
  2. Issue a new key
  3. Delete commits containing the key from Git history (use git filter-repo, etc.)
  4. Rewrite history with a forced push (be careful if it's a shared repository)

GitHub has a mechanism where its own Secret Scanning feature automatically detects pushed keys and notifies the provider.
Major providers like OpenAI and Anthropic sometimes receive these notifications and automatically revoke keys.
However, you must not rely on that as a "safety net."

Caution when using AI coding tools

When writing code using Cursor, GitHub Copilot, Claude, etc., there is also a risk of pasting API keys into the chat field.

When asking an AI "what's wrong with this code?", avoid pasting code containing the key as is.
Replace the key part with a dummy value like sk-proj-XXXXXXXXXX before sharing.


Summary

Pitfall Actual Risk
Plain hardcoding in source Game over the moment you push to Git
Writing in iOS Info.plist Clearly visible if you expand the IPA
Writing in Android BuildConfig/strings.xml Extractable if you decompile the APK
Registering Flutter .env in assets Included as-is in the bundle
Feeling safe with domain restrictions Bypassed by Referer header spoofing
It's fine because it's a Private repo Game over via accidental disclosure/account compromise

Place the API key behind a Proxy (Firebase / Lambda, etc.). Do not include it in the mobile app binary. Do not include it in Git.

If the code written by an AI contains an API key, stop and think before running it. "Writing it in Info.plist is fine," "Let's read it with BuildConfig"—AIs generate such code without hesitation.
AI coding tools write "working code." But you must judge whether it is "safe code."


  • Motto of "Don't Embed API Keys in Binaries High School": "Keys are in your heart, outside the binary"*

Discussion