Why you can't ship an API key in a mobile app
Last verified
You cannot hide a secret in software you hand to someone else. The app has to reconstruct the key to use it, so anyone with the binary can reconstruct it too. This is not a difficulty gradient — it's a property of the situation.
A 2026 network-traffic study found 282 iOS apps leaking LLM API keys. Not 282 careless developers — 282 instances of an architecture that cannot work.
Why every approach fails
Hardcoded string. strings on the binary. Seconds.
Base64 or a simple cipher. Encoding is not encryption. The decode routine ships alongside the data, so extraction is: find routine, run routine.
Split into fragments and reassembled. The reassembly code is in the binary. A breakpoint on the network call shows you the finished key.
Fetched from your server at launch. Now it's in memory and on the wire. A proxy tool on a jailbroken device or an emulator reads it. You have added a step, not a barrier.
Native code / NDK. Raises the skill floor slightly. Frida attaches to native code perfectly well.
Commercial obfuscation. Buys time against casual inspection. Does not change the outcome against anyone motivated, and the people harvesting keys are running automation, not curiosity.
The common thread: the app must produce the plaintext key to make a request. Whatever it does to get there, an attacker can do too, with a debugger and patience they've already automated.
The only architecture that works
Your app talks to your server. Your server talks to the provider.
mobile app ──► your backend ──► OpenAI / Anthropic / …
(auth) (holds the key)
The app never sees a provider key. What it holds is a session token for _your_ service — which you can revoke per user, rate-limit per user, and scope to exactly what that user is allowed to do.
This gets you things embedding never could:
- Per-user rate and spend limits. One abusive user can't drain the account.
- Revocation without an app update. Cut off a user in seconds rather than shipping a release and waiting for adoption.
- Model changes without a release. Swap models server-side.
- Actual visibility. You see which user generated which cost.
- Prompt control. Your system prompt stays on your server rather than in a binary anyone can read.
Yes, it's a backend to run. That is the cost of the only design that works.
"But it's just a prototype"
The two things that make prototypes dangerous:
- Prototypes ship. The TestFlight build with the embedded key becomes the release, because nobody schedules time to redo the auth layer.
- TestFlight and internal builds leak. They get shared, sideloaded, and pulled apart.
If you must prototype with an embedded key: use a key in its own project, with a low hard spend limit, and treat it as burnt. Rotate it before any wider release.
What to do if you already shipped one
- Revoke the key. It is public. It has been public since the day you shipped.
- Check for unauthorised usage over the whole period the build has been live — which may be months.
- Build the backend proxy. There is no shortcut.
- Ship an update, and expect a long tail of users on the old build. Consider a forced upgrade if the exposure is significant.
- Set a hard spend limit immediately, because that tail is unprotected until it drains.
Note the asymmetry: your key was public from the day of release, and it stays public in every old build. The clock has been running the entire time.
Web apps too
Identical logic. Anything in a browser bundle is public — not obscure, public. NEXT_PUBLIC_ and VITE_ prefixed variables are compiled into client JavaScript by design, and putting a provider key in one is the single most common version of this mistake.