Securely managing user details in the iOS Keychain for iOS app development by 200OK Solutions.

Securely Managing User Details in the Keychain on iOS: Part I

Share this post on:

If you need to keep a password, API token or refresh token on an iPhone, the Keychain is the right place. You add the secret with SecItemAdd, read it with SecItemCopyMatching, change it with SecItemUpdate and remove it with SecItemDelete. This guide shows each call in Swift, explains the attributes that matter, and covers the mistakes that cause most Keychain bugs.

What is the iOS Keychain?

Keychain Services is Apple’s encrypted storage for small, sensitive values: passwords, tokens, cryptographic keys and certificates. Items are encrypted by the system, and by default only the app that created an item can read it. Apps from the same developer can share items through an access group.

The Keychain is built for small secrets, not for whole user profiles or large files.

Keychain vs UserDefaults vs files

  • Keychain: passwords, auth tokens, encryption keys. Encrypted, with controls over when the data can be read.
  • UserDefaults: non-sensitive settings such as theme or onboarding state. Stored unencrypted in a property list.
  • Files, SwiftData or Core Data: larger or structured app data. Protect them with file protection, and keep the encryption key in the Keychain if the content is sensitive.

A good rule: if leaking the value would let someone act as the user, it belongs in the Keychain.

How a Keychain query works

Every Keychain call takes a dictionary of attributes. The dictionary says which item you mean, or which item you want to create. For a generic secret, the identity of an item is the combination of kSecClass, kSecAttrService and kSecAttrAccount.

The attributes you will actually use

  • kSecClass (required): the kind of item. Common values are kSecClassGenericPassword (app secrets and tokens), kSecClassInternetPassword (a credential for a server), kSecClassCertificate, kSecClassKey and kSecClassIdentity.
  • kSecAttrService: a label for the part of your app that owns the item, such as com.example.myapp.auth. Use it with generic passwords.
  • kSecAttrAccount: the account or key name, such as a username or refreshToken.
  • kSecAttrServer: the host name for kSecClassInternetPassword items. This is the Internet-password counterpart of the service attribute.
  • kSecValueData: the secret itself, as Data.
  • kSecAttrAccessible: when the item can be read (see the next section).
  • kSecAttrAccessControl: an access control object, used for biometrics or passcode requirements. Do not set it together with kSecAttrAccessible; the access control object already carries an accessibility value.
  • kSecAttrAccessGroup: the Keychain access group, used to share items between your own apps. It needs the Keychain Sharing capability.
  • kSecAttrSynchronizable: set to true to sync an item through iCloud Keychain.
  • kSecAttrLabel: a human-readable label.
  • kSecReturnData, kSecReturnAttributes, kSecReturnRef: choose what a search returns. kSecAttrCreationDate and kSecAttrModificationDate come back as attributes; the system sets them, so you do not pass them in.
  • kSecMatchLimit: kSecMatchLimitOne for a single result or kSecMatchLimitAll for every match.
  • kSecAttrKeyType and kSecAttrKeySizeInBits: used with kSecClassKey when creating cryptographic keys, for example kSecAttrKeyTypeECSECPrimeRandom and 256 bits.

kSecAttrAccess appears in some older tutorials, but it is macOS-only and does not apply to iOS.

Choose when the data can be read: kSecAttrAccessible

This is the attribute most tutorials skip, and it matters. It controls when the system will release the item.

  • kSecAttrAccessibleWhenUnlocked: readable only while the device is unlocked.
  • kSecAttrAccessibleWhenUnlockedThisDeviceOnly: same, but the item is not included in backups or synced to a new device.
  • kSecAttrAccessibleAfterFirstUnlock: readable after the first unlock following a restart. Useful for background tasks.
  • kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: the same, not migrated to other devices.
  • kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly: readable only while unlocked, and only if the device has a passcode. The item is deleted if the passcode is removed.

For most auth tokens, WhenUnlockedThisDeviceOnly is a safe default. Use AfterFirstUnlock only if your app genuinely needs the secret while the device is locked, for example during background refresh. Avoid kSecAttrAccessibleAlways; Apple deprecated it.

The four core functions

  • SecItemAdd(_:_:): create a new item. It fails with errSecDuplicateItem if one already exists.
  • SecItemCopyMatching(_:_:): find items and return data or attributes.
  • SecItemUpdate(_:_:): change an existing item. You pass one dictionary that finds the item and another with the new values.
  • SecItemDelete(_:): remove matching items.

All four return an OSStatus. errSecSuccess means success. Related code that is not a failure of your app, such as errSecItemNotFound, is part of normal flow and should be handled.

A complete, reusable Keychain wrapper

The wrapper below handles add, read, update, save-or-update and delete, with typed errors. It uses a generic password item scoped by service and account.

import Foundation
import Security

enum KeychainError: Error {
    case duplicateItem
    case itemNotFound
    case unexpectedData
    case unhandled(OSStatus)
}

struct KeychainStore {
    let service: String

    private func baseQuery(account: String) -> [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
    }

    /// Creates a new item. Throws .duplicateItem if it already exists.
    func add(_ data: Data, account: String) throws {
        var query = baseQuery(account: account)
        query[kSecValueData as String] = data
        query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly

        let status = SecItemAdd(query as CFDictionary, nil)
        switch status {
        case errSecSuccess:
            return
        case errSecDuplicateItem:
            throw KeychainError.duplicateItem
        default:
            throw KeychainError.unhandled(status)
        }
    }

    /// Returns the stored data. Throws .itemNotFound if nothing is saved.
    func read(account: String) throws -> Data {
        var query = baseQuery(account: account)
        query[kSecReturnData as String] = kCFBooleanTrue
        query[kSecMatchLimit as String] = kSecMatchLimitOne

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

        guard status != errSecItemNotFound else { throw KeychainError.itemNotFound }
        guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
        guard let data = result as? Data else { throw KeychainError.unexpectedData }
        return data
    }

    /// Replaces the data of an existing item.
    func update(_ data: Data, account: String) throws {
        let attributes: [String: Any] = [kSecValueData as String: data]
        let status = SecItemUpdate(baseQuery(account: account) as CFDictionary,
                                   attributes as CFDictionary)

        guard status != errSecItemNotFound else { throw KeychainError.itemNotFound }
        guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
    }

    /// Adds the item, or updates it if it already exists.
    func save(_ data: Data, account: String) throws {
        do {
            try add(data, account: account)
        } catch KeychainError.duplicateItem {
            try update(data, account: account)
        }
    }

    /// Deletes the item. A missing item is not treated as an error.
    func delete(account: String) throws {
        let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unhandled(status)
        }
    }
}

Using the wrapper

let store = KeychainStore(service: "com.example.myapp.auth")

do {
    // Save (adds or updates)
    try store.save(Data(refreshToken.utf8), account: "refreshToken")

    // Read
    let data = try store.read(account: "refreshToken")
    let token = String(decoding: data, as: UTF8.self)

    // Delete on sign-out
    try store.delete(account: "refreshToken")
} catch KeychainError.itemNotFound {
    // Nothing saved yet: show the sign-in screen
} catch {
    print("Keychain error: \(error)")
}

Prefer storing a token over a raw password. If the token leaks, you can revoke it; a leaked password has to be changed everywhere the user reused it.

Turn OSStatus codes into readable messages

A bare number such as -25299 is hard to debug. SecCopyErrorMessageString(_:_:) converts an OSStatus into a readable description. Apple lists the codes in its Security framework result codes.

extension KeychainError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .duplicateItem: return "An item with this key already exists."
        case .itemNotFound: return "No item was found for this key."
        case .unexpectedData: return "The stored data was not in the expected format."
        case .unhandled(let status):
            let message = SecCopyErrorMessageString(status, nil) as String?
            return message ?? "Keychain error \(status)"
        }
    }
}

Require Face ID or Touch ID to read an item

For very sensitive values, attach an access control object so the system asks for biometrics before releasing the data. Add NSFaceIDUsageDescription to your Info.plist, or Face ID will not work.

import LocalAuthentication

extension KeychainStore {
    func saveProtected(_ data: Data, account: String) throws {
        var error: Unmanaged<CFError>?
        guard let access = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
            .biometryCurrentSet,
            &error
        ) else {
            throw error!.takeRetainedValue() as Error
        }

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecAttrAccessControl as String: access,
            kSecValueData as String: data
        ]

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

    func readProtected(account: String, reason: String) throws -> Data {
        let context = LAContext()
        context.localizedReason = reason

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: kCFBooleanTrue as Any,
            kSecMatchLimit as String: kSecMatchLimitOne,
            kSecUseAuthenticationContext as String: context
        ]

        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
        guard let data = result as? Data else { throw KeychainError.unexpectedData }
        return data
    }
}

.biometryCurrentSet invalidates the item if the user adds or removes a fingerprint or face. Use .biometryAny if you do not want that, or .userPresence to allow the passcode as a fallback. Reading a protected item shows a system prompt and can block, so call it off the main thread.

Common Keychain mistakes

  1. Calling SecItemAdd for an item that exists. You get errSecDuplicateItem. Use the save method above, which falls back to update.
  2. Leaving out kSecAttrService. Items from different features can collide. Always set it.
  3. Setting both kSecAttrAccessible and kSecAttrAccessControl. Choose one.
  4. Expecting the Keychain to be wiped on uninstall. Items can survive a reinstall. Check for a first-launch flag and clear old items if your app needs a clean start.
  5. Storing large or non-secret data. Keep the Keychain for secrets, and use another store for everything else.
  6. Logging secrets. Never print tokens or passwords, even in debug builds.
  7. Reading protected items on the main thread. Authentication prompts can freeze the UI.

FAQ

Q. Is the iOS Keychain secure?

A. Yes, for small secrets. The system encrypts items and ties access to your app and to the accessibility level you choose. No storage is perfect: a jailbroken device or a compromised account is outside what the Keychain can defend.

Q. What is the difference between the Keychain and UserDefaults?

A. UserDefaults stores values unencrypted and is meant for preferences. The Keychain encrypts values and controls when they can be read, so it is the place for passwords and tokens.

Q. Can I store a whole user object in the Keychain?

A. You can encode a small object to Data, but it is better to store only the secret (a token or key) in the Keychain and keep profile fields in normal app storage.

Q. Does the Keychain sync between devices?

A. Only if you set kSecAttrSynchronizable to true and the user has iCloud Keychain enabled. The ThisDeviceOnly accessibility values keep items on one device.

Q. Should I use a library instead of writing a wrapper?

A. A thin wrapper like the one above is enough for most apps. Libraries such as KeychainAccess or Valet add convenience, but you give up some control and gain a dependency.

Next steps

You now have a working Keychain layer for tokens and credentials. In Part II, we go further into practical patterns for managing user details. (Editor: confirm Part II’s current content and retitle it as the advanced follow-up, for example “iOS Keychain Access Groups, iCloud Sync and Testing”, so it no longer repeats the basics here.)

Avatar photo

Piyush Solanki

PHP Tech Lead & Backend Architect

10+ years experience
UK market specialist
Global brands & SMEs
Full-stack expertise

Core Technologies

PHP 95%
MySQL 90%
WordPress 92%
AWS 88%
  • Backend: PHP, MySQL, CodeIgniter, Laravel
  • CMS: WordPress customization & plugin development
  • APIs: RESTful design, microservices architecture
  • Frontend: React, TypeScript, modern admin panels
  • Cloud: AWS S3, Linux deployments
  • Integrations: Stripe, SMS/OTP gateways
  • Finance: Secure payment systems & compliance
  • Hospitality: Booking & reservation systems
  • Retail: E-commerce platforms & inventory
  • Consulting: Custom business solutions
  • Food Services: Delivery & ordering systems
  • Modernizing legacy systems for scalability
  • Building secure, high-performance products
  • Mobile-first API development
  • Agile collaboration with cross-functional teams
  • Focus on operational efficiency & innovation

Piyush Solanki is a seasoned PHP Tech Lead with 10+ years of experience architecting and delivering scalable web and mobile backend solutions for global brands and fast-growing SMEs.

He specializes in PHP, MySQL, CodeIgniter, WordPress, and custom API development, helping businesses modernize legacy systems and launch secure, high-performance digital products.

He collaborates closely with mobile teams building Android & iOS apps, developing RESTful APIs, cloud integrations, and secure payment systems. With extensive experience in the UK market and across multiple sectors, Piyush Solanki is passionate about helping SMEs scale technology teams and accelerate innovation through backend excellence.

    Reach Out Us


    Your name

    Your email

    Subject

    Your message