A free API key is still a secret. When I wire a cloud model endpoint into a test build, the key usually lands in whatever storage was closest: SharedPreferences, UserDefaults, an .env file bundled into the app. Then the phone gets backed up — and the key quietly travels with it, restorable onto any device that restores that backup.
This post is a hands-on verification workflow: prove that your model-provider key (I used a key from MonkeyCode's free model access) does not survive Android Auto Backup or iCloud backup/restore, and fix your storage choice if it does.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Test environment
- Android: Pixel 7, Android 14 (UP1A), targetSdk 34, a debug build signed with a throwaway keystore
- iOS: iPhone 13, iOS 17.5, Xcode 15.4, an ad-hoc build on a test Apple ID
- Endpoint: any HTTPS LLM API; I used MonkeyCode's free tier key because it costs nothing to revoke and rotate during the test
- Backup path (Android): Auto Backup to Google Drive, triggered via
bmgr - Backup path (iOS): encrypted local backup via Finder, restored to the same device after erase
Treat everything below as a procedure to re-run on your own devices, not universal results — OEM backup implementations and OS versions change the details.
Why defaults leak
Both platforms back up more than developers expect:
-
Android Auto Backup ships app-private files and
SharedPreferencesto the user's Drive unless you opt out per-path. If your key sits in a preferences file, it is in the backup. -
iOS backs up the app's
Documents/andLibrary/Preferences/(that includesUserDefaults). Keychain items are not in the device backup — unless you chose an accessibility class that syncs, likekSecAttrAccessibleAfterFirstUnlockcombined with iCloud Keychain sync viakSecAttrSynchronizable.
So the failure mode is boring: the key works after restore on a different phone, proving it left the device.
The verification artifact
The test is a single assertion: after backup + restore to a fresh app install, the stored key must be absent. I run it as a small script around platform tooling.
Android: force a backup/restore cycle
# 1. Install debug build, launch once, write the key via your settings screen
adb install app-debug.apk
# 2. Force Auto Backup to run now
adb shell bmgr backupnow com.example.llmapp
# 3. Confirm the key file was captured (inspect what the transport received)
# Then wipe and restore:
adb uninstall com.example.llmapp
adb install app-debug.apk
adb shell bmgr restore <token> com.example.llmapp
# 4. Assert: key must be gone
adb shell run-as com.example.llmapp ls files/ shared_prefs/
adb shell run-as com.example.llmapp cat shared_prefs/auth_prefs.xml # expect: no key
If the key reappears in step 4, the backup captured it. Fail.
iOS: restore from an encrypted local backup
# 1. Install, write the key (e.g., into UserDefaults for the failing case)
# 2. Take an encrypted Finder backup (encrypt — unencrypted backups drop more items,
# which can hide the leak you're testing for)
# 3. Erase the device, restore from that backup, relaunch the app
# 4. In-app diagnostic build flag prints whether the key resolved on launch
A debug-only diagnostic screen that logs keyPresentOnLaunch: true/false makes this a one-glance check.
The fix, per platform
Android — exclude the path from Auto Backup. Create res/xml/backup_rules.xml:
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="sharedpref" path="auth_prefs.xml"/>
<exclude domain="file" path="llm/"/>
</full-backup-content>
<!-- AndroidManifest.xml -->
<application
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules" ...>
For Android 12+, mirror the exclusions in data_extraction_rules.xml — device-to-device transfer uses that file, not fullBackupContent. Better still, don't store the raw key in preferences at all: wrap it with Android Keystore and store only the ciphertext, or fetch short-lived tokens from your own backend.
iOS — use Keychain with a non-migrating accessibility class:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "llm_api_key",
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: key.data(using: .utf8)!
]
SecItemDelete(query as CFDictionary) // idempotent write
SecItemAdd(query as CFDictionary, nil)
The ThisDeviceOnly suffix is the whole point: the item is excluded from backups and does not migrate to a new device. Never set kSecAttrSynchronizable for a provider key unless you genuinely want it in iCloud Keychain.
Decision table
| Storage choice | In Android backup? | In iOS backup? | Verdict |
|---|---|---|---|
SharedPreferences / UserDefaults
|
Yes (default) | Yes | Fail |
| App-private files, no exclusion rules | Yes (default) | Yes (Documents/) | Fail |
| Keystore-wrapped ciphertext in prefs | Ciphertext yes, plaintext no | — | Acceptable |
iOS Keychain ThisDeviceOnly
|
— | No | Pass |
| Short-lived token from your backend | Nothing worth stealing | Nothing worth stealing | Best |
What restore should look like
After the fix, a restored app on a fresh device launches with no key and falls into a re-auth or re-pairing flow. That is the correct outcome — not an error state. In my runs, the first implementation failed exactly as predicted (key in shared_prefs came back from Drive); after adding the exclusion rules and moving to Keystore-wrapped storage, step 4 showed no key, and the app prompted for re-pairing.
Limitations and who should skip this
- I tested on one Pixel and one iPhone. Samsung/Xiaomi backup transports and older OS versions behave differently — re-run the script on your actual device matrix.
-
bmgr backupnowis a debug-build convenience and can silently no-op; verify the backup actually ran before trusting a "pass." - Encrypted vs. unencrypted iOS backups change what survives, which can mask a leak. Always test the encrypted path.
- If your key is per-user and expires in minutes, backup leakage is a much smaller risk and this whole exercise is lower priority.
- This does not replace server-side key rotation. Any key that ever shipped in a preference file should be rotated after you ship the fix.
A free tier (MonkeyCode's free model access and free server option, in my case) is handy here precisely because the key is disposable — rotate it mid-test and confirm the old one is dead. If you run this on your own setup, I'm curious about your numbers: device, OS version, which storage you started with, and whether the restored app recovered gracefully or silently kept working with a leaked key.
Top comments (0)