# Keychain Access Control

Two independent settings decide whether a read succeeds:

- **When** the item can be decrypted: `kSecAttrAccessible` picks the data
  protection class, which decides whether the class key is in memory given the
  device state (locked, unlocked, not yet unlocked since boot).
- **How** the user must prove presence at the moment of access:
  `SecAccessControl` adds biometric, passcode or application-password
  requirements.

A read has to satisfy both. Getting either wrong produces the keychain failures
that reach production most often: background reads that return nothing, items
that vanish after a device migration, and credentials that are readable at rest
when they should not be.

Sources: Apple Platform Security Guide (2024 to 2026 editions), Keychain
Services documentation, TN3137, the `SecAccessControl` documentation, WWDC
2014 Session 711, WWDC 2015 Session 706, and OWASP MASTG.

## Contents

- [When: The Accessibility Constants](#when-the-accessibility-constants)
- [How: SecAccessControl Flags](#how-secaccesscontrol-flags)
- [Accessibility and Access Control Are Exclusive](#accessibility-and-access-control-are-exclusive)
- [Choosing a Combination](#choosing-a-combination)
- [Mistakes in Generated Code](#mistakes-in-generated-code)
- [Code Patterns](#code-patterns)
- [macOS: kSecUseDataProtectionKeychain](#macos-ksecusedataprotectionkeychain)
- [File Protection Sidebar](#file-protection-sidebar)
- [Error Codes](#error-codes)
- [Version Timeline](#version-timeline)
- [Testing](#testing)
- [Checklist](#checklist)
- [Related Files](#related-files)

## When: The Accessibility Constants

Every item is encrypted under a class key. Class keys derive from the device's
hardware UID and, for most classes, from the passcode as well. If an add leaves
`kSecAttrAccessible` out, the item gets `kSecAttrAccessibleWhenUnlocked`, which
means nothing running in the background can read it.

| Constant | Readable when | Leaves the device? | Notes |
| --- | --- | --- | --- |
| `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` (iOS 8+) | unlocked, and a passcode is currently set | never | `SecItemAdd` fails if there is no passcode. Removing the passcode deletes every such item for good. No backup, no iCloud sync, not in the escrow keybag, and no migratable variant. |
| `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` | unlocked | never | Not in backups, not migrated. |
| `kSecAttrAccessibleWhenUnlocked` | unlocked | encrypted backups | The default. Corresponds to `NSFileProtectionComplete`. With Require Passcode set to Immediately, the class key is discarded about ten seconds after lock. |
| `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | from the first unlock after boot until restart, including while locked | never | The choice for background work that should stay on this device. |
| `kSecAttrAccessibleAfterFirstUnlock` | same as above | encrypted backups | Apple uses it for Wi-Fi passwords, mail accounts and iCloud tokens. Corresponds to `NSFileProtectionCompleteUntilFirstUserAuthentication`. |
| `kSecAttrAccessibleAlwaysThisDeviceOnly` | always | never | Deprecated in iOS 12 and macOS 10.14; the intent was announced at WWDC 2015 Session 706. |
| `kSecAttrAccessibleAlways` | always | encrypted backups | Deprecated at the same time. Protected by the UID only, the same strength as `NSFileProtectionNone`. |

The two `Always` constants still compile, but what they do depends on the OS
version (they may be treated as `AfterFirstUnlock`). Move existing items to
`AfterFirstUnlock` or something stricter, and add a CI lint that rejects the
constants.

Summary:

- Still readable while locked: the `AfterFirstUnlock` pair (and the deprecated
  `Always` pair).
- Carried in backups: `WhenUnlocked`, `AfterFirstUnlock`, `Always`.

### Device states

- After a restart the device is in **Before First Unlock** (BFU). Nothing but
  the `Always` classes can be read, including `AfterFirstUnlock` items.
- Once the user enters the passcode the device is **After First Unlock** (AFU).
  `AfterFirstUnlock` class keys stay loaded through later lock cycles until the
  next restart. `WhenUnlocked` keys are dropped at every lock.
- From iOS 15, the system may pre-warm an app, launching its process before the
  first unlock. Even `AfterFirstUnlock` items can be unreadable then. Check
  `UIApplication.shared.isProtectedDataAvailable` and postpone the read when it
  is `false` (or wait for `protectedDataDidBecomeAvailableNotification`).

## How: SecAccessControl Flags

```swift
func SecAccessControlCreateWithFlags(
    _ allocator: CFAllocator?,
    _ protection: CFTypeRef,
    _ flags: SecAccessControlCreateFlags,
    _ error: UnsafeMutablePointer<Unmanaged<CFError>?>?
) -> SecAccessControl?
```

Pass `nil` for the allocator. `protection` is one of the accessibility
constants above; it becomes part of the access-control object.

| Flag | Since | Requires | Works in the background |
| --- | --- | --- | --- |
| `.userPresence` | iOS 8 | biometry or the passcode; falls back to the passcode by itself and works with nothing enrolled. Close to `[.biometryAny, .or, .devicePasscode]` but tolerant of devices without biometry. | no |
| `.biometryAny` | iOS 11.3 (was `.touchIDAny`) | any enrolled finger or face; survives enrollment changes | no |
| `.biometryCurrentSet` | iOS 11.3 (was `.touchIDCurrentSet`) | the enrollment that existed when the item was stored; any change invalidates the item. The strongest choice, since an attacker who adds their own face or finger gains nothing. | no |
| `.devicePasscode` | iOS 9 | the passcode only | no |
| `.privateKeyUsage` | iOS 9 | needed for any Secure Enclave private key operation (signing, key agreement) | yes, for key operations |
| `.applicationPassword` | iOS 9 | a password supplied by the app, mixed into key derivation; an additional encryption layer rather than a gate | yes, if the password is cached |

`.or` accepts any one of the listed requirements; `.and` demands all of them.

What misuse looks like:

| Flag | Failure |
| --- | --- |
| `.userPresence` | -25308 when read in the background |
| `.biometryAny` | `errSecAuthFailed` when nothing is enrolled |
| `.biometryCurrentSet` | authentication fails for good after an enrollment change |
| `.devicePasscode` | -25308 when no UI can be shown |

Usual pairings: `.userPresence` with a foreground `WhenUnlocked` item;
`.biometryCurrentSet` with `WhenPasscodeSetThisDeviceOnly`; `.devicePasscode`
for flows that a compliance rule requires; `.privateKeyUsage` on Secure
Enclave keys.

`SecAccessControlCreateFlags` is an `OptionSet`:

```swift
let strongWithRecovery: SecAccessControlCreateFlags = [.biometryCurrentSet, .or, .devicePasscode]
let both: SecAccessControlCreateFlags = [.biometryAny, .and, .devicePasscode]
let layered: SecAccessControlCreateFlags = [.biometryAny, .or, .devicePasscode, .applicationPassword]
```

Two authentication flags with no `.or` or `.and` between them make
`SecAccessControlCreateWithFlags` return `nil` with `errSecParam` (-50).

Any item whose flags involve biometry makes the system show Face ID on devices
that have it, so the app's `Info.plist` must contain
`NSFaceIDUsageDescription`. See
[biometric-authentication.md](biometric-authentication.md).

## Accessibility and Access Control Are Exclusive

An add dictionary carries `kSecAttrAccessible` or `kSecAttrAccessControl`,
never both. The pair is rejected with `errSecParam` (-50). With access control,
the accessibility value lives inside the `SecAccessControl` object.

```swift
// Wrong: accessibility given twice, and the access control force-unwrapped.
let acl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
                                          .userPresence, nil)!
let clash: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "journal-key",
    kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    kSecAttrAccessControl as String: acl
]
```

```swift
func journalKeyEntry(_ bytes: Data) throws -> [String: Any] {
    var cfError: Unmanaged<CFError>?
    guard let acl = SecAccessControlCreateWithFlags(
        nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .userPresence, &cfError) else {
        throw (cfError?.takeRetainedValue() as Error?) ?? KeychainError(status: errSecParam)
    }
    return [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.example.journal",
        kSecAttrAccount as String: "journal-key",
        kSecAttrAccessControl as String: acl,
        kSecValueData as String: bytes
    ]
}
```

## Choosing a Combination

| Setting | For | Consequence to design for | Examples |
| --- | --- | --- | --- |
| `WhenPasscodeSetThisDeviceOnly` + `.biometryCurrentSet` | the most sensitive data | Items disappear if the passcode is removed and never move to a new device; the app must cope with the item being gone and re-authenticate. | banking session tokens, password-vault keys, health-record credentials, end-to-end private keys |
| `WhenUnlockedThisDeviceOnly` | device-bound data that should survive passcode changes | Re-authenticate after migration. | refreshable OAuth access tokens, app API keys, cached credentials, device registration tokens |
| `AfterFirstUnlockThisDeviceOnly` | anything read by background code: push handlers, WidgetKit timeline providers, background fetch, VPN and notification service extensions | Readable while locked, so weaker at rest. | push payload decryption keys, VPN credentials, background sync tokens, watch connectivity tokens |
| `AfterFirstUnlock` | background access that should also survive a restore | Travels in encrypted backups. | enterprise VPN credentials, mail credentials, Wi-Fi passwords |

When one credential needs both background access and biometric protection in
the foreground, keep two items: one `AfterFirstUnlockThisDeviceOnly` with no
presence flags for background code, and one `WhenUnlockedThisDeviceOnly` with a
biometric access control for the foreground path.

## Mistakes in Generated Code

1. **No accessibility value.** The item defaults to `WhenUnlocked`. Everything
   works during development on an unlocked phone; in the background the read
   returns -25308, which is often swallowed. Background items should say
   `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` explicitly.
2. **`kSecAttrAccessibleAlways`.** On iOS 12 and later it compiles with a
   deprecation warning and runs, offering no protection tied to the lock state.
   Replace it with `kSecAttrAccessibleAfterFirstUnlock`.
3. **Forgetting what `ThisDeviceOnly` means.** Such items are tied to the
   hardware UID and are left out of backups, iCloud Keychain and Quick Start
   transfers. After a restore onto a new device the read returns
   `errSecItemNotFound` (-25300). Build the re-authentication path.
4. **`.biometryCurrentSet` on an `AfterFirstUnlock` item.** The API accepts it,
   but the two settings contradict each other: the class promises background
   availability and the flag demands a user. Background reads fail with
   `errSecInteractionNotAllowed`.
5. **Two authentication flags with no operator.** `nil` and `errSecParam`.

## Code Patterns

### Top-sensitivity secret

Saving follows add, then update on duplicate. Updating the data of an item that
has an access control requires the user to authenticate, so pass an
`LAContext` for the prompt. `SecItemUpdate` cannot change an item's access
control, so a change of policy is the one case that deletes and re-adds.

```swift
import LocalAuthentication

enum VaultKeyStore {
    static var base: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: "com.example.vault",
            kSecAttrAccount as String: "master"
        ]
    }

    static func policy() throws -> SecAccessControl {
        var cfError: Unmanaged<CFError>?
        guard let acl = SecAccessControlCreateWithFlags(
            nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
            [.biometryCurrentSet, .or, .devicePasscode], &cfError) else {
            throw (cfError?.takeRetainedValue() as Error?) ?? KeychainError(status: errSecParam)
        }
        return acl
    }

    static func save(_ key: Data, context: LAContext) throws {
        var insert = base
        insert[kSecAttrAccessControl as String] = try policy()
        insert[kSecValueData as String] = key
        let added = SecItemAdd(insert as CFDictionary, nil)
        guard added == errSecDuplicateItem else {
            guard added == errSecSuccess else { throw KeychainError(status: added) }
            return
        }
        var match = base
        match[kSecUseAuthenticationContext as String] = context
        let changed = SecItemUpdate(match as CFDictionary,
                                    [kSecValueData as String: key] as CFDictionary)
        guard changed == errSecSuccess else { throw KeychainError(status: changed) }
    }

    static func replacePolicy(keeping key: Data) throws {
        let removed = SecItemDelete(base as CFDictionary)
        guard removed == errSecSuccess || removed == errSecItemNotFound else {
            throw KeychainError(status: removed)
        }
        var insert = base
        insert[kSecAttrAccessControl as String] = try policy()
        insert[kSecValueData as String] = key
        let added = SecItemAdd(insert as CFDictionary, nil)
        guard added == errSecSuccess else { throw KeychainError(status: added) }
    }
}
```

`replacePolicy` exists only because access control cannot be updated in place.
Read the value (with authentication) before calling it, so nothing is lost.
`base` is computed: a `[String: Any]` is not `Sendable`, so a `static let` of
it is a Swift 6 error.

### Token read by background code

```swift
func saveSyncToken(_ token: Data) throws {
    let match: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.example.sync",
        kSecAttrAccount as String: "bg-token"
    ]
    var insert = match
    insert[kSecValueData as String] = token
    insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
    let added = SecItemAdd(insert as CFDictionary, nil)
    if added == errSecSuccess { return }
    guard added == errSecDuplicateItem else { throw KeychainError(status: added) }
    let changed = SecItemUpdate(match as CFDictionary,
                                [kSecValueData as String: token] as CFDictionary)
    guard changed == errSecSuccess else { throw KeychainError(status: changed) }
}
```

### What goes wrong in an extension

```swift
// Wrong: the item was stored with the default WhenUnlocked class. A widget or
// notification service extension running while the phone is locked gets -25308,
// and this code turns that into a silent nil with no log.
func tokenForWidget() -> Data? {
    var out: CFTypeRef?
    let q: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                            kSecAttrAccount as String: "widget-token",
                            kSecReturnData as String: true]
    guard SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess else { return nil }
    return out as? Data
}
```

## macOS: kSecUseDataProtectionKeychain

Unless told otherwise, SecItem on macOS works on the legacy file-based keychain
at `~/Library/Keychains/login.keychain-db`. Without
`kSecUseDataProtectionKeychain: true`:

- `SecAccessControl` fails with `errSecParam`;
- iCloud Keychain sync is unavailable;
- Secure Enclave keys cannot be stored;
- Touch ID protection does not apply.

```swift
let macEntry: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: "com.example.mac-notes",
    kSecAttrAccount as String: "sync",
    kSecValueData as String: Data("v".utf8),
    kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    kSecUseDataProtectionKeychain as String: true
]
```

iOS, tvOS and watchOS ignore the flag. The data protection keychain needs a
logged-in user context, so a `launchd` daemon running outside any user session
has to use the legacy keychain.

## File Protection Sidebar

The keychain is for small, discrete secrets. Documents, databases and images
use `NSFileProtection` instead.

| File class | Keychain counterpart | Behaviour |
| --- | --- | --- |
| A: `NSFileProtectionComplete` | `WhenUnlocked` | key discarded about ten seconds after lock |
| B: `NSFileProtectionCompleteUnlessOpen` | none | uses Curve25519 key agreement so a file opened before lock can still be written while locked (for example an attachment downloading in the background) |
| C: `NSFileProtectionCompleteUntilFirstUserAuthentication` | `AfterFirstUnlock` | the default for third-party app files |
| D: `NSFileProtectionNone` | deprecated `Always` | UID protection only |

For layered protection, keep an encryption key in the keychain as
`WhenUnlockedThisDeviceOnly`, encrypt the file with it, and mark the file
`NSFileProtectionComplete` as well.

## Error Codes

| Code | Name | Typical cause |
| --- | --- | --- |
| -25308 | `errSecInteractionNotAllowed` | a `WhenUnlocked` item read while locked; an `AfterFirstUnlock` item read before first unlock; a biometric flag used in the background |
| -50 | `errSecParam` | both accessibility attributes present; flags without a combinator; access control on macOS without the data protection flag |
| -25293 | `errSecAuthFailed` | biometric match failed; enrollment changed under `.biometryCurrentSet`; nothing enrolled |
| -25300 | `errSecItemNotFound` | never stored; a `ThisDeviceOnly` item lost in migration; a `WhenPasscodeSet` item deleted with the passcode |
| -25299 | `errSecDuplicateItem` | save with add-or-update |
| -128 | `errSecUserCanceled` | the user tapped Cancel on the prompt |
| -34018 | `errSecMissingEntitlement` | the access group is not in the entitlements; frequent in the Simulator |

-25308 is the hardest of these to catch: it happens in production and rarely on
a developer's unlocked phone. Postpone the work and retry once
`isProtectedDataAvailable` is `true`.

## Version Timeline

| Release | Change |
| --- | --- |
| iOS 8 | `WhenPasscodeSetThisDeviceOnly`, `SecAccessControlCreateWithFlags`, `.userPresence` |
| iOS 9 | `.devicePasscode`, `.applicationPassword`, `.privateKeyUsage`; plan to retire `Always` announced (WWDC 2015 Session 706) |
| iOS 11.3 | Touch ID flag names replaced by biometry names |
| iOS 12 | both `Always` constants deprecated; they still compile and run |
| iOS 15 | keychain items installed by MDM default to after-first-unlock and non-migratory instead of always; pre-warming can run the app before first unlock |
| iOS 16 | passkeys (FIDO2 / WebAuthn, synced end to end encrypted through iCloud Keychain); no access control API change |
| iOS 17 | passkeys for managed Apple Accounts; no change to accessibility or `SecAccessControl` |
| iOS 18 | the standalone Passwords app; no data protection API change |
| iOS 26 | Stolen Device Protection on by default (biometrics without passcode fallback for saved passwords away from familiar places; confirm against current release notes); passkey import and export using the FIDO Alliance format; no new accessibility constants |

## Testing

The Simulator enforces neither `kSecAttrAccessible` nor `NSFileProtection`.
Data protection behaviour can only be tested on a physical device with a
passcode set.

| Scenario | Expected |
| --- | --- |
| Reboot, then read an `AfterFirstUnlock` item before unlocking | -25308 or -25300; after one unlock and a relock, a background read succeeds |
| Lock, then immediately read a `WhenUnlocked` item | -25308 |
| Remove the passcode | `WhenPasscodeSet` item gone (-25300) |
| Enroll a new finger or face | `.biometryCurrentSet` item fails (-25293) |
| Restore a backup onto another device | `ThisDeviceOnly` items absent (-25300) |
| Notification extension or widget while locked | `AfterFirstUnlock` readable, `WhenUnlocked` not |

More on test structure: [testing-security-code.md](testing-security-code.md).

## Checklist

- Every add states its accessibility, chosen for foreground or background use.
- Never both attributes; with access control, the accessibility goes inside it.
- Extensions, widgets, VPN code and push handlers read
  `AfterFirstUnlockThisDeviceOnly` items.
- Top secrets use `WhenPasscodeSetThisDeviceOnly` with `.biometryCurrentSet`,
  and the app handles deletion when the passcode is removed.
- Multiple authentication flags are joined by `.or` or `.and`.
- macOS queries all set `kSecUseDataProtectionKeychain: true`.
- `ThisDeviceOnly` items have a re-authentication path after migration or
  restore.
- Launch paths check `isProtectedDataAvailable` (iOS 15 pre-warming).
- Saves are add, then update; only an access-control change deletes and
  re-adds.
- Biometric flags come with `NSFaceIDUsageDescription` in `Info.plist`.
- Lock, reboot, passcode removal and enrollment change are tested on hardware.
- CI rejects the deprecated `Always` constants, and existing items move to
  `AfterFirstUnlock` the next time the user authenticates in the foreground.

## Related Files

[keychain-fundamentals.md](keychain-fundamentals.md),
[biometric-authentication.md](biometric-authentication.md),
[secure-enclave.md](secure-enclave.md),
[keychain-item-classes.md](keychain-item-classes.md),
[common-anti-patterns.md](common-anti-patterns.md) (items on missing
accessibility and on `LAContext`-only gates), and
[compliance-owasp-mapping.md](compliance-owasp-mapping.md) (M9, insecure data
storage).
