Weak Biometric Access Control Flags
ID |
swift.weak_biometric_acl |
Severity |
high |
Resource |
Authentication |
Language |
Swift |
Tags |
CWE:287, CWE:305, MASVS:auth-8, MASWE:0014, NIST.SP.800-53, OWASP:2021:A07, PCI-DSS:8.2.1 |
Description
When protecting sensitive cryptographic keys in the iOS Keychain with biometric authentication, developers must carefully choose access control flags. Some flags allow authentication with any enrolled biometric on the device, including fingerprints or facial data added after the key was created. This creates a critical security vulnerability where an attacker with physical device access can enroll their own biometric data and gain unauthorized access to protected keys.
The SecAccessControlCreateWithFlags function allows developers to specify which biometric authentication methods are acceptable. Weak biometric access control flags do not invalidate Keychain items when new biometrics are enrolled, allowing potential attackers to bypass authentication.
Weak biometric ACL flags include:
-
.biometryAny- Allows authentication with any enrolled biometric, regardless of when it was enrolled. Keys remain valid even after new fingerprints or faces are added to the device. -
.touchIDAny- Similar to.biometryAnybut uses deprecated Touch ID naming. Has the same security weakness. -
.userPresence- Can fall back to device passcode authentication, which is vulnerable to shoulder surfing and social engineering.
Rationale
The following example demonstrates vulnerable code that uses weak biometric access control flags:
import Security
import Foundation
enum KeychainError: Error {
case accessControlCreationFailed
case keyGenerationFailed
}
func createKeyWithWeakBiometrics() throws {
// VULNERABLE: Using .biometryAny allows any enrolled biometric
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryAny, // WEAK! Allows newly enrolled biometrics
nil
) else {
throw KeychainError.accessControlCreationFailed
}
let privateKeyAttrs: [String: Any] = [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.payment".data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: privateKeyAttrs
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw KeychainError.keyGenerationFailed
}
}
func createKeyWithUserPresence() throws {
// VULNERABLE: .userPresence can fall back to passcode
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.userPresence, // WEAK! Falls back to passcode
nil
) else {
throw KeychainError.accessControlCreationFailed
}
let privateKeyAttrs: [String: Any] = [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.auth".data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: privateKeyAttrs
]
var error: Unmanaged<CFError>?
_ = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
}
func createKeyWithDeprecatedTouchIDAny() throws {
// VULNERABLE: .touchIDAny (deprecated) has same issue as .biometryAny
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.touchIDAny, // WEAK! Deprecated and allows any Touch ID
nil
) else {
throw KeychainError.accessControlCreationFailed
}
let privateKeyAttrs: [String: Any] = [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.legacy".data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: privateKeyAttrs
]
var error: Unmanaged<CFError>?
_ = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
}
This code has several critical security problems:
-
Vulnerable to biometric enrollment attacks: An attacker with temporary physical access to an unlocked device can enroll their own fingerprint or face. The protected keys will then be accessible using the attacker’s newly enrolled biometric data.
-
No invalidation on biometric changes: When
.biometryAnyis used, adding new biometrics does not invalidate existing Keychain items. This means keys created with legitimate user biometrics remain accessible even after an attacker enrolls additional biometrics. -
Compromised device scenarios: If a device is compromised (stolen, lost, or accessed without authorization), an attacker can add their biometric data and access all keys protected with
.biometryAny. -
Weak passcode fallback: The
.userPresenceflag allows falling back to device passcode authentication, which is significantly weaker than biometric authentication due to shoulder surfing risks. -
Persistence across re-enrollment: Keys remain valid even when legitimate users re-enroll their biometrics after device compromise, potentially leaving backdoor access for attackers.
Remediation
Use strong biometric access control flags that invalidate keys when biometrics change:
Option 1: .biometryCurrentSet (Recommended)
This is the most secure option that restricts authentication to biometrics enrolled at key creation time:
import Security
import Foundation
enum KeychainError: Error {
case accessControlCreationFailed
case keyGenerationFailed
case storageFailed
}
class SecureBiometricKeychain {
func createKeyWithStrongBiometrics() throws {
// SECURE: Using .biometryCurrentSet restricts to current biometrics only
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet, // Keys invalidate when new biometrics are added
nil
) else {
throw KeychainError.accessControlCreationFailed
}
let privateKeyAttrs: [String: Any] = [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.secure.payment".data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: privateKeyAttrs
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw KeychainError.keyGenerationFailed
}
print("✅ Key created with strong biometric protection")
print(" • Keys will invalidate if new biometrics are enrolled")
print(" • Maximum security for sensitive operations")
}
func createSigningKeyWithBiometryCurrentSet() throws {
// SECURE: Combining .biometryCurrentSet with .privateKeyUsage
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.biometryCurrentSet, .privateKeyUsage],
nil
) else {
throw KeychainError.accessControlCreationFailed
}
let privateKeyAttrs: [String: Any] = [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.signing".data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: privateKeyAttrs
]
var error: Unmanaged<CFError>?
_ = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
}
}
Option 2: .touchIDCurrentSet (For Touch ID specific apps)
Use this when you need Touch ID specific authentication:
func createKeyWithTouchIDCurrentSet() throws {
// SECURE: Using .touchIDCurrentSet for Touch ID devices
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.touchIDCurrentSet, // Equivalent to .biometryCurrentSet for Touch ID
nil
) else {
throw KeychainError.accessControlCreationFailed
}
let privateKeyAttrs: [String: Any] = [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.touchid".data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: privateKeyAttrs
]
var error: Unmanaged<CFError>?
_ = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
}
Option 3: Handling biometric availability
Always check for biometric availability and handle errors gracefully:
import LocalAuthentication
class SecureBiometricManager {
func createKeyWithBiometricCheck() throws {
let context = LAContext()
var error: NSError?
// Check if biometrics are available
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
if let err = error {
print("Biometrics not available: \(err.localizedDescription)")
// Handle appropriately - perhaps use different protection or inform user
}
throw KeychainError.accessControlCreationFailed
}
// Create with .biometryCurrentSet knowing biometrics are available
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
nil
) else {
throw KeychainError.accessControlCreationFailed
}
let privateKeyAttrs: [String: Any] = [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.example.checked".data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecPrivateKeyAttrs as String: privateKeyAttrs
]
var cfError: Unmanaged<CFError>?
_ = SecKeyCreateRandomKey(attributes as CFDictionary, &cfError)
}
}
Comparison of Biometric Access Control Flags:
| Access Control Flag | Behavior on New Biometric Enrollment | Security Level |
|---|---|---|
|
Keys remain valid ❌ |
⚠️ Weak (allows attacker biometrics) |
|
Keys remain valid ❌ |
⚠️ Weak (deprecated, same as .biometryAny) |
|
Keys remain valid, allows passcode ❌ |
⚠️ Weak (passcode fallback) |
|
Keys automatically invalidated ✅ |
✅ Strong (maximum security) |
|
Keys automatically invalidated ✅ |
✅ Strong (equivalent to .biometryCurrentSet) |
Best Practices:
-
Always use .biometryCurrentSet: For maximum security, always use
.biometryCurrentSetinstead of.biometryAnywhen protecting sensitive keys. -
Handle invalidation gracefully: When keys are invalidated due to biometric changes, provide a clear user experience explaining why re-authentication or key recreation is needed.
-
Check biometric availability: Before creating keys with biometric protection, verify that biometrics are available on the device using
LAContext. -
Avoid .userPresence for sensitive operations: The
.userPresenceflag should not be used for highly sensitive operations like payment authentication or signing, as it allows passcode fallback. -
Document security assumptions: Clearly document in your code why
.biometryCurrentSetis being used and what security guarantees it provides. -
Plan for biometric changes: Implement proper workflows for when users legitimately change their biometrics (e.g., after getting a new phone or re-enrolling fingerprints).
-
Consider user experience: While
.biometryCurrentSetprovides maximum security, it may require users to recreate keys when they add new fingerprints. Balance security with usability. -
Test edge cases: Test scenarios where users add/remove biometrics, use different fingers, or switch between Face ID and passcode.
Attack Scenarios
Understanding how attackers can exploit weak biometric ACL flags:
Scenario 1: Physical Device Access
1. Attacker gains temporary physical access to unlocked device
2. Attacker navigates to Settings → Face ID/Touch ID
3. Attacker enrolls their own biometric data
4. Later, attacker can unlock device and access keys protected with .biometryAny
Scenario 2: Compromised Device
1. User’s device is stolen while unlocked (e.g., at a café)
2. Attacker quickly enrolls their fingerprint before device locks
3. All keys protected with .biometryAny are now accessible to attacker
4. Even if user remotely locks device, attacker retains biometric access
Scenario 3: Social Engineering
1. Attacker convinces user to "help" with a biometric enrollment
2. User unknowingly allows attacker’s biometric to be enrolled
3. Keys protected with .biometryAny remain accessible to attacker indefinitely
4. Attacker can access sensitive data without further interaction
Configuration
This detector can be configured with the list of weak biometric ACL flags to detect:
weakBiometricFlags:
- biometryAny
- touchIDAny
- userPresence
You can customize this list if your security policy requires stricter or different controls.
References
-
CWE-287: Improper Authentication.
-
CWE-305: Authentication Bypass by Primary Weakness.
-
OWASP - Top 10 2021 Category A07 : Identification and Authentication Failures.
-
Apple Developer: Accessing Keychain Items with Face ID or Touch ID.
-
MASWE-0014: Cryptographic Keys Not Properly Protected at Rest.