I've been building native iOS apps since 2008 (when Apple released the first native SDK) and have never once used Apple's Keychain APIs directly. Neither have most developers I know, everyone reaches for a wrapper. Over the years I've written several myself. SwiftKeychainKit is the latest one, written from scratch for Swift 6.2. GitHub | Documentation Unless you've dug deeper into how the Keychain actually works, it's easy to miss that it's not a key-value store. It's a SQLite database with a fixed schema: different item classes, each with their own attributes, composite primary keys, and a permission system built around access groups and access control constraints. Most popular wrappers simplify this drastically by reducing it to a string key and a blob of data. I understand why, the raw Security framework API is not pleasant to work with. That's part of why I kept writing my own. I wanted something that's easier to use than the raw API, but without giving up so much of what the Keychain provides. In many of these wrappers, useful attributes and functionality are left out, access control is only available in its most basic form, and detailed error information is hard to get at. Some wrappers also store entries in a proprietary format, which makes switching harder. A different approach SwiftKeychainKit models the actual Keychain structure rather than hiding it behind a key-value interface: separate types for generic passwords, keys (including Secure Enclave), certificates, and identities, each with their own API. Generic passwords and keys, the two most commonly used item classes, get the most detailed treatment. The API makes the primary key explicit through named parameters while keeping everything else simple: let secret = try SecretData.makeByCopyingUTF8(fromUnsafeString: "s3cret") try await Keychain.GenericPassword.add(secret, account: "user@example.com", service: "com.example.myapp") let password = try await Keychain.GenericPassword.get(account: "user@example.com", service: "com.example.myapp") let privateKey = P256.Signing.PrivateKey() try await Keychain.Keys.addPrivateKey(privateKey, applicationTag: tag) let key: P256.Signing.PrivateKey? = try await Keychain.Keys.queryOne(applicationTag: tag) You don't need to think about the underlying Keychain attributes, the API guides you toward correct usage through its parameters. But the Keychain's actual data model is preserved, not abstracted away. For more complex lookups, query methods use scope parameters to filter across multiple dimensions: let passwords = try await Keychain.GenericPassword.query( account: .specific("user@example.com"), service: .any, accessGroup: .any, synchronizable: .any ) Each scope can be .specific() for an exact match or .any to include all values. This maps directly to how the Keychain's query attributes work, without inventing a custom query language. Access control constraints are built from composable types using & and | operators. The type system ensures that only valid combinations compile, no runtime surprises from invalid flags: try await Keychain.GenericPassword.add( secret, account: "user@example.com", service: "com.example.myapp", accessControl: .make( accessibility: .whenUnlockedThisDeviceOnly, constraint: .biometryAny & .devicePasscode ) ) All operations are async. Every Keychain call is I/O against a SQLite database, and async/await lets the caller suspend instead of blocking the calling thread. Secrets are returned as SecretData, a move-only type (~Copyable). This was always a pain point in my earlier implementations: returning secrets as plain Data or String means they can be copied freely, stay in memory indefinitely, and might get swapped to disk. SecretData addresses this by locking its memory with mlock to prevent swapping, zeroing it on deallocation, and providing timing-safe comparison. Non-copyable types were a welcome addition to Swift for this, the compiler enforces single ownership and consuming semantics: let secret = try SecretData.makeByCopyingUTF8(fromUnsafeString: "s3cret") try await Keychain.GenericPassword.add(secret, account: "user", service: "app") // secret is consumed here, using it again is a compile error Trade-offs The trade-off is a slightly more involved API. For generic passwords, a simple get() with account and service is enough since these form a unique primary key. For other item classes like keys, uniqueness depends on a more complex set of attributes, and the developer is responsible for choosing the right values. queryOne() is a convenience that throws an error if your parameters match more than one result. In the simple cases this isn't much different to use, but it does require you to think a bit more about how you organize entries, especially when sharing items across extensions or other apps via access groups. Another conscious trade-off is that all operations are async-only, which makes it harder to use the API from synchronous code. The one exception is delete, which is also available as a synchronous variant to support the typical cleanup scenarios in deinit and similar contexts. Feedback The library is already in production in one of my apps, but I'd appreciate any feedback, especially on the API design and on SecretData as a non-copyable secret container. Are there features you'd expect from a Keychain library that are missing? And if you've taken a similar approach in your own projects, I'd be curious to hear about your experience. 3 posts - 2 participants Read full topic