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

ConcernWhat it answersiOS 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:

FlagEffect
.userPresenceBiometric or passcode
.biometryAnyAny enrolled biometric (Face ID or Touch ID)
.biometryCurrentSetOnly currently-enrolled fingerprints/faces; invalidated if user enrolls a new biometric
.devicePasscodeDevice passcode only
.privateKeyUsageEnables 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

  1. “Face ID success = authenticated.” No — LAContext.evaluatePolicy returning true is UI-level. Real security comes from gating a cryptographic operation on the biometric.
  2. “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.
  3. “Passkeys replace OAuth.” They replace the password step. You still need a server with user accounts; Passkeys just handle the authentication primitive.
  4. SFAuthenticationSession / SFSafariViewController is fine for OAuth.” Both are deprecated for auth flows. Use ASWebAuthenticationSession — it’s the only API that handles the redirect-back-to-app handoff securely.
  5. “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 evaluatePolicy on 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.


Next: 9.5 — Jailbreak & Tampering Detection