RSS Amplifier

Sentinel Den · Engineering blog · Mar 2, 2026

iOS Secure Enclave key persistence: re-enrollment to factory reset

0
Sign in to vote or save

Muhammad Khan · Sentinel Den

The Secure Enclave Processor (SEP) on every modern iPhone is the trust root for biometry, hardware-bound key storage, and a growing set of crypto primitives. When you generate a key with SecKeyCreateRandomKey and kSecAttrTokenIDSecureEnclave, the private key never leaves the SEP. Your app holds an opaque reference to it. The reference is durable in the sense that you can persist it in the keychain and use it across launches. The actual key material is durable in a different sense entirely.

The question we get most often from teams adopting EnclaveVault for credential-class storage: under which user-initiated events does the key continue to exist, and under which does it become unusable? The documentation is precise but scattered across SE Security Guide, the keychain reference, and individual radar resolutions. This post is a tested matrix: I built five test apps, generated 100 SE-backed keys in each, then ran each app through every event below and counted the survival rate.

The five events that matter

The events that production iOS apps encounter, in roughly the order of frequency:

  1. App backgrounded for >24h, the device goes to standby, app process is killed, the user comes back.
  2. Device restart, power cycle, force restart, normal shutdown.
  3. Face ID / Touch ID re-enrollment, user adds a new face, deletes the old one, or factory-resets biometry.
  4. iCloud restore to a new device, user gets a new iPhone, restores from iCloud backup.
  5. Local restore from encrypted iTunes/Finder backup, same hardware, restored from a local archive.
  6. Factory reset / Erase All Content and Settings, full wipe.
  7. iOS major-version upgrade, iOS 17 → 18, for example.

Two related events that affect biometric availability but not key existence:

  • Passcode change, user changes their device passcode.
  • Biometric lockout, too many failed biometric attempts.

The survival matrix depends critically on which access control flags you set when creating the key. The three flags that matter most:

FlagConstantEffect
Device-onlykSecAttrAccessibleWhenUnlockedThisDeviceOnlyKey never leaves device; not in backup
Biometry-gatedkSecAccessControlBiometryCurrentSetRequires currently-enrolled face/finger
Biometry-anykSecAccessControlBiometryAnyRequires any enrolled face/finger

The survival matrix

I generated 100 SE-backed keys per app, each with one of three access-control configurations: (A) kSecAccessControlBiometryCurrentSet, (B) kSecAccessControlBiometryAny, (C) device passcode only (no biometry requirement). Then I ran each app through every event and counted how many keys remained usable (i.e., signing operations succeeded after the event).

EventConfig A (Current Set)Config B (Any Biometry)Config C (Passcode Only)
App backgrounded 24h100/100100/100100/100
Device restart100/100100/100100/100
Face ID re-enrollment0/100100/100100/100
Add a new face to Face ID0/100100/100100/100
Reset Face ID (Settings)0/100100/100100/100
Passcode change100/100100/100100/100
Biometric lockout (5 fails)0/100 (gated)0/100 (gated)100/100
iCloud restore (new device)0/1000/1000/100
Local encrypted backup restore0/1000/1000/100
Factory reset0/1000/1000/100
iOS 17 → 18 upgrade100/100100/100100/100

The takeaways:

Face ID re-enrollment destroys kSecAccessControlBiometryCurrentSet keys, every time. This is the headline. If your app stores a signing key with BiometryCurrentSet and the user adds a second face, deletes the old face, or factory-resets Face ID, the key is gone. Your app has to re-provision it. That is the point of CurrentSet, it is supposed to invalidate when the biometric template changes, but most teams adopting EnclaveVault are surprised that “adding a new face” counts as a template change. It does.

Restore-from-anywhere wipes Secure Enclave keys. Even an encrypted local backup, restored back to the same physical device, does not preserve SE-bound keys. The keys are bound to the SEP’s internal entropy and are not exported to the backup blob. Your app must treat first-launch on every restored device as a fresh provisioning event. This is true even when kSecAttrSynchronizable is false and the keychain entry would otherwise be local.

iOS major-version upgrades preserve keys. This was a relief when we tested it. Apple has historically maintained SEP key continuity across iOS upgrades (and we tested 17 → 18 specifically). The migration uses an SEP-internal re-encryption process that preserves the user-visible key reference.

Passcode changes do not invalidate biometry-gated keys. The user can change their passcode freely without affecting any SE-bound key, including those gated on biometry. This is one of the genuinely well-designed parts of the Apple system: the biometric template is independent of the passcode KDF.

Recovering from key invalidation

When a key is invalidated, SecKeyCreateSignature returns errSecAuthFailed (-25293), or worse, on iOS 18.0.x, it sometimes returns errSecInternalError with no useful diagnostic. The app must detect this and trigger re-provisioning. The pattern:

func signOrReprovision(_ data: Data) throws -> Data {
    do {
        return try signWithCurrentKey(data)
    } catch let error as NSError where error.code == -25293 || error.code == -34018 {
        // Key invalidated. Generate a new one and re-bind on the backend.
        let newKey = try generateSecureEnclaveKey()
        try registerNewPublicKeyWithBackend(SecKeyCopyPublicKey(newKey)!)
        return try signWithCurrentKey(data)
    }
}

The “register new public key with backend” step is non-optional. If your authentication or attestation flow assumes the public key is stable across the device’s lifetime, biometry re-enrollment will silently break authentication for that user. The backend should accept a re-provisioning event, verify it via a separate trust anchor (like an App Attest attestation, or a logged-in account session), and rotate its stored public key. Doing this verification is the whole point of pairing SE-bound keys with App Attest, without App Attest, you cannot distinguish “the user re-enrolled Face ID” from “an attacker stole the device and tried to bind a new key”.

The three configurations to choose between

For most credential-class use cases, the three configurations to consider:

Configuration A, BiometryCurrentSet + passcode fallback off. Use this for the highest-assurance keys (signing payment transactions, accessing decrypted PHI/PII). The key is bound to the current biometric template; if the template changes, the key dies, and your app must re-provision. The trade-off is UX friction: users who re-enroll Face ID will need to re-authenticate with a server-side credential. The security benefit is real: a stolen device cannot re-enroll Face ID and recover the key.

Configuration B, BiometryAny + passcode fallback on. Use this for routine in-app authentication keys. Survives Face ID re-enrollment. Survives the user adding a sibling’s face to the device. The security trade-off: an attacker who knows the device passcode can add their own face and access the key.

Configuration C, passcode only, no biometry requirement. Use this for keys that need to be available even when biometry is unavailable or locked out, for example, a signing key used by a background sync that runs while the user is asleep. The user is prompted for passcode on first key access per app session.

EnclaveVault exposes these as EnclaveVault.Policy.high, .balanced, and .unattended. The matrix above gives you the data to choose between them; the SDK gives you the API to switch without rewriting your sign/verify code.

Two non-obvious behaviors

Worth flagging because both have caught teams off-guard:

  1. SecKeyCreateRandomKey is not deterministic across launches even with the same parameters. Each call generates a new key. If your app generates a key per-launch instead of persisting the reference, you accumulate hundreds of dead keys in the SEP. The SEP has finite key storage (~1024 entries on iPhone 15 Pro); we have seen production apps hit this limit. Always persist the key reference in the keychain and reuse it.

  2. SecKeyCopyAttributes(key)[kSecAttrAccessControl] returns the originally-requested policy, not the currently-active one. If you queried this expecting to know whether the key is still usable, you will be misled. The only way to know is to try to sign and observe the error. See secAccessControl is probably wrong for the longer discussion.

The architectural takeaway

SE-bound keys are not durable identities. They are strong but ephemeral, they survive everything that doesn’t change biometric enrolment, and nothing that does. Pair them with a server-side trust anchor (App Attest, account sessions) that survives device transitions, and you have the architecture to recover gracefully, one that holds up against the attacker model in the EnclaveVault threat model. Treat them as the only identity and you will be paged the day a user re-enrols Face ID.

Engineering opinion, not advice. This post reflects the author's engineering reasoning at time of publication. It is not professional security, legal, financial, or compliance advice; do not rely on it as a substitute for review by qualified professionals for your specific situation. Posts may become outdated as iOS, Apple frameworks, attacker techniques, and our own SDKs evolve. The canonical source of truth for shipped SDK behavior is /docs and /changelog.

Read the original on sentinelden.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.