Securing API Keys in Mobile Apps
Here's the rule that should shape how you handle every credential in a mobile app: anything shipped in the binary can be extracted. APKs unzip. IPAs unpack. Strings can be dumped, network traffic intercepted, and obfuscation only slows a motivated person down by an afternoon. Once you accept that the client is hostile territory, the right architecture follows naturally.
Two kinds of keys
The confusion starts because "API key" describes two very different things.
Publishable keys are designed to live in clients. A Firebase config, a maps key, an analytics write key, a payment provider's publishable key — these identify your app rather than authorize it, and the vendor expects them to be visible. Shipping them is fine. Restrict them anyway (most consoles let you pin a key to your package name, bundle ID, or platform) so a copied key is useless elsewhere.
Secret keys authorize spending or data access: your LLM provider key, payment secret key, database credentials, admin tokens. A secret key in a mobile binary is compromised the day you ship. With an LLM key this isn't theoretical — extracted keys get run up against provider quotas, and the invoice is yours.
If leaking the key would let a stranger spend your money or read your users' data, it never goes in the app. No exceptions.
The fix is a backend, not a hiding place
Developers try increasingly clever hiding spots — split strings, native code, encrypted assets fetched at runtime. All of it is delay, not protection, because the app must eventually use the key, and anything the app can do, a person controlling the device can observe.
The actual fix is architectural. The secret key lives on your server; the app calls your endpoint; your server calls the vendor:
App --(user auth)--> Your backend --(secret key)--> LLM / payments / etc.
This is the same proxy you already want for other reasons — it's where rate limiting, logging, cost caps, and abuse detection live. Your endpoints authenticate the user (a short-lived token from your sign-in flow), so every expensive action is tied to an account you can throttle or ban. A tiny serverless function is enough; "I don't have a backend" stopped being a blocker years ago.
Keep secrets out of the repo, too
The second-most-common leak isn't the binary — it's Git. A key committed once lives in history forever, and scrapers watch public repos for exactly this.
- Keep secrets in environment variables or untracked files (
.envin.gitignore), and provide a.env.exampledocumenting what's needed. - In CI, use the platform's secret store; never echo secrets into logs.
- A note on Flutter's
--dart-define: it keeps values out of source code, which is good hygiene — but the value is still compiled into the binary. It solves the repo problem, not the extraction problem. Publishable config only.
And rotate: when a key does leak — it happens to careful teams — you want revocation to be a five-minute non-event, not a migration. That's only true if you know where every key is used and can swap it without a release. Server-side keys can be rotated instantly; keys baked into shipped binaries can't, which is one more argument for keeping anything sensitive off the client.
What belongs on the device
User-specific tokens — session tokens, refresh tokens — do belong on the device, in the platform's secure storage (Keychain on iOS, Keystore-backed storage on Android; flutter_secure_storage wraps both). Not SharedPreferences, which is plain storage. Keep them short-lived and revocable server-side, so a stolen token is a contained problem, not a permanent identity.
The takeaway
Sort every credential into two buckets. Designed-to-be-public: ship it, restricted. Authorizes money or data: server only, behind an endpoint that authenticates your users. Keep secrets out of Git, rotate without drama, and store user tokens in secure storage. None of this is exotic — it's one small proxy service and some discipline, and it turns "our key leaked" from a crisis into a log line.