9.4 — Authentication, Biometrics & Secure Enclave
Opening scenario
The PM asks: “Can we add Face ID to unlock the app?” Sounds trivial. But the answer to what Face ID actually authenticates, what gets unlocked, and what happens when Face ID fails shapes the entire auth architecture. Get it wrong and a screenshot-style attacker walks past your “secured” app; get it right and even a stolen unlocked phone can’t decrypt the vault.
Context — three different auth concerns
| Concern | What it answers | iOS API |
|---|---|---|
| Authentication to your server | “Is this user really alice@acme.com?” | OAuth/PKCE + token, Passkeys |
| Local re-confirmation | “Is this still alice holding her phone?” | LAContext (biometrics) |
| Cryptographic identity | “Sign this challenge with a key only this device holds” | Secure Enclave + ASAuthorization |
Each requires different APIs and threat models. Conflating them is the most common architectural mistake.
LocalAuthentication for re-confirmation
import LocalAuthentication
func reconfirm() async throws {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
throw error ?? AuthError.biometricsUnavailable
}
let ok = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Confirm to unlock your vault"
)
guard ok else { throw AuthError.userCancelled }
}
Two policies:
.deviceOwnerAuthenticationWithBiometrics— biometrics only; no passcode fallback..deviceOwnerAuthentication— biometrics first, falls back to device passcode after failures.
Use WithBiometrics for genuine biometric reconfirmation; use the broader policy when “verify the device owner is present, somehow” is acceptable.
Critically: LAContext.evaluatePolicy returning true does not give you anything cryptographic. It’s a UI-level confirmation. The attacker who jailbreaks the device can hook the function to always return true. Gate something cryptographic on the auth.
Cryptographic gating via the Keychain
To make biometrics actually decrypt something, store the key with SecAccessControl:
import Security
func storeBiometricGatedSecret(_ data: Data) throws {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "vault.key",
kSecValueData as String: data,
kSecAttrAccessControl as String: access,
]
SecItemDelete(q as CFDictionary)
let status = SecItemAdd(q as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
SecAccessControl flags:
| Flag | Effect |
|---|---|
.userPresence | Biometric or passcode |
.biometryAny | Any enrolled biometric (Face ID or Touch ID) |
.biometryCurrentSet | Only currently-enrolled fingerprints/faces; invalidated if user enrolls a new biometric |
.devicePasscode | Device passcode only |
.privateKeyUsage | Enables use with Secure Enclave keys |
Pair with .biometryCurrentSet for high-security flows — if anyone adds a new fingerprint, the secret becomes inaccessible until re-enrolled by the user. This blocks “let a friend add their finger and unlock my vault” attacks.
Secure Enclave for key-pair operations
For asymmetric crypto (signing API calls, deriving session keys), generate the key inside the SE so the private half never reaches main memory:
import CryptoKit
import Security
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: "com.acme.signing".data(using: .utf8)!,
kSecAttrAccessControl as String: access,
],
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { throw error!.takeRetainedValue() }
let publicKey = SecKeyCopyPublicKey(privateKey)!
To sign: SecKeyCreateSignature(privateKey, .ecdsaSignatureMessageX962SHA256, data, &error). Each invocation triggers the biometric prompt (since .biometryCurrentSet is set on the key’s access control). Even if an attacker has the device unlocked, signing requires a fresh face.
Passkeys (FIDO2 / WebAuthn)
Passkeys are the modern replacement for passwords. Apple’s implementation uses Secure Enclave-backed ECC keys synced via iCloud Keychain. The server stores only the public key; the device stores the private key; biometric unlock signs the authentication challenge.
import AuthenticationServices
final class PasskeySignIn: NSObject, ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding {
func signIn(relyingPartyID: String, challenge: Data) {
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: relyingPartyID)
let request = provider.createCredentialAssertionRequest(challenge: challenge)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
}
// delegate methods…
}
Server side, you implement WebAuthn relying-party logic. Use a vetted library (e.g., webauthn-rs, py_webauthn, or platform-specific equivalents); don’t roll your own challenge verification.
Adoption pattern: offer Passkeys alongside OAuth and email-link login. Don’t make it the only option until your support team is ready for “how do I sync my passkey to a non-Apple device” tickets.
OAuth 2.0 with PKCE via ASWebAuthenticationSession
For third-party identity providers (Google, GitHub, your own SSO), ASWebAuthenticationSession is the right primitive — it shares cookies with Safari for SSO and supports the secure return URL handshake:
import AuthenticationServices
func login() async throws -> URL {
let codeVerifier = generateRandomString(64)
let codeChallenge = sha256Base64URL(codeVerifier)
let url = URL(string: "https://idp.acme.com/oauth/authorize?client_id=…&code_challenge=\(codeChallenge)&code_challenge_method=S256&redirect_uri=acme://callback")!
return try await withCheckedThrowingContinuation { cont in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "acme") { url, error in
if let url { cont.resume(returning: url) }
else { cont.resume(throwing: error ?? AuthError.unknown) }
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false // true to disable SSO cookies
session.start()
}
}
Exchange the returned code for tokens server-side (PKCE ensures the original verifier matches). Store the refresh token in Keychain with biometric gating; rotate access tokens via short expiry.
JWT storage
Refresh tokens: Keychain, WhenUnlockedThisDeviceOnly, ideally biometric-gated.
Access tokens: short-lived (5-15 min), can live in memory only and re-fetch on app cold start. If you must persist, Keychain with the same accessibility.
Never put tokens in UserDefaults, plain files, or query strings. Send via Authorization: Bearer header, not URL.
In the wild
Apple’s own Wallet app uses Secure Enclave keys for every payment authorization, with .biometryCurrentSet so re-enrolling Face ID invalidates the payment credential and forces re-add. 1Password’s vault key derivation uses biometric-gated Keychain plus a server-side challenge. Bank of America’s app uses Passkeys for primary login since 2024, with OAuth/PKCE as fallback.
Common misconceptions
- “Face ID success = authenticated.” No —
LAContext.evaluatePolicyreturning true is UI-level. Real security comes from gating a cryptographic operation on the biometric. - “Secure Enclave can store any secret.” Only ECC P-256 keys. For symmetric secrets, store the key handle in SE and use it to derive symmetric keys.
- “Passkeys replace OAuth.” They replace the password step. You still need a server with user accounts; Passkeys just handle the authentication primitive.
- “
SFAuthenticationSession/SFSafariViewControlleris fine for OAuth.” Both are deprecated for auth flows. UseASWebAuthenticationSession— it’s the only API that handles the redirect-back-to-app handoff securely. - “Biometric-gated Keychain items survive Face ID re-enrollment.” Only if you use
.biometryAny. With.biometryCurrentSet, re-enrollment invalidates the item — usually what you want for high-value secrets.
Seasoned engineer’s take
Auth architecture is layered cake: server-side identity (OAuth/Passkeys), token storage (Keychain), re-confirmation gates (LAContext + access controls), and cryptographic ops (Secure Enclave). Each layer addresses a different threat. Cargo-culting one layer without the others — “we added Face ID!” — creates a security theater that auditors and attackers see through. Design the whole stack on day one even if you ship MVP with only the first layer.
TIP: For high-value mutations (transfers, key rotation, bulk delete), always require a fresh
evaluatePolicyon a biometric-gated key — don’t trust that “the user unlocked the app 20 minutes ago” still applies.
WARNING: Don’t ship biometric auth without a passcode fallback path documented in your support flow. Users get new phones, get cataract surgery, wear COVID masks indefinitely. The fallback is a feature, not a bug.
Interview corner
Junior: “How do you add Face ID to an app?”
LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics). But that’s just a UI confirmation — for real security, gate a Keychain item or Secure Enclave key on biometrics via SecAccessControl.
Mid: “What’s the difference between LAContext and a biometric-gated Keychain item?”
LAContext.evaluatePolicy is a UI prompt that returns a Bool; an attacker hooking the function can bypass it. A biometric-gated Keychain item only releases its data after a successful biometric, with the gate enforced by the OS, not your app’s code. Always pair the UX with a cryptographic gate.
Senior: “Design end-to-end auth for a new fintech iOS app.”
Server-side: OAuth 2.0 with PKCE via ASWebAuthenticationSession, with Passkeys as the preferred path for new signups. Tokens: refresh token in Keychain with WhenPasscodeSetThisDeviceOnly + .biometryCurrentSet, access tokens short-lived in memory. App-unlock: LAContext biometric prompt that, on success, unlocks a Secure Enclave key used to decrypt the local SQLCipher database. High-value mutations — wire transfers, payment-method changes — re-prompt for biometrics gating a Secure Enclave signing operation that produces a signed transaction the server verifies before executing. Fallback path: if biometrics fail or unavailable, force a fresh OAuth login with the IDP; never relax the cryptographic gate. Monitoring: log biometric failures (anonymized) so we can spot patterns of stolen-device attempts.
Red-flag answer: “We use Face ID for login.” Reveals conflation of UI confirmation with cryptographic auth, and missing server-side story.
Lab preview
Lab 9.1 (Secure Notes App) requires you to wire LAContext to unlock a Keychain-stored database key, with proper fallback when biometrics aren’t available.