```html SwiftUI: How to Implement Keychain Storage (iOS 17+, 2026)

How to Implement Keychain Storage in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Keychain Services Updated: May 11, 2026
TL;DR

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

  1. Delete-then-add patternSecItemDelete is called unconditionally before SecItemAdd in KeychainHelper.save. This sidesteps errSecDuplicateItem (OSStatus -25299), which is the single most common Keychain crash in production apps.
  2. 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 in kSecAttrAccessibleAfterFirstUnlock for background-capable apps (e.g., VoIP or location apps).
  3. @Observable CredentialStore — rather than exposing raw Keychain calls to views, CredentialStore owns a published apiToken property. SwiftUI's Observation framework (iOS 17+) automatically re-renders any view that reads store.apiToken when it changes — no @Published or objectWillChange needed.
  4. Error surfacing via throwsKeychainHelper methods throw KeychainError with a LocalizedError description, so callers can present actionable alerts rather than silently failing. The CredentialStore swallows errors with try? for now; in production you'd bubble them up to an alert.
  5. SecureField for input — the view uses SecureField so 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

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.

```