Lab note · from Vitality
Anthropic API key in an iOS app binary: move it to the Keychain
An API key written as a Swift string literal is compiled into the app's executable as plain bytes. It ships to every device that installs the app, and anyone who pulls the binary off one can read it and run requests billed to its owner. Vitality, an iOS nutrition app with optional AI features, had a live Anthropic key in a static let. It now asks the user for their own key and stores it in the Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, which keeps the key from being restored onto a different device from a backup. The other options are a server that holds the key, or Anthropic's App Attest for iOS and macOS apps, whose Swift package is in beta.
Symptoms
Nothing fails, and that is the problem.
Services/APIKeys.swiftheld a live Anthropic key asstatic let claudeAPIKey. The comments directly under it warned against exactly that. The AI features needed no setup, and every request from every install would have carried the same key. Commit7aa1e82(2026-07-26) removed it.- The file was never committed. The commit message says so,
.gitignorecovers**/APIKeys.swift, and a search of every commit in both of the project's repositories, reflogs included, finds no key-shaped string. The key was still treated as burned, because every build made from that working tree contained it. - A throwaway build shows what anyone would see. A 55-character fake key in a
static let, compiled withswiftc -Oforarm64-apple-ios26.1, comes straight back out:
$ strings -a keytest | grep FAKE-KEY
FAKE-KEY-FOR-THIS-TEST-0123456789abcdef0123456789abcdef
Why it happens
A literal is bytes in the executable
In that test build the literal sits in __TEXT,__cstring. The section is 56 bytes: the key plus its terminator. A 7-character literal in the same kind of test was stored in __TEXT,__const and was just as readable. Building with -O didn't change either.
App Store copies add a step, not protection. OWASP's MASTG-TECH-0054 notes: "Because of DRM, the app binary is encrypted when stored on the iOS device". It then describes frida-ios-dump, which "will extract the unencrypted version from memory while the application is running on the device."
The key is the account
Anthropic's API key guidance is blunt: "Much like a credit card number, if someone obtains and uses your API key, they incur charges on your behalf." It also says "don't share your API key. If someone needs access to the Claude API, they should obtain their own key." The Commercial Terms (effective June 17, 2025) leave the cost with the key's owner: "Customer is responsible for all activity under its account." We found no clause in them that names client apps specifically, so the case against a shipped key rests on those two points, not on a specific prohibition. Anthropic's authentication docs list API keys as best for "Local development, prototyping, scripts, and servers where you control secret storage". A phone someone else owns is none of those.
The fix
The broken version, from the only committed copy of the old service, which is in the project's first commit. The key file is reduced to its shape:
// Services/APIKeys.swift — never committed, now gone
enum APIKeys {
static let claudeAPIKey = "…" // a live Anthropic key, in the binary
}
// Services/AIService.swift, before 7aa1e82
actor AIService {
private let apiKey: String
init(apiKey: String = APIKeys.claudeAPIKey) {
self.apiKey = apiKey
}
}
The replacement, Services/APIKeyStore.swift, trimmed to the write path:
enum APIKeyStore {
private static let service = "com.vitality.anthropic"
private static let account = "api-key"
static var claudeAPIKey: String? {
get { /* SecItemCopyMatching with kSecReturnData; nil if absent */ }
set {
guard let newValue, !newValue.isEmpty else {
SecItemDelete(baseQuery as CFDictionary)
return
}
let data = Data(newValue.utf8)
var attributes = baseQuery
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(attributes as CFDictionary, nil)
if status == errSecDuplicateItem {
SecItemUpdate(baseQuery as CFDictionary,
[kSecValueData as String: data] as CFDictionary)
}
}
}
private static var baseQuery: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account]
}
}
AIService now reads the key on every request instead of capturing it in init:
private var apiKey: String? { APIKeyStore.claudeAPIKey }
// in makeAPICall:
request.addValue(apiKey, forHTTPHeaderField: "x-api-key")
request.addValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
Why this works:
- Nothing is in the binary. Settings takes the key in a
SecureFieldand checks it withlooksLikeAnthropicKey: it must start withsk-ant-and be at least 40 characters. Only then is it written through the setter. The source's comment says it exists so "an obviously-wrong paste is caught before a request is made". ThisDeviceOnlystays on this phone.SecItem.hin the iOS 27.0 SDK says items with this class "will never migrate to a new device, so after a backup is restored to a new device these items will be missing." Apple's keychain accessibility guide spells out the edge: "the item can be restored to the same device that created a backup, but it isn't migrated when restoring another device's backup data." The Platform Security guide gives the mechanism: the item is "protected with the UID when being copied from the device during a backup, rendering it useless if it's restored to a different device." A user who moves to a new phone pastes the key again.- Replacing keeps the class. The duplicate path updates only
kSecValueData, so the accessibility set on first save stays. - Reading per request fixes a stale-key bug. The source comment records that an
AIServicecreated before the key was entered "kept the empty string forever", until relaunch.
How to check you've fixed it
- Grep the executable you are about to ship. On a Release simulator build of the current code:
$ strings -a Vitality.app/Vitality | grep -cE 'sk-ant-[A-Za-z0-9_-]{20,}'
0
The only sk-ant- strings left are the validator's prefix and its error message.
- Search history without printing values.
git grep -cprintscommit:path:count, never the match, andgit log -Glists the commits that added or removed one. In a test repository with a fake key committed and then deleted, both found it.
git grep -c -E 'sk-ant-[A-Za-z0-9_-]{20,}' $(git rev-list --all)
git log --all --oneline -G 'sk-ant-[A-Za-z0-9_-]{20,}'
- Read the stored item's class back with
kSecReturnAttributesand comparekSecAttrAccessibletokSecAttrAccessibleAfterFirstUnlockThisDeviceOnly(raw valuecku). RunningAPIKeyStoreitself in an iOS 26.5 simulator, the item read back asckuafter the first save and again after a replace.
Caveats
- Bring-your-own-key is a wall. The source says so: it is "a hard wall for a consumer nutrition app — most people will not have one." The answer it names is a small hosted endpoint that holds one key and is paid for by a subscription. There is a declared seam for that,
AIEndpoint, but it is not wired in yet.AIServicestill builds its own URL and header, so moving to a proxy means editingAIService, not just configuration. - App Attest skips the proxy entirely. Anthropic's App Attest page describes short-lived tokens issued to "a genuine, unmodified build", with "no proxy for you to operate". It runs through a Swift package that "is in beta: it requires the OS 27 betas". Vitality's deployment target is iOS 26.1.
- The setter doesn't check for failure. Any status other than
errSecDuplicateItemis dropped, and Settings shows "Stored" regardless. In an unentitled test harness,SecItemAddreturned-34018(errSecMissingEntitlement) and nothing was saved, with no error shown. The fix is to check theOSStatusand only update the UI onerrSecSuccess. AfterFirstUnlockis looser than it needs to be. Vitality declares no background modes, and every AI call starts from a view. Apple's advice is "Always use the most restrictive option that makes sense for your app", which here would bekSecAttrAccessibleWhenUnlockedThisDeviceOnly.- Versions: iOS 26.1 deployment target, Swift 5 language mode, model
claude-sonnet-4-5,anthropic-version: 2023-06-01. Thex-api-keyheader is what Anthropic's API overview calls a "Legacy fallback forAuthorization, still supported".
More lab notes
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.
- Claude Code signed out by another app: refresh token rotationAnthropic rotates the refresh token on every refresh, so another app that refreshes Claude Code's login signs it out. Only Claude Code should refresh it.
- Laplacian sharpness always 0 on iOS: Core Image and Vision trapsRendering a Laplacian to .RGBA8 with a grey colour space wrote nothing, and caught Vision errors became 0. Render to .Rf and return nil when Vision fails.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.