An APK renames to .zip and opens. An .ipa is signed, not sealed. Every string you compile in, every entry in strings.xml, every value in google-services.json ships to whoever downloads the package. Yet the app still has to call APIs that want credentials. That collision is the subject here.
The constraint
OWASP's Mobile Application Security project catalogs the weakness as MASWE-0004: sensitive data "embedded in the app package (APK/IPA) and shipped with the app, where anyone who downloads the package can recover it." Apple states the mirror image for iOS: "You can't rely on your app's logic to perform security checks on itself because a compromised app can falsify the results." Two facts then define the problem. No shipped byte is confidential, and no on-device check can vouch for itself. A credential that must stay secret has to live somewhere the artifact never reaches.
The options
Ship the key and call it fine. Defensible in exactly one documented case: when the key is an identifier rather than a credential. Firebase's own API-key page says its keys "only identify your Firebase project and app to those services" and "do not need to be treated as secrets," because authorization is handled by Security Rules and App Check instead. The same page warns that a Gemini Developer API key "should never be included in your code or configuration files." The deciding question is what the key authorizes, not where it sits. If it authorizes spend or access, shipping it loses on day one.
Hide it. XOR it, base64 it, split it across files, bury it in a native .so. OWASP files this under "Harden Only as a Last Resort" and is blunt about the limit: "these techniques deter but do not prevent extraction." What deterrence costs the attacker is measurable, and the demo below measures it in lines of code.
Attest the app instead of trusting it. Play Integrity on Android and App Attest on iOS move the proof off the device: the platform vouches that a request came from your unmodified binary on a genuine device. The catch is that attestation answers "is this my app," never "should this request succeed," and Google says the API "works best when used alongside other signals as part of your overall anti-abuse strategy and not as your sole anti-abuse mechanism."
Proxy the credential. Keep the key on a server you control, give the app a short-lived token, and let a middleware hold the third-party secret. OWASP's first mitigation is "Proxy Static Secrets Through a Middleware," paired with dynamic tokens that "expire after a reasonably short time (e.g., 1 hour)." It is the only option where a leaked APK leaks nothing.
The mechanism
Two claims deserve a demonstration instead of a citation. First, extraction is free. Second, encoding raises the cost by an afternoon, not by enough to matter. The script builds a fake dex-shaped blob with an embedded key, runs the same printable-run scan strings performs, then obfuscates the key with a single-byte XOR and breaks it the way an attacker actually would: every key of this format starts with the same prefix, so one XOR against a known prefix reveals the cipher byte. Run it with node extract.mjs:
// what extracting an embedded key actually costs
// builds two fake "compiled" artifacts: binary bytes around an embedded secret
const KEY = "sk_demo_9f2c7a1b4e8d5c6b3a0f9e1d";
const XOR_BYTE = 0xaa;
const raw = (bytes) => Buffer.from(bytes);
const str = (s) => Buffer.from(s, "utf8");
// strings(1): printable runs of >= 4 bytes
function strings(buf, min = 4) {
const hits = [];
let start = -1;
for (let i = 0; i <= buf.length; i++) {
const ok = i < buf.length && buf[i] >= 0x20 && buf[i] <= 0x7e;
if (ok && start === -1) start = i;
if (!ok && start !== -1) {
if (i - start >= min) hits.push(buf.subarray(start, i).toString("utf8"));
start = -1;
}
}
return hits;
}
// artifact 1: dex-shaped bytes, key stored as a plain string constant
const dexBlob = Buffer.concat([
raw([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x39, 0x00]), // dex magic
raw([0x00, 0x01, 0x02, 0xff, 0xfe, 0x80, 0x7f, 0x00]),
str("GET /v1/reports"),
raw([0x00, 0x00, 0x1a, 0x02, 0x03]),
str(KEY),
raw([0x00, 0xff, 0x10, 0x20, 0x30]),
]);
console.log(`strings() on dex-shaped bytes (${dexBlob.length} B):`);
for (const s of strings(dexBlob)) console.log(` ${s}`);
// artifact 2: ELF-shaped native blob, same key xor'd with one byte
const encoded = Buffer.from([...str(KEY)].map((b) => b ^ XOR_BYTE));
const elfBlob = Buffer.concat([
raw([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00]), // ELF header
encoded,
raw([0x00, 0xff, 0x00]),
]);
const elfRuns = strings(elfBlob);
console.log(`strings() on xor'd native blob (${elfBlob.length} B): ${elfRuns.length ? elfRuns.join(" ") : "no printable runs"}`);
// attacker knows every key starts with "sk_demo_": xor reveals the cipher byte
const knownPrefix = "sk_demo_";
const cipherByte = encoded[0] ^ str(knownPrefix)[0];
const recovered = Buffer.from(encoded.map((b) => b ^ cipherByte)).toString("utf8");
console.log(`prefix "${knownPrefix}" -> xor byte 0x${cipherByte.toString(16)}`);
console.log(`recovered: ${recovered}`);
strings() on dex-shaped bytes (73 B):
GET /v1/reports
sk_demo_9f2c7a1b4e8d5c6b3a0f9e1d
strings() on xor'd native blob (43 B): no printable runs
prefix "sk_demo_" -> xor byte 0xaa
recovered: sk_demo_9f2c7a1b4e8d5c6b3a0f9e1d
Attestation is the one control the binary cannot fake, because the proof is minted by the platform and checked by your server. Play Integrity returns a signed and encrypted token carrying a JSON verdict; the fields your server must verify before trusting anything:
requestDetails match requestHash or nonce + timestampMillis to your request
appIntegrity appRecognitionVerdict must be PLAY_RECOGNIZED
accountDetails appLicensingVerdict must be LICENSED
deviceIntegrity deviceRecognitionVerdict contains MEETS_DEVICE_INTEGRITY
empty means rooted, hooked, or not a physical device
On iOS the equivalent is a ceremony: generateKey creates a key pair whose private half lives in the Secure Enclave "from where no process can ever directly read or modify it," your server issues a challenge of at least 16 bytes, attestKey gets Apple to certify the key belongs to a genuine instance of your app, and generateAssertion signs each sensitive request after that. Nothing in the IPA carries a secret; the credential is generated on the device and certified by Apple.
Where it bites
Attestation's failure mode is rejecting people you wanted to keep. An empty deviceRecognitionVerdict covers rooted phones, hooked runtimes, and emulators, but it also covers a paying user on an uncertified ROM, and UNEVALUATED on the licensing verdict fires when "the user is not signed in to Google Play" or the installed version is unknown to Play. On iOS, isSupported can return false and Apple instructs you to "gracefully bypass the service," which means your server needs an answer for unverifiable clients decided before you ship, not after the support tickets arrive.
The verdict also has to be verified, not believed. Google warns that requestPackageName "might be spoofed in the middle of the request," so the server compares requestDetails against what it actually sent. A clean verdict still does not prove a human drove the request: a genuine, Play-installed app on a certified device can be scripted into abuse, which is why Play offers recentDeviceActivity as a separate opt-in signal for "an anomalously high volume of requests." Apple's version of the same ceiling: "No single policy can eliminate all fraud. For example, App Attest can't definitively pinpoint a device with a compromised operating system." Finally, the operational tax is real. App Attest keys "don't survive app reinstallation, device migration, or restoration of a device from a backup," and App Check tokens default to a 1-hour TTL while "the App Check library refreshes tokens at approximately half the TTL duration," so every active install re-attests twice an hour against a finite daily quota.
The numbers
- Running the demo above:
stringspulled the key as the second of 2 printable runs in a 73-byte artifact, and the XOR'd variant was recovered by decoding 32 bytes against a known prefix. On a real APK the equivalent command isstrings classes.dex. - Play Integrity's default limit is 10,000 total requests per day across all installs, with token requests shared between classic calls and standard preparations and decryptions on Google's servers capped at the same 10,000. "Processing requests can take up to a week" for a quota increase.
- Latency: standard requests average "a few hundred milliseconds," classic "a few seconds."
- App Check token TTL: default 1 hour, configurable between 30 minutes and 7 days.
MEETS_STRONG_INTEGRITYon Android 13 and higher requires "security updates in the last year for all partitions of the device"; on Android 12 and lower it requires only hardware-backed proof of boot integrity with no recency requirement.
Our position
Never ship a credential; ship only identifiers. If a value in the package authorizes spend, data access, or quota, it moves behind a server you control and the app holds a short-lived token. Add attestation as a gate on that server, not a substitute for it, and only where the abuse you are blocking justifies a 10,000-request daily budget and a designed rejection path. Restrict whatever does ship, API allowlists and security rules, because the binary is public either way. Obfuscation is for slowing down readers; if the threat model requires a key to be unreadable inside the artifact, the design is already wrong.
The takeaway
identifier-style key + security rules, minimum api scope
app holds a short-lived token (~1h), never the real key
play: requestDetails + PLAY_RECOGNIZED + LICENSED + MEETS_DEVICE_INTEGRITY
ios: challenge >= 16 bytes, enclave key, assertion per request
quota is 10k/day and increases take a week
This is the model we apply to mobile backends like CashTaq in our product engineering work, and the same split shows up in data layers: the anon key in a client is an identifier, and the row policies are the guard. If your app is shipping a key that can spend money, send us the package name and we will show you what is already public.
// sources
// next step
We work this way on client projects too.
Tell us what you are making. We reply with a plan, a timeline, and a price.
