Skip to content
← All writing
February 14, 2026·7 min read

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 with standard tools. IPAs unpack. Strings can be dumped from compiled code, network traffic can be intercepted with a proxy and a user-installed certificate, and obfuscation only slows a motivated person down by an afternoon. This isn't a niche skill — it's a first-week lesson in any mobile security course, and there are scrapers that do it to published apps automatically, at scale, looking for exactly one thing: credentials someone assumed were hidden.

Once you accept that the client is hostile territory, the right architecture follows naturally. The rest of this post is that architecture, plus the handful of habits that keep keys from leaking through the side doors.

Two kinds of keys

The confusion starts because "API key" describes two very different things, and vendors don't always label them loudly.

Publishable keys are designed to live in clients. A Firebase config, a Google Maps key, an analytics write key, a payment provider's publishable key — these identify your app rather than authorize it, and the vendor's security model assumes they're visible. Shipping them is fine; that's what they're for. Restrict them anyway: most vendor consoles let you pin a key to your Android package name and signing certificate, your iOS bundle ID, or specific APIs — so a copied key is useless anywhere but inside your genuine app. Five minutes in the console, and the most common abuse vector closes.

Secret keys authorize spending or data access: your LLM provider key, a payment secret key, database credentials, cloud service accounts, admin tokens. A secret key in a mobile binary is compromised the day you ship — not "at risk," compromised, because extraction is mechanical and someone's bot does it for sport. With an LLM key this isn't theoretical: extracted keys get pooled and resold, run against provider quotas from data centers you've never heard of, and the invoice is yours. The same story plays out with cloud storage keys (your bucket becomes someone's file host) and email keys (your domain becomes a spam relay).

The test is one question: if a stranger had this key, could they spend your money or read your users' data? If yes, it never goes in the app. No exceptions, no clever hiding, no "it's just for the beta."

The fix is a backend, not a hiding place

Developers try increasingly elaborate hiding spots — split strings reassembled at runtime, keys stashed in native code via the NDK, encrypted assets decrypted on launch, values fetched from a config service at startup. All of it is delay, not protection, and it's worth understanding why: the app must eventually use the key. Whatever unscrambling dance the code performs, it performs in memory on a device the attacker controls — and if all else fails, the key travels in an HTTP header that a proxy reads in plaintext. You cannot give software a secret and also give the user the hardware it runs on.

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 every AI feature anyway — it's where rate limiting, logging, cost caps, response caching, and abuse detection naturally 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. Someone abusing your service now has to abuse it as a logged-in user at user speed, instead of as an anonymous script with your master key at machine speed — which changes the economics of attacking you completely.

And the barrier to entry is genuinely gone: a single serverless function is enough for the first version. "I don't have a backend" stopped being a valid reason years ago; it's a few dozen lines of FastAPI or a cloud function, deployed in an afternoon.

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, surviving the deletion of the file that held it, and scrapers watch public repositories in real time for exactly this. GitHub's secret scanning catches many known formats, and some vendors auto-revoke keys found in public repos — a mercy you shouldn't rely on.

  • Keep secrets in environment variables or untracked files (.env in .gitignore), and commit a .env.example documenting what's needed without containing anything real.
  • In CI, use the platform's encrypted secret store, and never echo secrets into build logs — logs are readable by more people than you think and retained longer than you'd guess.
  • 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, extractable like anything else. It solves the repo problem, not the extraction problem. Publishable config only.
  • If a secret does hit a repo, treat it as leaked immediately: rotate first, clean history second. A rewritten Git history doesn't un-leak anything that was public for an hour.

And build for rotation before you need it: when a key leaks — it happens to careful teams — you want revocation to be a five-minute non-event, not an archaeology project. That means knowing where every key is used and being able to swap it without shipping anything. Server-side keys rotate instantly; keys baked into shipped binaries can't rotate until every user updates, which is one more argument — maybe the decisive one — for keeping anything sensitive off the client.

What belongs on the device

User-specific tokens — session tokens, refresh tokens — do belong on the device; that's how the user stays signed in. Store them in the platform's secure storage: Keychain on iOS, Keystore-backed encrypted storage on Android — flutter_secure_storage wraps both behind one API. Not SharedPreferences/UserDefaults, which are plaintext files that back up and restore in ways you don't control.

Keep the tokens short-lived and revocable server-side, so a stolen token is a contained, expiring problem rather than a permanent identity. The difference between a user token and an API secret is exactly this: a leaked user token compromises one account until revocation; a leaked vendor secret compromises you. Design accordingly — the full picture of session handling is its own topic, covered in the mobile auth post.

A five-minute audit worth doing today

Grep your repo and your Flutter project for every string that looks like a credential, and sort each into the two buckets. Check what's in your built binary — unzip a release APK and run strings on it; it's sobering the first time. Confirm your publishable keys are restricted in their consoles. Confirm your secret keys exist only server-side and in CI secret stores. Most apps fail at least one of these checks, and every one of them is cheaper to fix this afternoon than after the invoice.

The takeaway

Sort every credential into two buckets. Designed-to-be-public: ship it, restricted to your app's identity. Authorizes money or data: server only, behind an endpoint that authenticates your users — because hiding secrets in a binary is a game you lose by playing. Keep secrets out of Git, make rotation boring, and store user tokens in real secure storage. None of this is exotic — it's one small proxy service and some habits — and it turns "our key leaked" from a company crisis into a log line and a shrug.

Further reading