iOS Keychain and Secure Enclave — source audit
TL;DR: iOS Keychain stores credentials in encrypted form, with policy controlled by the
kSecAttrAccessibleconstant and (optionally)kSecAccessControlflags. Source-audit risks: too-permissive accessible classes (background-readable secrets), no biometric ACL, sharing keychain across an app group with unverified apps, andSecKeyCreateRandomKeykeys that aren’t Secure-Enclave-bound. Companion to ios-source-review-methodology and android-keystore-and-crypto-audit.
The map
Every keychain item carries:
kSecAttrService— your namespace key.kSecAttrAccount— usually the user identifier.kSecValueData— the encrypted blob.kSecAttrAccessible— when can it be read.kSecAccessControl— biometric / device-passcode gates.kSecAttrSynchronizable— iCloud sync flag.kSecAttrAccessGroup— for sharing.
1
2
grep -rn 'SecItemAdd\|SecItemCopyMatching\|SecItemUpdate\|SecItemDelete' .
grep -rn 'kSecAttrAccessible\|kSecAccessControl\|kSecAttrSynchronizable\|kSecAttrAccessGroup' .
The kSecAttrAccessible ladder (most to least secure)
| Constant | When readable |
|---|---|
WhenPasscodeSetThisDeviceOnly | Device unlocked; device has a passcode; not on backups |
WhenUnlockedThisDeviceOnly | Device unlocked; not on backups |
WhenUnlocked | Device unlocked; restorable to a new device |
AfterFirstUnlockThisDeviceOnly | After first unlock after boot (background tasks need this); not on backups |
AfterFirstUnlock | Same; restorable |
Always (deprecated) | Always — even before first unlock |
AlwaysThisDeviceOnly (deprecated) | Same; not restorable |
The bug pattern:
1
2
3
4
5
6
7
8
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "vault",
kSecAttrAccount as String: user,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAlways // BAD
]
SecItemAdd(query as CFDictionary, nil)
kSecAttrAccessibleAlways survives reboot before passcode entry → anything reading at that window gets the secret. Use WhenUnlocked or stricter.
kSecAttrAccessControl for biometrics
1
2
3
4
5
6
7
8
9
let acl = SecAccessControlCreateWithFlags(nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.biometryCurrentSet], // requires biometry; rotates if user enrols new fingerprint
nil)!
let query: [String: Any] = [
...,
kSecAttrAccessControl as String: acl
]
Flags worth knowing:
.biometryAny— Face ID or Touch ID, including future enrollments..biometryCurrentSet— invalidates if user adds/removes a finger or face..devicePasscode— passcode fallback..userPresence— biometric or passcode.
For sensitive vaults prefer .biometryCurrentSet so a coerced enrolment doesn’t quietly unlock the vault.
Secure Enclave keys
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let access = SecAccessControlCreateWithFlags(nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.privateKeyUsage, nil)!
let attrs: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, // SE-bound
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.signing",
kSecAttrAccessControl as String: access
]
]
var error: Unmanaged<CFError>?
let key = SecKeyCreateRandomKey(attrs as CFDictionary, &error)!
Audit:
kSecAttrTokenID = kSecAttrTokenIDSecureEnclave— without this the private key lives in software.kSecAttrAccessControlincludes.privateKeyUsage— required for SE keys.- Operations use
SecKeyCreateSignature/SecKeyCreateDecryptedData(SE-supported); falling back toSecKeyCreateEncryptedDatafor RSA/AES outside SE leaks the path.
Sharing keychain across an app group
1
kSecAttrAccessGroup as String: "ABCDE12345.com.example.shared"
Trust model: all apps signed by your team prefix can read. Bug class: an old, deprecated companion app from the same team still in the app group, with weaker code, leaks the keychain item to attackers.
Audit:
keychain-access-groupsentitlement lists only currently-supported bundle IDs.- No
.sharedaccess group on items that don’t need cross-app reach.
kSecAttrSynchronizable
Setting this to true syncs the item via iCloud Keychain. Two concerns:
- Item is now accessible on the user’s other devices — sometimes desired, sometimes not.
- Apple’s iCloud Keychain has its own threat model; for high-assurance items prefer device-only storage.
Common Keychain mistakes (greppable)
1
2
3
4
5
6
7
8
# Storing tokens in UserDefaults (the bug)
grep -rn 'UserDefaults\.standard\.set.*token\|UserDefaults\.standard\.set.*password' .
# Storing tokens in plain files
grep -rn 'documentDirectory.*token\|cachesDirectory.*password' .
# Always-on accessible
grep -rn 'kSecAttrAccessibleAlways\|kSecAttrAccessibleAlwaysThisDeviceOnly' .
Wiping keychain on logout
A subtle bug: app deletes UserDefaults state on logout but leaves Keychain items intact. On reinstall, Keychain survives (across app removals on some iOS versions) and the next user inherits secrets.
Audit:
1
grep -rn 'logout\|signOut\|clearCredentials' .
Verify they SecItemDelete the relevant queries.
Source-audit checklist
- No secret in UserDefaults or plain file storage.
kSecAttrAccessibleisWhenUnlockedThisDeviceOnlyor stricter for user secrets.- Biometric items use
.biometryCurrentSet. - Long-lived keys are Secure-Enclave-bound (
kSecAttrTokenIDSecureEnclave). - Access groups limited to currently-supported bundle IDs.
- Sync flag off unless cross-device is required.
- Logout flow wipes the relevant keychain entries.