Skip to content
KeyDrift
Scan for free
All posts

Mobile binaries: secrets in shipped apps

What lives extractably inside an APK or IPA, why signing certificates don't protect keys, and the backend-proxy pattern that removes the problem.

KeyDrift13 min read

Mobile binaries: secrets in shipped apps

Assume every byte you ship to an app store is readable, because it is. This covers what an attacker can extract from the package you already distributed, which keys are legitimately public, and how to architect secrets out entirely.

Every mobile distribution decision has an unusual property compared to web development: you hand users the complete compiled artifact. There is no server-side execution boundary to hide behind. The .apk or .ipa a store serves is a zip containing your compiled code, resources, and configuration — and whatever string literals survived compilation are recoverable with standard tools in minutes. This isn't a jailbreak scenario or a sophisticated attack; it's unzip.

The consequence follows directly: any secret embedded in a mobile binary must be treated as public from the moment the build is distributed — including to the app stores' CDNs, mirrors, and every user's download cache. This guide covers where secrets actually live in shipped packages, why common mitigations don't hold, which keys are legitimately embeddable, and the architectural pattern that eliminates the question. The browser-bundle mechanics have a sibling guide here; mobile inherits all of them plus a few of its own.

What's inside the package

Both platforms distribute structured archives. Android's APK unzips to Dalvik bytecode (classes*.dex), native libraries, assets, and resources. iOS's IPA unzips to a Payload directory holding the compiled binary, plists, asset catalogs, and frameworks. Extraction requires nothing beyond standard tools:

# Android
unzip -o app-release.apk -d apk/
strings apk/classes*.dex \
  | grep -E 'AIza[0-9A-Za-z_-]{30,}|AKIA[0-9A-Z]{16}|sk_live_[A-Za-z0-9]{10,}|xox[baprs]-'

# iOS (macOS)
unzip -o YourApp.ipa -d ipa/
plutil -p "ipa/Payload/YourApp.app/Info.plist"
strings "ipa/Payload/YourApp.app/YourApp" \
  | grep -E 'AIza|AKIA|sk_live_|sb_secret_'

(All patterns above match synthetic examples like AIzaSySYNTHETIC_EXAMPLE_do_not_use00; they detect format families, not validity.)

The recurring embed points:

LocationPlatformHow keys get there
res/values/strings.xmlAndroidKeys added as string resources "for convenience"
BuildConfig.java / Gradle constantsAndroidbuildConfigField populated from CI environment variables
Info.plistiOSKeys placed in custom plist entries read at startup
Compiled Swift/Kotlin constantsBothlet apiKey = "…" inlined by the compiler
React Native / Expo JS bundleAndroid + iOSThe full browser-bundle problem — framework prefixes like EXPO_PUBLIC_* inline values into shipped JavaScript (details)
Flutter libapp.so / assetsBothAOT-compiled Dart retains string literals; assets ship verbatim

Two properties survive every standard hardening measure. Obfuscation renames symbols, not strings: R8/ProGuard and Swift symbol stripping mangle identifiers, while string literals — what keys are — pass through intact, because mangling them would break lookups against external APIs. And resource files are not compiled at all: plists and XML resources are data, served exactly as authored.

Certificates are identity, not authorization

The most persistent confusion in mobile security is between code-signing certificates and credentials. Your signing certificate proves who built the binary — to the OS, to the store, to update mechanisms. It says nothing about what the binary may do once running on someone else's device. A leaked API key inside a properly signed, store-reviewed app is equally leaked; review processes examine behavior and policy compliance, not string tables.

The corollary matters for architecture decisions: "the app is signed" and "we ship through official stores" provide zero protection for embedded secrets. Neither does requiring the app be installed rather than inspectable — extraction works on any downloaded package, and store packages are downloadable through the normal install path on both platforms.

Keys that are legitimately public — with their conditions

Some providers design keys for client embedding and say so. Embedding those is correct; embedding them without their compensating controls is the actual failure mode:

KeyDesigned public?Compensating control required
Firebase web API key (AIza… in google-services.json)Yes — identifies project, not authority (FAQ)Firebase Security Rules governing data access; App Check attesting genuine clients
Google Maps SDK keysYes, embeddable in appsApplication restrictions (bundle ID/package name + signing fingerprint) plus API restrictions (security best practices)
Stripe publishable (pk_live_)YesNone needed — cannot read data (Stripe keys)
Supabase publishable/anonYes, conditionallyWorking Row Level Security on every table (analysis)
Provider secret keys (sk_live_, sb_secret_, AWS AKIA, LLM keys)NeverNo control makes client embedding acceptable — move the call server-side

The pattern across the public rows: the key identifies your project, and authorization happens elsewhere — rules, restrictions, RLS. When teams skip the "elsewhere," public-key embedding becomes indistinguishable from leaking. Firebase without Security Rules and App Check exposes the database to anyone holding the config file, which anyone does.

The backend proxy pattern removes the question

The durable fix is architectural: no secret-tier credential should exist anywhere in the client process. Route privileged operations through your own backend, which authenticates the user session and holds the keys:

// server.js — runs where STRIPE_SECRET_KEY lives in process.env only
app.post("/api/create-payment-intent", async (req, res) => {
  const userId = await requireSession(req);        // your auth check
  const intent = await stripe.paymentIntents.create({
    amount: 2000,
    currency: "usd",
    metadata: { userId },                          // never trust client amounts
  });
  res.json({ clientSecret: intent.client_secret });
});
// Mobile side — no provider SDK secret required
val resp = api.post("/api/create-payment-intent")
PaymentIntentParams(resp.clientSecret)             // confirmable via publishable key

The pattern generalizes: LLM calls proxied through a server route with per-user rate limits; cloud-storage uploads issued time-limited signed URLs minted server-side; provider webhooks received by your backend, never the app. Each secret-tier key now exists in exactly one place — your server environment — where scanning, rotation, and access policies apply normally. Client-side attack surface collapses to keys designed to be public.

For teams whose entire product is client-side (no existing backend), managed edge/serverless functions fill the same role; the migration is usually days, and the fix guides cover the per-provider moves. Two sequencing notes smooth it:

  • Migrate by blast radius, not by effort: payment and database credentials first, analytics keys last. The order makes partial migrations strictly safer than their starting point.
  • Plan the overlap for shipped binaries: old app versions keep calling old paths until users update, so backend routes serve both patterns during the store-release window — the rotation playbook's mobile-overlap logic applied architecturally.

Auditing your own binaries

Make binary inspection routine per release, using the extraction commands shown earlier plus these additions:

  • Diff embed points across builds: extract strings.xml, BuildConfig constants, and Info.plist from each release candidate and grep before upload, not after discovery.
  • Check the JS bundle separately for React Native/Expo builds — it carries web-style leaks independently of native layers, including anything prefixed for client visibility.
  • Verify the public set matches expectations: finding pk_live_ or a Firebase AIza… key is expected; finding sk_live_, sb_secret_, or an AWS access key ID fails the release.
  • Automate per release, because binaries change every build and manual checks decay exactly like bundle checks do — the reasoning behind continuous monitoring applies unchanged to shipped packages.

Automation hooks exist in every mobile pipeline: a Gradle task or Fastlane lane that extracts the built artifact, runs the grep sweep, and fails on secret-tier hits. The implementation is the web-side pattern transplanted — same detectors, different artifact container:

# Inside a release lane, after assembly:
unzip -q -o app-release.apk -d .audit-tmp
if strings .audit-tmp/classes*.dex | grep -qE 'sk_live_|sb_secret_|AKIA[0-9A-Z]{16}|xox[baprs]-'; then
  echo "secret-tier credential found in binary" >&2; exit 1
fi
rm -rf .audit-tmp

When an audit finds a secret-tier key in a distributed binary: rotate first (treat every historical version of the app as disclosed — see incident response), then force-upgrade users past the vulnerable version, then apply the proxy pattern so the class of leak ends.

Platform embed points, in detail

Generic advice ages poorly against specific build systems, so here are the concrete mechanisms by which keys enter each platform's shipped output — the places release audits should enumerate.

Android.

  • buildConfigField entries bake values into generated BuildConfig constants at compile time. Gradle files frequently wire them straight from environment variables meant for CI convenience:
// app/build.gradle — ships the literal into classes.dex
android {
    defaultConfig {
        buildConfigField "String", "PROVIDER_KEY",
            "\"${System.getenv('PROVIDER_KEY') ?: 'FAKE_LOCAL_ONLY'}\""
    }
}
  • <meta-data> entries in AndroidManifest.xml carry SDK initialization keys verbatim; merged manifests from dependencies deserve inspection too (apkanalyzer manifest print).
  • google-services.json lands Firebase web configuration — legitimately public per the table above, but worth confirming nothing beyond web config rode along.
  • NDK code holds literals inside .so files; strings covers them exactly as shown earlier.

iOS.

  • Custom Info.plist entries are the dominant carrier — SDKs document "add your key here" plists constantly. Dump and grep the built plist, not the source one, because build phases can inject.
  • .xcconfig values flow into Info.plist substitutions and occasionally directly into compiled settings; treat xcconfig contents as shipping content when referenced by plists or build settings exposed at runtime.
  • Swift/Kotlin string literals survive optimization as established; strings on the main binary plus embedded frameworks catches them.
  • React Native/Expo builds ship the JS bundle inside the binary — the full browser-bundle analysis applies, including prefix conventions (mechanics).

Cross-platform frameworks collapse onto these primitives: Flutter assets and AOT snapshots, Capacitor webviews wrapping web bundles, Kotlin Multiplatform sharing constants — every path terminates in one of the extractable locations above, which is why the audit commands stay constant even as stacks churn.

A release checklist for binary hygiene

Run per release candidate, in order:

  1. Extract the artifact (unzip) into a throwaway directory.
  2. Grep extracted trees for the format families table above — expect public-tier hits only.
  3. Diff strings.xml / Info.plist against the previous release: unexplained additions get investigated before upload.
  4. Scan the JS bundle layer separately for hybrid apps, including any service-worker precache.
  5. Verify signing configuration didn't leak keystore passwords into build logs (CI logging discipline).
  6. Confirm maps/Firebase keys carry their documented restrictions (package/bundle constraints, API scoping).
  7. Confirm zero secret-tier credentials appear in crash-reporting metadata uploads.
  8. Record the audit outcome alongside the release tag, so archaeology later has a baseline.

Items two through four automate cleanly into CI against built artifacts — the same placement logic as web pre-deploy checks (pipeline gates), adapted to package outputs. Manual runs remain worthwhile precisely because automation drifts; the checklist survives tooling changes.

One architectural corollary closes the loop: every item above inspects for what shouldn't have shipped. The proxy pattern removes the highest-stakes category from inspection entirely — when no secret-tier credential exists client-side, the checklist's failure modes degrade from "incident" to "hygiene ticket," which is the correct place for them to live.

When embedding is unavoidable: restriction patterns that help

Legacy SDKs, vendor limitations, and migration timelines sometimes force client-side keys that deserve better. The honest hierarchy of compensating controls:

  1. Application identity restrictions. Google Maps-class APIs accept restrictions binding a key to your Android package name plus signing-certificate fingerprint, or iOS bundle identifier (documented best practice). This converts "anyone with the key" into "anyone able to impersonate your signed binary" — raising effort from trivial to moderate. Pair with API restrictions so a captured key exercises only intended services.
  2. Attestation-gated backends. Play Integrity (Android) and App Check with DeviceCheck/App Attest (iOS/Firebase) let your backend reject requests lacking genuine-app attestations. The attestation raises spoofing cost substantially; treat it as strong signal rather than cryptographic guarantee, since attestation systems face ongoing bypass research.
  3. Short-lived minted credentials. Where you control the protocol, exchange app identity for time-boxed tokens at runtime — sixty-minute scoped access beats eternal keys embedded at compile time by construction (the stage-four principle).
  4. Rate limits and quotas keyed to the embedded identity. Provider-side ceilings bound abuse economics even after compromise — the LLM-key lesson applied anywhere meters exist.

Sequence them honestly: restriction patterns raise attacker cost and bound duration; nothing except architectural removal (the proxy pattern) eliminates disclosure itself. Roadmap accordingly — embed-with-restrictions as the bridge, backend-proxying as the destination, and telemetry (provider-side usage anomalies) watching the gap between them. Document which keys currently sit in binaries alongside their restriction status; that inventory becomes the rotation runbook's input the day a binary leaks anyway.

iOS audits deserve two additions to the extraction sweep: embedded frameworks carry their own binaries (strings ipa/Payload/*.app/Frameworks/* catches literals living outside the main executable), and build-phase scripts sometimes inject values into plists at archive time — meaning the source-tree plist can look clean while the shipped one isn't. Always inspect the artifact, never the project file, when judging what shipped.

Desktop parity closes the distribution story: Electron applications ship app.asar archives that unpack with standard tools, Tauri bundles embed web assets verbatim, and installer packages generally distribute the same literal-preserving formats as mobile counterparts. The extraction-and-grep discipline transfers unchanged — different container, identical physics — and hybrid stacks should treat desktop artifacts as additional members of the release-audit rotation rather than exceptions to it.

Store metadata completes the audit surface: screenshots occasionally capture debug consoles holding tokens, descriptions sometimes embed configuration examples, and staged-rollout notes reference internal hostnames. Release review includes the listing itself — the package is public, and everything shipped beside it inherits the audience.

The composite picture across platforms stays constant even as containers differ: extract, sweep for format families, verify the public-tier set, automate per release, rotate-first on any secret-tier hit. Teams carrying that loop across web, iOS, Android, and desktop releases discover it reads as one process with four packaging steps — which is exactly the right mental model, and exactly why the tooling choices made for bundles extend naturally here.

FAQ

Doesn't app-store encryption or FairPlay DRM protect the binary? DRM protects the distribution channel, not the runtime: on-device, the app must execute decrypted, and extraction tools operate at or below that layer. Treat DRM as raising effort marginally, never as secrecy.

Is splitting a key across multiple strings or computing it at runtime effective? It raises effort by minutes. Reconstruction logic ships alongside the fragments, and static analysis recovers assembled literals routinely. If a mitigation's strength depends on an attacker giving up early, it isn't a control.

Are native modules safer than JavaScript bundles for hiding keys? No — strings reads native libraries directly, as shown above. The layer hosting the literal doesn't matter; the literal shouldn't be there.

What about ephemeral tokens minted at login? That's the right direction: short-lived, scoped, server-minted credentials (signed URLs, session-bound access tokens) shrink the window a captured value remains useful. They complement the proxy pattern rather than replacing it — something still holds the long-lived key that mints them, and it should be your backend.


Mobile binaries make every embedded secret permanently public. Run KeyDrift's free scan on your deployed web surfaces today — no signup — and extend the same discipline to what your apps serve.

KeyDrift reads the JavaScript your app actually serves and finds the Supabase, Stripe, OpenAI and AWS keys that should never have left your server. Run a free scan.

KeyDrift is a Veristria product. More about KeyDrift.