Introduction

iOS Keychain items have an accessibility attribute that controls when the data is available. Using kSecAttrAccessibleAfterFirstUnlock allows access after the first device unlock but before the user enters their passcode. Using kSecAttrAccessibleWhenUnlocked requires the device to be unlocked. Choosing the wrong accessibility level causes data to be inaccessible at critical times, such as when a background task needs to authenticate before the user unlocks the device.

Symptoms

  • Keychain query returns errSecItemNotFound after device restart
  • Background fetch cannot access authentication tokens
  • Push notification handler fails to read stored credentials
  • WatchKit extension cannot access shared keychain items
  • Data accessible in foreground but not in background tasks

Error: ```swift let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "authToken", kSecReturnData as String: true ]

var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) // status = errSecItemNotFound (-25300) // Data exists but is not accessible with current accessibility level ```

Common Causes

  • Wrong kSecAttrAccessible value for the use case
  • Data stored with WhenUnlocked but needed during background processing
  • Keychain group access not configured for app extensions
  • iCloud Keychain sync interfering with local accessibility
  • Migration from old accessibility level to new one fails

Step-by-Step Fix

  1. 1.Choose correct accessibility level for the use case:
  2. 2.```swift
  3. 3.import Security

enum KeychainAccessibility { // Available only when device is unlocked case whenUnlocked // Available after first unlock, even if device is locked later case afterFirstUnlock // Same as afterFirstUnlock but does not iCloud backup case afterFirstUnlockThisDeviceOnly }

func saveToken(_ token: String, accessibility: KeychainAccessibility) -> OSStatus { let data = token.data(using: .utf8)!

let accessibleValue: CFString = { switch accessibility { case .whenUnlocked: return kSecAttrAccessibleWhenUnlocked case .afterFirstUnlock: return kSecAttrAccessibleAfterFirstUnlock case .afterFirstUnlockThisDeviceOnly: return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly } }()

let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "authToken", kSecAttrService as String: "com.myapp.service", kSecAttrAccessible as String: accessibleValue, kSecValueData as String: data ]

// Delete existing item first SecItemDelete(query as CFDictionary)

return SecItemAdd(query as CFDictionary, nil) }

// For background tasks (push notifications, background fetch): saveToken(token, accessibility: .afterFirstUnlock)

// For user-facing data only: saveToken(token, accessibility: .whenUnlocked) ```

  1. 1.Migrate existing keychain items to new accessibility level:
  2. 2.```swift
  3. 3.func migrateKeychainAccessibility(
  4. 4.account: String,
  5. 5.from fromAccessibility: CFString,
  6. 6.to toAccessibility: CFString
  7. 7.) -> Bool {
  8. 8.// Query existing item
  9. 9.let query: [String: Any] = [
  10. 10.kSecClass as String: kSecClassGenericPassword,
  11. 11.kSecAttrAccount as String: account,
  12. 12.kSecReturnData as String: true,
  13. 13.kSecAttrAccessible as String: fromAccessibility,
  14. 14.kSecMatchLimit as String: kSecMatchLimitOne
  15. 15.]

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

guard status == errSecSuccess, let data = result as? Data else { return false // Item not found or wrong accessibility }

// Delete old item let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: account, kSecAttrAccessible as String: fromAccessibility ] SecItemDelete(deleteQuery as CFDictionary)

// Add with new accessibility let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: account, kSecAttrAccessible as String: toAccessibility, kSecValueData as String: data ]

return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess } ```

  1. 1.**Configure keychain sharing for app groups":
  2. 2.```swift
  3. 3.// For sharing keychain data between app and extensions
  4. 4.func saveSharedToken(_ token: String) -> OSStatus {
  5. 5.let data = token.data(using: .utf8)!

let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "sharedToken", kSecAttrService as String: "group.com.myapp.shared", // Must match App Group kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, kSecAttrAccessGroup as String: "TEAMID.com.myapp.shared", // Your Team ID + group kSecValueData as String: data ]

return SecItemAdd(query as CFDictionary, nil) } ```

Prevention

  • Use kSecAttrAccessibleAfterFirstUnlock for tokens needed by background tasks
  • Use kSecAttrAccessibleWhenUnlocked for user-only data
  • Test keychain access after device restart in QA
  • Document accessibility requirements for each stored item
  • Use a keychain wrapper library that exposes accessibility as a parameter
  • Add keychain accessibility audit to code review checklist