Nikola Čorlija

Storing tokens in the Keychain: the settings that matter

SecItemAdd is the easy part. Accessibility classes, device-only items and the item that outlives your app decide whether your login really is secure.

Security · Keychain · iOS

Every app with a login stores a token somewhere, and every iOS developer knows the answer is "the Keychain, not UserDefaults". The Keychain API is old C-style code with dictionaries of CFString keys, so most teams wrap it once and never look at it again. The wrapper is usually fine. The attributes it passes are where the decisions are.

A minimal save

KeychainStore.swift
import Security

enum KeychainError: Error { case status(OSStatus) }

func saveToken(_ token: Data, account: String) throws {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.example.app.auth",
        kSecAttrAccount as String: account,
    ]
    let attributes: [String: Any] = [
        kSecValueData as String: token,
        kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
    ]

    var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
    if status == errSecItemNotFound {
        status = SecItemAdd(query.merging(attributes) { $1 } as CFDictionary, nil)
    }
    guard status == errSecSuccess else { throw KeychainError.status(status) }
}

Update first, add if missing. Calling SecItemAdd for an item that already exists fails with errSecDuplicateItem, which is the most common Keychain bug in code review.

Accessibility: when can the item be read?

kSecAttrAccessible decides when the system will hand the item to your app. The two you will use most:

ValueReadableTypical use
WhenUnlockedOnly while the device is unlockedData only needed while the user is in the app
AfterFirstUnlockAfter the first unlock since boot, also in the backgroundTokens used by background refresh, push handling, widgets

If your app refreshes data in the background, WhenUnlocked will fail there with errSecInteractionNotAllowed whenever the phone is locked. The failure is silent in testing, because test devices are usually unlocked.

ThisDeviceOnly: should the token move to a new phone?

Each accessibility value has a ThisDeviceOnly variant. Items with it are not included in encrypted backups restored to another device, and are not migrated to a new phone. For a session token that is usually what you want: a restored phone should log in again rather than inherit a session issued to different hardware.

The item that survives deletion

Keychain items are not removed when the user deletes the app. Reinstall, and the old token is still there, possibly for a user who sold the phone, or for a backend environment that no longer exists.

A common pattern is to clear the app's Keychain items on the first launch after install, using a flag in UserDefaults (which is removed with the app):

swift
let key = "hasLaunchedBefore"
if !UserDefaults.standard.bool(forKey: key) {
    try? clearKeychain(service: "com.example.app.auth")
    UserDefaults.standard.set(true, forKey: key)
}

Requiring Face ID

For high-value actions you can tie an item to biometrics with SecAccessControlCreateWithFlags and the .biometryCurrentSet flag. The item is then invalidated when the enrolled fingers or faces change, which is exactly the property you want for a payment confirmation, and exactly the property that surprises users if it guards their whole login. Use it for the action, not for the session.

Checklist

  • Update, then add: never rely on SecItemAdd alone.
  • Pick AfterFirstUnlock if anything reads the item in the background.
  • Prefer ThisDeviceOnly for session tokens.
  • Clear stale items on the first launch after install.
  • Keep biometric access control for specific actions.

← All notes