Home

Using the Keychain directly, without a wrapper library

Every iOS project I have joined has had a Keychain wrapper — either a dependency or a copied file — and in each case the wrapper was larger than the thing it wrapped.

The Keychain API is genuinely unpleasant: C functions, CFDictionary, untyped Any values, and error codes instead of errors. It is also small. Here is the whole of what most apps need.

The four operations

import Security

enum Keychain {
    enum Error: Swift.Error {
        case unexpectedStatus(OSStatus)
    }

    static func set(_ data: Data, for key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
        ]

        let attributes: [String: Any] = [
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
        ]

        let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)

        switch status {
        case errSecSuccess:
            return
        case errSecItemNotFound:
            var newItem = query
            newItem.merge(attributes) { current, _ in current }
            let addStatus = SecItemAdd(newItem as CFDictionary, nil)
            guard addStatus == errSecSuccess else { throw Error.unexpectedStatus(addStatus) }
        default:
            throw Error.unexpectedStatus(status)
        }
    }

    static func get(_ key: String) throws -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]

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

        switch status {
        case errSecSuccess:
            return result as? Data
        case errSecItemNotFound:
            return nil
        default:
            throw Error.unexpectedStatus(status)
        }
    }

    static func delete(_ key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw Error.unexpectedStatus(status)
        }
    }
}

Sixty lines, no dependency, and you can read all of it.

The three details that matter

SecItemAdd fails on a duplicate. It returns errSecDuplicateItem rather than overwriting, which is why set above tries SecItemUpdate first and falls back to adding. Wrappers that call delete then add have a window where the value does not exist, which matters if two threads race.

The accessibility constant is a security decision, not a default. This is the one people get wrong:

Constant Available
kSecAttrAccessibleWhenUnlocked Only while the device is unlocked
kSecAttrAccessibleAfterFirstUnlock After the first unlock since boot — needed for background work
…ThisDeviceOnly variants Same, but excluded from backups

The default if you omit it is kSecAttrAccessibleWhenUnlocked, which means a background refresh at 3am cannot read your auth token. That is the cause of “the app logs me out overnight” reports.

AfterFirstUnlock is the right answer for a token that background code needs. WhenUnlockedThisDeviceOnly is right for something that must never leave the device, at the cost of the user losing it when they restore to a new phone.

Warning

Keychain items survive app deletion. Uninstall the app, reinstall it, and the old token is still there. That is occasionally what you want and usually a surprise — it is why testers report being “still logged in” after a fresh install. If you want a clean state on install, clear the Keychain on first launch using a flag in UserDefaults, which does not survive.

Making it typed

Two extensions cover almost every use:

extension Keychain {
    static func setString(_ string: String, for key: String) throws {
        try set(Data(string.utf8), for: key)
    }

    static func string(_ key: String) throws -> String? {
        try get(key).flatMap { String(data: $0, encoding: .utf8) }
    }

    static func setCodable<T: Encodable>(_ value: T, for key: String) throws {
        try set(JSONEncoder().encode(value), for: key)
    }

    static func codable<T: Decodable>(_ type: T.Type, for key: String) throws -> T? {
        try get(key).map { try JSONDecoder().decode(T.self, from: $0) }
    }
}

That is the entire API surface of most wrapper libraries.

What a wrapper is actually for

I do not want to argue that dependencies are always wrong. A wrapper earns its place if you need:

  • Access groups, for sharing between an app and its extensions
  • iCloud Keychain sync (kSecAttrSynchronizable)
  • Biometric protection via SecAccessControl and LAContext
  • Certificates and keys, not just passwords

Those are genuinely fiddly and a well-tested library is worth it. But if what you need is “store a token, read a token, delete a token”, the code above is all of it — and the sixty lines you own are easier to debug than a dependency, because when a Keychain call fails the useful information is the OSStatus, and most wrappers throw it away.