How to Implement Keychain Storage in SwiftUI
Wrap SecItemAdd, SecItemCopyMatching, SecItemUpdate, and SecItemDelete in a small helper, then expose it through an @Observable class so your SwiftUI views can reactively read and write secrets. Never store passwords or tokens in UserDefaults — the Keychain encrypts them at rest and ties them to the device's Secure Enclave.
import Security
func save(key: String, value: String) {
let data = Data(value.utf8)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecValueData: data
]
SecItemDelete(query as CFDictionary) // remove stale entry
SecItemAdd(query as CFDictionary, nil) // add fresh entry
}
func load(key: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
Full implementation
The pattern below separates concerns cleanly: a pure KeychainHelper struct handles all C-level Keychain Services calls, and a @Observable class called CredentialStore bridges those calls into SwiftUI's data-flow system. Views observe CredentialStore directly — no boilerplate objectWillChange needed thanks to the iOS 17 Observation framework.
import SwiftUI
import Security
// MARK: - Low-level Keychain wrapper
struct KeychainHelper {
enum KeychainError: Error, LocalizedError {
case duplicateItem
case itemNotFound
case unexpectedStatus(OSStatus)
var errorDescription: String? {
switch self {
case .duplicateItem: return "An item with that key already exists."
case .itemNotFound: return "No keychain item found for that key."
case .unexpectedStatus(let s): return "Keychain error: \(s)"
}
}
}
static func save(_ value: String, forKey key: String) throws {
guard let data = value.data(using: .utf8) else { return }
// Delete any existing entry first to avoid errSecDuplicateItem
let deleteQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecValueData: data,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
static func load(forKey key: String) throws -> String {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var item: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &item)
switch status {
case errSecSuccess:
guard let data = item as? Data,
let string = String(data: data, encoding: .utf8) else {
throw KeychainError.itemNotFound
}
return string
case errSecItemNotFound:
throw KeychainError.itemNotFound
default:
throw KeychainError.unexpectedStatus(status)
}
}
static func delete(forKey key: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
}
// MARK: - Observable store for SwiftUI
@Observable
final class CredentialStore {
private(set) var apiToken: String = ""
private let tokenKey = "com.myapp.apiToken"
init() { reload() }
func save(token: String) {
try? KeychainHelper.save(token, forKey: tokenKey)
apiToken = token
}
func clearToken() {
try? KeychainHelper.delete(forKey: tokenKey)
apiToken = ""
}
private func reload() {
apiToken = (try? KeychainHelper.load(forKey: tokenKey)) ?? ""
}
}
// MARK: - SwiftUI view
struct KeychainDemoView: View {
@State private var store = CredentialStore()
@State private var draft = ""
var body: some View {
NavigationStack {
Form {
Section("Stored token") {
if store.apiToken.isEmpty {
Text("None saved")
.foregroundStyle(.secondary)
} else {
Text(store.apiToken)
.font(.system(.body, design: .monospaced))
.lineLimit(1)
.truncationMode(.middle)
}
}
Section("Save new token") {
SecureField("Paste token here", text: $draft)
.accessibilityLabel("API token input")
Button("Save to Keychain") {
guard !draft.isEmpty else { return }
store.save(token: draft)
draft = ""
}
}
Section {
Button("Delete token", role: .destructive) {
store.clearToken()
}
.disabled(store.apiToken.isEmpty)
}
}
.navigationTitle("Keychain Storage")
}
}
}
#Preview {
KeychainDemoView()
}
How it works
-
Delete-then-add pattern —
SecItemDeleteis called unconditionally beforeSecItemAddinKeychainHelper.save. This sidestepserrSecDuplicateItem(OSStatus -25299), which is the single most common Keychain crash in production apps. -
kSecAttrAccessibleWhenUnlockedThisDeviceOnly— this accessibility constant means the item is readable only while the device is unlocked and is never backed up to iCloud or migrated to a new device. For auth tokens that are per-installation secrets, this is the right choice. Swap inkSecAttrAccessibleAfterFirstUnlockfor background-capable apps (e.g., VoIP or location apps). -
@Observable CredentialStore— rather than exposing raw Keychain calls to views,CredentialStoreowns a publishedapiTokenproperty. SwiftUI's Observation framework (iOS 17+) automatically re-renders any view that readsstore.apiTokenwhen it changes — no@PublishedorobjectWillChangeneeded. -
Error surfacing via throws —
KeychainHelpermethods throwKeychainErrorwith aLocalizedErrordescription, so callers can present actionable alerts rather than silently failing. TheCredentialStoreswallows errors withtry?for now; in production you'd bubble them up to an alert. -
SecureFieldfor input — the view usesSecureFieldso the token is masked while typing, preventing shoulder-surfing and excluding the value from the screenshot cache that iOS takes on app backgrounding.
Variants
Keychain-backed @AppStorage replacement (property wrapper)
Need the ergonomics of @AppStorage but with Keychain security? Define a custom @KeychainStorage property wrapper that reads and writes transparently:
@propertyWrapper
struct KeychainStorage {
let key: String
var wrappedValue: String {
get { (try? KeychainHelper.load(forKey: key)) ?? "" }
set { try? KeychainHelper.save(newValue, forKey: key) }
}
}
// Usage inside an @Observable class:
@Observable
final class AuthModel {
@KeychainStorage(key: "com.myapp.refreshToken")
var refreshToken: String
}
Shared Keychain across App Group / App Clip
To share a Keychain item between your main app and an App Clip or Share Extension, add kSecAttrAccessGroup to your query dictionaries. The value must match an App Group identifier enabled in both targets' entitlements (e.g., "group.com.myapp.shared"). Both targets also need the Keychain Sharing entitlement with the same group listed. This is the recommended way to pass a login token from an App Clip to the full app after install, rather than passing it through URL parameters.
Common pitfalls
- Simulator vs. device behaviour: On the iOS Simulator, Keychain items persist between app uninstalls and even across different simulators sharing the same host user. Always test Keychain delete/reset flows on a real device, where uninstalling the app removes its Keychain items (unless an App Group is used).
-
Forgetting to delete before add: Calling
SecItemAddon a key that already exists returnserrSecDuplicateItem(-25299). Always callSecItemDeletefirst, or useSecItemUpdateconditionally — otherwise your token save silently fails and the user wonders why they keep getting logged out. -
Accessibility constants and iCloud Backup:
kSecAttrAccessibleAlwaysand its variants withoutThisDeviceOnlyare backed up to iCloud. A leaked backup can expose your users' secrets. PreferkSecAttrAccessibleWhenUnlockedThisDeviceOnlyfor tokens andkSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyfor items that should disappear if the user removes their device passcode. -
VoiceOver & SecureField: Always set
.accessibilityLabelonSecureFieldinputs — VoiceOver will otherwise announce the placeholder text, which can inadvertently reveal what credential type the field expects.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement keychain storage in SwiftUI for iOS 17+. Use Keychain Services (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete). Wrap in a KeychainHelper struct and expose via an @Observable CredentialStore class. Make it accessible (VoiceOver labels on SecureField inputs). Add a #Preview with realistic sample data.
In Soarias's Build phase, paste this prompt into the implementation panel to scaffold the full Keychain layer — Soarias will wire it into your existing SwiftData model and generate the entitlements plist entries automatically.
Related
FAQ
Does this work on iOS 16?
KeychainHelper itself works on iOS 13+ — Keychain Services is a C API that predates SwiftUI. However, the @Observable macro used in CredentialStore requires iOS 17+. For iOS 16 support, replace @Observable with class CredentialStore: ObservableObject, mark apiToken with @Published, and inject the store with @StateObject instead of @State.
Can I store Data (binary blobs) instead of Strings — for example, a cryptographic key?
Yes. The Keychain natively stores Data via kSecValueData. Omit the UTF-8 encoding/decoding steps in KeychainHelper and work with Data directly. For asymmetric keys (RSA, EC), prefer kSecClassKey instead of kSecClassGenericPassword, and use the CryptoKit/SecKey APIs, which integrate tightly with the Secure Enclave for hardware-backed key storage.
What's the UIKit equivalent?
Keychain Services is framework-agnostic C API — the exact same KeychainHelper struct works unchanged in UIKit apps. In UIKit you'd typically create a singleton service (e.g., KeychainService.shared) and call it from view controllers or a Combine-powered AuthViewModel. There is no UIKit-specific Keychain wrapper; both UIKit and SwiftUI apps call the same Security framework functions.
Last reviewed: 2026-05-11 by the Soarias team.