Skip to content
All lab notes

Lab note · from Vitality

Anthropic API key in an iOS app binary: move it to the Keychain

By Mark Santos · · 5 min read

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.swift held a live Anthropic key as static 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. Commit 7aa1e82 (2026-07-26) removed it.
  • The file was never committed. The commit message says so, .gitignore covers **/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 with swiftc -O for arm64-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 SecureField and checks it with looksLikeAnthropicKey: it must start with sk-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".
  • ThisDeviceOnly stays on this phone. SecItem.h in 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 AIService created before the key was entered "kept the empty string forever", until relaunch.

How to check you've fixed it

  1. 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.

  1. Search history without printing values. git grep -c prints commit:path:count, never the match, and git log -G lists 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,}'
  1. Read the stored item's class back with kSecReturnAttributes and compare kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (raw value cku). Running APIKeyStore itself in an iOS 26.5 simulator, the item read back as cku after 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. AIService still builds its own URL and header, so moving to a proxy means editing AIService, 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 errSecDuplicateItem is dropped, and Settings shows "Stored" regardless. In an unentitled test harness, SecItemAdd returned -34018 (errSecMissingEntitlement) and nothing was saved, with no error shown. The fix is to check the OSStatus and only update the UI on errSecSuccess.
  • AfterFirstUnlock is 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 be kSecAttrAccessibleWhenUnlockedThisDeviceOnly.
  • Versions: iOS 26.1 deployment target, Swift 5 language mode, model claude-sonnet-4-5, anthropic-version: 2023-06-01. The x-api-key header is what Anthropic's API overview calls a "Legacy fallback for Authorization, still supported".

More lab notes