Why are secrets in the app binary not secret, and what do you do instead?

14.4.2 · L2 · Interview

Predict first

A program reads its own executable and looks for its secret string. Does it find it?

import Foundation

let apiKey = "sk_live_ABC123_DEMO_SECRET"
let exe = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[0]))
print(exe.range(of: Data("sk_live_ABC123_DEMO_SECRET".utf8)) != nil, apiKey.count)
  • true 26
  • false 26
  • true 0

Mental model. Hiding a key under the doormat of a house the burglar is allowed to enter freely. Moving it under a flowerpot (obfuscation) only delays them; the fix is not to leave a master key there at all.

Everything you ship runs on a device the attacker owns, and an iOS app binary can be downloaded, decrypted and inspected with common tools in minutes. A string literal in your code is stored in the executable’s data: running strings on the binary, or searching its bytes, finds "sk_live_…" exactly as written, whether the build was optimised or not. The same goes for API keys in Info.plist, JSON config files, asset catalogs and plists in the bundle, and values hidden in #if branches that still ship. Obfuscation (splitting a string, XOR-ing it, base64) only raises the cost slightly: the program must reconstruct the secret to use it, so a debugger, a hook (Frida) or a breakpoint at the use site reveals it. Treat any key that can authenticate to a backend, sign requests or decrypt data as already compromised if it ships in the app.

What to do instead. Keep real secrets on the server. The app should authenticate the user (OAuth, sessions) and receive short-lived tokens; the server holds third-party API keys and calls those services on the app’s behalf (a proxy), which also allows rate limits and revocation. If the client must call a service directly, use scoped, low-privilege, revocable keys with restrictions (bundle id, domain, quota) and rotate them; publishable keys (such as a payment provider’s public key) are designed to be public. Generate per-install secrets at runtime (a random key kept in the Keychain) instead of shipping a global one, and use App Attest to make the server trust requests only from the genuine app. Keep secrets out of version control and CI logs, inject build-time values through the build system without committing them, and scan the repository and the built artefact for leaked keys. Accept the residual risk consciously: a determined attacker can read anything the app can read, so the design goal is that what they extract has little value and is cheap to revoke.

The literal is stored verbatim in the binary, so a byte search finds it. Optimisation does not change that.

Common traps

  • Shipping a master API key and obfuscating it. It is recoverable by anyone; keep it server-side and proxy.
  • Believing release builds or optimisation hide strings. Literals are still in the data section; tools find them.

They might follow up with

  • What may be in the binary? Public identifiers and publishable keys that are designed to be public.
  • How do you limit the damage of an extracted key? Scope it, restrict it, rate-limit it and make it easy to rotate.

Related questions

  • 14.1.2 How do you use cryptography safely: key management, nonces, key derivation, password storage and comparison?
  • 14.2.2 What do App Attest and DeviceCheck prove, when do they help, and what are their limits?
  • 13.1.2 How does the Keychain work, how do you add, read and update an item, and what do accessibility classes and…

← Why are deep links an attack surface, and how do you validate them?   What can jailbreak detection and pinning actually achieve, and what… →