SwiftSecurityKeychainiOS

iOS security (part 1): where to store tokens and secrets

The first security mistake I see in iOS apps is almost always the same one: a session token stored in UserDefaults. It works, the code is one line, and that’s exactly why it slips in. The problem is that UserDefaults writes everything to a plain-text .plist file inside the app container, and that file goes into device backups. Anyone with access to the backup, or to the file itself, reads the token with no effort.

This series is about the fundamentals an iOS dev should have settled before shipping. We start with the most basic and the most ignored: where a secret lives on the device.

The Keychain, in short

The Keychain is the system’s encrypted store for small, sensitive data: tokens, passwords, keys. It isn’t a database for your models, it’s a safe for credentials. What you put in it is encrypted with keys tied to the device and, depending on how you configure it, to the user’s passcode.

The API is Security framework (SecItem...). It’s a C API, so in Swift it feels rougher than UserDefaults.standard.set(...). That roughness is why people avoid it, and why it’s worth wrapping once and forgetting about.

Saving a token

The minimum to store a value:

import Security
import Foundation

enum KeychainError: Error {
    case unexpectedStatus(OSStatus)
}

func saveToken(_ token: String, account: String) throws {
    let data = Data(token.utf8)

    let query: [String: Any] = [
        kSecClass as String:       kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecValueData as String:   data,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    ]

    // The Keychain doesn't overwrite: if the item exists, delete it first.
    SecItemDelete(query as CFDictionary)

    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else {
        throw KeychainError.unexpectedStatus(status)
    }
}

Two details matter here.

The first is that SecItemAdd fails with errSecDuplicateItem if there’s already a value for that account. That’s why the SecItemDelete first: in practice you almost always want “save or replace”. The finer alternative is SecItemUpdate, but for a token that rotates, delete-then-insert is simpler and leaves no odd states.

The second is kSecAttrAccessible, which is where nearly all of this code’s security is decided.

The attribute almost nobody configures

kSecAttrAccessible decides when the system lets you read the item. The value I used above, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, is the strictest and the one I recommend by default. It means two things:

  • WhenUnlocked: the item is only readable while the device is unlocked. If the app tries to read it in the background with the screen locked, it fails.
  • ThisDeviceOnly: the item never leaves this device. It doesn’t go into iCloud or iTunes backups, and it isn’t migrated to a new iPhone.

That ThisDeviceOnly is the part people skip, because the default value (kSecAttrAccessibleAfterFirstUnlock, without the suffix) does go into backups. A session token has no reason to travel in a backup to another device. If you need background access (to refresh data with the screen locked, say), step up just one level to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, but keep the ThisDeviceOnly.

One thing that surprises people: if the user has no passcode, the protections tied to unlocking lose their strength. The Keychain still works, but the encryption that depends on the passcode isn’t there. You can’t force a passcode, but it’s worth knowing.

Reading and deleting

Reading follows the same dictionary pattern:

func readToken(account: String) throws -> String? {
    let query: [String: Any] = [
        kSecClass as String:       kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecReturnData as String:  true,
        kSecMatchLimit as String:  kSecMatchLimitOne,
    ]

    var result: AnyObject?
    let status = SecItemCopyMatching(query as CFDictionary, &result)

    switch status {
    case errSecSuccess:
        guard let data = result as? Data else { return nil }
        return String(decoding: data, as: UTF8.self)
    case errSecItemNotFound:
        return nil
    default:
        throw KeychainError.unexpectedStatus(status)
    }
}

func deleteToken(account: String) throws {
    let query: [String: Any] = [
        kSecClass as String:       kSecClassGenericPassword,
        kSecAttrAccount as String: account,
    ]
    let status = SecItemDelete(query as CFDictionary)
    guard status == errSecSuccess || status == errSecItemNotFound else {
        throw KeychainError.unexpectedStatus(status)
    }
}

Notice how I treat errSecItemNotFound: it isn’t an error, it’s “nothing stored”. Telling that case apart from a real failure keeps a generic catch from hiding an actual problem. Swallowing the OSStatus is another common mistake; that number tells you exactly what happened, and it’s worth propagating.

Putting a secret behind Face ID

Sometimes you want reading a value to require the user’s presence: a payment PIN, a recovery phrase. That’s what access control is for, which ties the item to biometrics:

func saveSecretWithBiometrics(_ secret: String, account: String) throws {
    var error: Unmanaged<CFError>?
    guard let access = SecAccessControlCreateWithFlags(
        nil,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        .biometryCurrentSet,
        &error
    ) else {
        throw error!.takeRetainedValue() as Error
    }

    let query: [String: Any] = [
        kSecClass as String:          kSecClassGenericPassword,
        kSecAttrAccount as String:    account,
        kSecValueData as String:      Data(secret.utf8),
        kSecAttrAccessControl as String: access,
    ]

    SecItemDelete(query as CFDictionary)
    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else {
        throw KeychainError.unexpectedStatus(status)
    }
}

The .biometryCurrentSet flag is stronger than it looks. It doesn’t just require Face ID or Touch ID to read the value: it invalidates the item if the user adds or removes a fingerprint or re-enrolls Face ID. The idea is that if someone enrolls their own biometrics on a compromised device, the stored secret stops being accessible. If you’d rather allow the passcode as a fallback to biometrics, use .userPresence instead of .biometryCurrentSet.

When the item has access control, reading it triggers the Face ID prompt on its own, without you calling LocalAuthentication by hand. The system handles it.

A couple of traps that cost you

There are two Keychain behaviors that bite if you don’t know them.

One: Keychain data can survive app deletion. Unlike the file container, which is wiped on uninstall, a Keychain item can stay there and reappear when the user reinstalls. Don’t treat it as storage that clears itself. If your logic needs a clean slate on reinstall, delete your items on first launch.

Two: the kSecAttrAccessGroup attribute and access groups. If you share the Keychain between your app and an extension, or between apps from the same team, that’s the mechanism. Configured wrong, you end up saving in one group and reading from another, and you spend an afternoon wondering why readToken always returns nil.

What we’ve got

The practical summary of this part fits in four lines:

  • Tokens and credentials go in the Keychain, never in UserDefaults or plain files.
  • Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly unless you genuinely need background access.
  • Tell errSecItemNotFound apart from a real error, and don’t swallow the OSStatus.
  • For secrets that require the user’s presence, tie the item to biometrics with access control.

What’s next

The Keychain protects small secrets. But your app also stores files: a local database, cached images, a JSON with the user’s profile. All of that lives on disk, and by default it isn’t as protected as you think.

In part 2 we look at Data Protection: how iOS encrypts files at rest, which protection classes exist, and why a file marked as always accessible is an open door while the device is locked.

Sources: Keychain Services (Apple) · SecAccessControlCreateWithFlags (Apple)