DEV Community

Roronoa
Roronoa

Posted on

Keep Your LLM API Key Out of Android Auto Backup and iCloud Backup

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 SharedPreferences to 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/ and Library/Preferences/ (that includes UserDefaults). Keychain items are not in the device backup — unless you chose an accessibility class that syncs, like kSecAttrAccessibleAfterFirstUnlock combined with iCloud Keychain sync via kSecAttrSynchronizable.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
<!-- AndroidManifest.xml -->
<application
    android:fullBackupContent="@xml/backup_rules"
    android:dataExtractionRules="@xml/data_extraction_rules" ...>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 backupnow is 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)