In the previous two parts we protected user data on the device: tokens in the Keychain and files with Data Protection. Now the problem flips. It isn’t about protecting the user’s data, it’s about protecting your own secrets that you put inside the app. And here the conclusion is uncomfortable: you can’t.
Anything that ships in the binary can be read
The app you upload to the App Store gets downloaded and runs on a device you don’t control. That binary, with everything inside it, is in the user’s hands. And pulling strings out of an iOS binary doesn’t take a sophisticated attacker.
A strings on the decrypted executable lists a good chunk of the text constants. Tools like Hopper or Ghidra disassemble the code. Frida hooks the running app and reads values in memory while it runs. None of this is exotic; it’s the first thing anyone auditing an app does.
So when you write this:
let apiKey = "sk_live_9f2a8c...b41" // no
you just published that key. It doesn’t matter whether it’s in a constant, in a .plist, in an .xcconfig compiled into the bundle, or split into pieces and reassembled at runtime. All of that lives in the binary or runs on the device, and both are the attacker’s territory.
Obfuscation raises the bar, it doesn’t close the door
It’s tempting to think that if you split the key into chunks, base64-encode it, or rebuild it with a convoluted algorithm, you’ve hidden it well enough. What you achieve with that is making extraction take ten minutes instead of ten seconds.
The underlying problem is that, at some point during execution, the key has to exist complete in memory to be usable. An attacker with a breakpoint or with Frida reads it right there, already reassembled, no matter how convoluted the path to build it was. Obfuscation has its place against large-scale automated analysis, but it doesn’t turn an embedded secret into a safe one.
Where a secret actually belongs
The rule is simple: a secret that must stay secret can’t run on the client. It lives on your server.
In practice this means your app doesn’t talk directly to the service that requires the secret key. It talks to your backend, and your backend, which does run in an environment you control, holds the key and makes the call to the third party. The app never sees the secret.
App → your backend (holds the secret) → third-party service
Yes, it’s more work than dropping the key in a constant. It’s also the only way the key doesn’t end up in the binary of thousands of devices. If the service can issue short-lived per-user tokens, better still: your backend issues a limited, temporary token, and the app works with that instead of the master key.
The keys you do have to ship
There’s one case you can’t avoid: third-party SDKs that require a key on the client. A maps SDK, an analytics SDK, a notifications SDK. That key has to go in the app because the SDK runs in the app.
For these, change the approach: treat them as public, because in practice they are. The security isn’t in hiding them, it’s in restricting what they can do.
- Restrict the key on the service side. Almost every provider lets you tie a key to your bundle identifier, limit which APIs it can call, and set usage limits. A key restricted to your app and to a single API is worth much less to whoever steals it.
- Rotate and monitor. Have a plan to rotate the key without shipping a new version, and alerts for anomalous usage. A leaked key you can rotate in minutes is an incident; one you can’t is a permanent leak.
- Don’t ship the admin key. The SDK needs the public or client key, not your account’s admin key. Confusing them is how a minor leak turns catastrophic.
Keeping secrets out of the repository
A different, related problem is not leaking secrets to your own git repository. Even if a key ends up public in the binary, you don’t want its history in git, and you don’t want your backend’s keys showing up in a commit.
Here .xcconfig files do help: they keep configuration out of the code, and you can leave them out of version control and inject the values from your CI at build time.
// Secrets.xcconfig (in .gitignore, injected by CI)
API_BASE_URL = https:/$()/api.mydomain.com
// Read from Info.plist, fed by the xcconfig
let baseURL = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String
Be clear about what this solves and what it doesn’t. It solves keeping your secrets out of git history and source code. It doesn’t stop a value placed in Info.plist from being extractable from the binary. Those are two separate problems, and .xcconfig only tackles the first.
One tip worth its weight in gold: if you ever committed a key by mistake, rotate it. Deleting it from the last commit isn’t enough, because it’s still in the history and in every clone of the repository. A secret that ever touched git has to be treated as compromised.
What we’ve got
- Anything that ships in the binary, obfuscated or not, can be extracted. Obfuscation only raises the cost.
- A secret that must stay secret lives on your server; the app talks to your backend, not to the third party.
- The keys that do have to ship on the client are treated as public: restrict them on the service side, rotate them, and monitor them.
- Keep secrets out of git with
.xcconfigand CI, and rotate any key that has touched the history.
What’s next
We’ve covered where to store secrets, how to protect files, and what to do with API keys. There’s one last leak path, and it’s one of the quietest: sensitive data that escapes through logs. A token printed to the console, a full response body in a crash report, a value marked public “just for debugging” that stayed in production.
In part 4, which closes the fundamentals block, we look at log hygiene with os.Logger, the privacy-level system, and a couple of neighboring leaks: the clipboard and the app switcher snapshot.
Sources: Security (Apple) · App Attest (Apple)