# Secure Enclave

The Secure Enclave is a security coprocessor kept apart from the main
processor. It creates keys, stores them and uses them without the private
material ever reaching the Application Processor. It has shipped since the
iPhone 5s in 2013.

This file covers the CryptoKit `SecureEnclave` namespace (iOS 13+), the older
Security framework route, the post-quantum additions in iOS 26, how to persist
keys, how to test, and what the hardware cannot do.

Sources: the enclave chapter of the Apple Platform Security Guide; CryptoKit's
`SecureEnclave` reference pages; WWDC 2019 session 709 (on cryptography in
apps); the WWDC 2025 session on quantum-secure cryptography; and two Apple
developer articles, one on protecting keys with the enclave and one on keeping
CryptoKit keys in the keychain.

## Contents

- [What It Is](#what-it-is)
- [What It Cannot Do](#what-it-cannot-do)
- [CryptoKit SecureEnclave (iOS 13+)](#cryptokit-secureenclave-ios-13)
- [Keeping a Key Across Launches](#keeping-a-key-across-launches)
- [Biometric-Gated Enclave Keys](#biometric-gated-enclave-keys)
- [Security Framework Route (iOS 10+)](#security-framework-route-ios-10)
- [iOS 26: Post-Quantum Types in the Enclave](#ios-26-post-quantum-types-in-the-enclave)
- [Enclave or Software Key?](#enclave-or-software-key)
- [Six Traps](#six-traps)
- [Testing and CI](#testing-and-ci)
- [Operating Enclave Keys](#operating-enclave-keys)
- [Summary](#summary)

## What It Is

- It boots through its own secure boot chain and runs isolated. Apps reach it
  only through CryptoKit or the Security framework.
- Using a key means posting a request to the enclave's mailbox. Only the
  output, such as a signature or an agreed secret, travels back; the key stays. No API, debugger or
  JTAG path exports it.
- Each chip has a UID fused in at manufacture that no software can read, not
  even Apple's. Every enclave key descends from it, which is why enclave keys
  are tied to one device.

Hardware with an enclave: iPhone 5s and later (A7+), iPad Air and later, Apple
Watch Series 1 and later, Apple TV HD (4th generation) and later, HomePod,
Macs with a T1, T2 or Apple silicon chip, and Apple Vision Pro.

Hardware without one: Intel Macs with neither T1 nor T2, such as MacBook Pro
models before 2016, MacBook Air before 2018, and iMac before 2020 (the iMac Pro
has a T2).

## What It Cannot Do

- **One classical curve.** Only P-256. Curve25519, secp256k1, P-521 and P-384
  are all absent, and CryptoKit has neither a `SecureEnclave.Curve25519` nor a
  `SecureEnclave.P384` type.
  iOS 26 adds lattice-based algorithms, not more curves.
- **No symmetric crypto for apps.** The enclave's AES engine serves Data
  Protection and FileVault and is not exposed. There is no
  `SecureEnclave.AES`.
- **No export.** `SecKeyCopyExternalRepresentation()` fails on an enclave
  private key. `dataRepresentation` is an encrypted, opaque blob, not key
  material.
- **No import.** `init(dataRepresentation:)` accepts only a blob produced by an
  earlier enclave key. Enclave types have no `init(rawRepresentation:)`.
- **Bound to the device.** A factory reset destroys the keys. They are not in
  iCloud backups, do not sync through iCloud Keychain, and cannot move to a new
  device.
- **Limited room.** Reserve it for keys that matter. For bulk data, keep one
  enclave root key from which software symmetric keys are derived or unwrapped.
- **Slow per operation.** Every call is an interrupt-driven round trip, so
  thousands of signatures per second are out of reach. High-volume work uses
  symmetric keys derived from an enclave key.

## CryptoKit SecureEnclave (iOS 13+)

For new code this is the API to use. It has two families,
`SecureEnclave.P256.Signing` and `SecureEnclave.P256.KeyAgreement`.

### Creating a key, and the Simulator

The Simulator has no Secure Enclave. Decide Simulator behaviour at compile time
with `#if targetEnvironment(simulator)`, and treat `SecureEnclave.isAvailable`
as the runtime check on real hardware. Do not base Simulator behaviour on the
value `isAvailable` happens to report there; key creation in the Simulator is
not a supported path, and the compile-time branch keeps tests and previews
predictable.

```swift
import CryptoKit

enum EnclaveKeyError: Error {
    case simulator
    case noEnclave
}

func newDeviceSigningKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
    #if targetEnvironment(simulator)
    throw EnclaveKeyError.simulator
    #else
    guard SecureEnclave.isAvailable else { throw EnclaveKeyError.noEnclave }
    return try SecureEnclave.P256.Signing.PrivateKey()
    #endif
}
```

```swift
// Wrong: no guard. On the Simulator or on a Mac without an enclave this fails
// at runtime instead of taking a planned fallback.
let key = try SecureEnclave.P256.Signing.PrivateKey()
```

### Signing

```swift
func signReceipt(_ receipt: Data, with key: SecureEnclave.P256.Signing.PrivateKey) throws -> (wire: Data, compact: Data) {
    let signature = try key.signature(for: receipt)
    precondition(key.publicKey.isValidSignature(signature, for: receipt))
    return (signature.derRepresentation, signature.rawRepresentation)
}

func registrationPayload(for key: SecureEnclave.P256.Signing.PrivateKey) -> Data {
    key.publicKey.derRepresentation
}
```

The enclave computes the ECDSA signature and hands back a
`P256.Signing.ECDSASignature`. Send `derRepresentation` over the wire, or
`rawRepresentation` when a compact 64-byte form is expected. The public key is
an ordinary `P256.Signing.PublicKey`, free to export; register its
DER form with the server.

### Key agreement, then symmetric encryption

```swift
func sealForPeer(_ message: Data,
                 mine: SecureEnclave.P256.KeyAgreement.PrivateKey,
                 peer: P256.KeyAgreement.PublicKey,
                 context: Data) throws -> AES.GCM.SealedBox {
    let secret = try mine.sharedSecretFromKeyAgreement(with: peer)
    let key = secret.hkdfDerivedSymmetricKey(using: SHA256.self,
                                             salt: Data("example.chat.v1".utf8),
                                             sharedInfo: context,
                                             outputByteCount: 32)
    return try AES.GCM.seal(message, using: key)
}
```

A key created with `SecureEnclave.P256.KeyAgreement.PrivateKey()` agrees on a
secret with a `P256.KeyAgreement.PublicKey`, HKDF turns that secret into a
symmetric key, and `AES.GCM.seal` does the encryption. This chain is the only
correct way to get symmetric encryption out of an enclave key.
`SecureEnclave.AES.GCM` does not exist, and handing a signing key to
`AES.GCM.seal` does not type-check. More on HKDF and AES-GCM in
[cryptokit-symmetric.md](cryptokit-symmetric.md) and
[cryptokit-public-key.md](cryptokit-public-key.md).

## Keeping a Key Across Launches

An enclave key object disappears when the app quits unless you save its
`dataRepresentation`. That blob is not the private key; only the same enclave
on the same device can turn it back into a usable key.

Store the blob as a generic password with `WhenUnlockedThisDeviceOnly`, saving
with add, then update on duplicate:

```swift
func persist(_ key: SecureEnclave.P256.Signing.PrivateKey, account: String) throws {
    let match: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.example.device-key",
        kSecAttrAccount as String: account,
        kSecUseDataProtectionKeychain as String: true
    ]
    var insert = match
    insert[kSecValueData as String] = key.dataRepresentation
    insert[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    let added = SecItemAdd(insert as CFDictionary, nil)
    if added == errSecSuccess { return }
    guard added == errSecDuplicateItem else { throw KeychainError(status: added) }
    let changed = SecItemUpdate(match as CFDictionary,
                                [kSecValueData as String: key.dataRepresentation] as CFDictionary)
    guard changed == errSecSuccess else { throw KeychainError(status: changed) }
}

func restore(account: String) throws -> SecureEnclave.P256.Signing.PrivateKey? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: "com.example.device-key",
        kSecAttrAccount as String: account,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne,
        kSecUseDataProtectionKeychain as String: true
    ]
    var out: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &out)
    if status == errSecItemNotFound { return nil }
    guard status == errSecSuccess, let blob = out as? Data else { throw KeychainError(status: status) }
    return try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: blob)
}
```

- `kSecUseDataProtectionKeychain` is what makes this work on macOS; other
  platforms ignore it.
- Always use `WhenUnlockedThisDeviceOnly` for these blobs. A class that syncs or
  migrates would copy a blob to devices whose enclave cannot open it.
- `KeychainError` is the wrapper from
  [keychain-fundamentals.md](keychain-fundamentals.md).

## Biometric-Gated Enclave Keys

The enclave evaluates the access control itself, so the policy holds even if
the OS is compromised. The flags always include `.privateKeyUsage`; without it
the key is created but every signing attempt fails.

Face ID prompts need `NSFaceIDUsageDescription` in `Info.plist`.

```swift
import LocalAuthentication

func newApprovalKey() throws -> (key: SecureEnclave.P256.Signing.PrivateKey, context: LAContext) {
    guard SecureEnclave.isAvailable else { throw EnclaveKeyError.noEnclave }
    var cfError: Unmanaged<CFError>?
    guard let acl = SecAccessControlCreateWithFlags(
        nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        [.privateKeyUsage, .biometryCurrentSet], &cfError) else {
        throw (cfError?.takeRetainedValue() as Error?) ?? EnclaveKeyError.noEnclave
    }
    let ctx = LAContext()
    ctx.localizedReason = "Approve the transfer"
    ctx.touchIDAuthenticationAllowableReuseDuration = 10
    let key = try SecureEnclave.P256.Signing.PrivateKey(compactRepresentable: true,
                                                        accessControl: acl,
                                                        authenticationContext: ctx)
    return (key, ctx)
}

func approve(_ payload: Data, blob: Data) throws -> Data {
    let ctx = LAContext()
    ctx.localizedReason = "Approve the transfer"
    let key = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: blob,
                                                        authenticationContext: ctx)
    return try key.signature(for: payload).derRepresentation
}
```

Calling `signature(for:)` on the restored key shows the biometric prompt.

```swift
// Wrong: .privateKeyUsage missing. Creation succeeds, signing fails.
let brokenACL = SecAccessControlCreateWithFlags(
    nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .biometryCurrentSet, nil)
```

Choosing the flag:

- `.biometryCurrentSet`: strongest; the key becomes unusable when enrollment
  changes, so plan for re-keying. Banking and healthcare.
- `.biometryAny`: keeps working after re-enrollment.
- `.userPresence`: biometrics or the passcode; proof that a person is there.

The invalidation caused by `.biometryCurrentSet` is permanent. Watch for
`errSecItemNotFound` or authentication errors, tell the user what happened,
create a new key, and register it with the server again.
[biometric-authentication.md](biometric-authentication.md) covers the
LocalAuthentication side.

## Security Framework Route (iOS 10+)

```swift
func legacyEnclaveKey(tag: Data) throws -> SecKey {
    var cfError: Unmanaged<CFError>?
    guard let acl = SecAccessControlCreateWithFlags(
        nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        [.privateKeyUsage, .biometryCurrentSet], &cfError) else {
        throw (cfError?.takeRetainedValue() as Error?) ?? EnclaveKeyError.noEnclave
    }
    let spec: [String: Any] = [
        kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
        kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
        kSecAttrKeySizeInBits as String: 256,
        kSecPrivateKeyAttrs as String: [
            kSecAttrIsPermanent as String: true,
            kSecAttrApplicationTag as String: tag,
            kSecAttrAccessControl as String: acl
        ]
    ]
    guard let key = SecKeyCreateRandomKey(spec as CFDictionary, &cfError) else {
        throw (cfError?.takeRetainedValue() as Error?) ?? EnclaveKeyError.noEnclave
    }
    return key
}
```

Reach for this only when the target predates iOS 13 or the work involves
certificates and identities. Otherwise CryptoKit is better: one type per
algorithm, memory zeroed automatically, Swift errors, and a small, curated
surface. The Security framework is still required for `SecTrust`, RSA, and
items that already exist in the keychain.

## iOS 26: Post-Quantum Types in the Enclave

iOS 26, macOS 26 and the other 2025 platform releases add hardware-backed
lattice algorithms to the enclave:

- `SecureEnclave.MLKEM768` and `SecureEnclave.MLKEM1024`: key encapsulation per
  FIPS 203;
- `SecureEnclave.MLDSA65` and `SecureEnclave.MLDSA87`: signatures per FIPS 204.

Apple states that these implementations were formally verified against the FIPS
specifications.

```swift
@available(iOS 26.0, macOS 26.0, *)
func pqRoundTrip() throws -> Bool {
    let recipient = try SecureEnclave.MLKEM768.PrivateKey()
    let sent = try recipient.publicKey.encapsulate()
    let received = try recipient.decapsulate(sent.encapsulated)
    return received == sent.sharedSecret
}
```

Transport needs no work: in iOS 26, `URLSession` and `Network.framework`
negotiate quantum-secure TLS 1.3 on their own, offering the hybrid
`X25519MLKEM768` key-exchange group, and CloudKit, push notifications and
iCloud Private Relay already use it. For an app's own end-to-end encryption,
prefer a hybrid of post-quantum and classical algorithms, such as the CryptoKit
X-Wing KEM (`XWingMLKEM768X25519`) in HPKE; an
`SecureEnclave.MLKEM768.PrivateKey` performs encapsulation and decapsulation
inside the hardware. The full CryptoKit catalog is in
[cryptokit-public-key.md](cryptokit-public-key.md).

| Release | Enclave-related change |
| --- | --- |
| iOS 13 | CryptoKit ships with enclave P-256 signing and key agreement and `SecureEnclave.isAvailable` |
| iOS 14 | nothing for the enclave (HKDF as a type, PEM and DER key formats arrive) |
| iOS 15, 16 | nothing |
| iOS 17 | nothing (HPKE is software only; iMessage PQ3 arrives in 17.4) |
| iOS 18 | nothing |
| iOS 26 | enclave ML-KEM and ML-DSA; post-quantum TLS by default |

The only classical curve remains P-256.

## Enclave or Software Key?

The enclave suits root signing keys, your own scheme of device attestation,
transaction approval, biometric-gated authentication, and proving that a
request came from one particular device.

Software keys kept in the keychain suit API keys, session tokens, symmetric
keys, algorithms the enclave lacks (Ed25519, RSA, P-384), keys required to sync or
survive a device change, and high-throughput operations.

The pattern that works: one asymmetric master key in the enclave, with ECDH
deriving or wrapping the symmetric keys that do the bulk encryption. Putting
every secret in the enclave is an anti-pattern.

## Six Traps

1. **Availability.** Combine the compile-time Simulator branch with
   `SecureEnclave.isAvailable`.
2. **Import.** Enclave types have no `init(rawRepresentation:)`, and
   `init(dataRepresentation:)` given raw software key bytes throws. Keys are
   generated inside the enclave.
3. **AES.** Not available. Go through ECDH, HKDF, then AES-GCM.
4. **Backup.** None. From the first release, the server records each device's
   public key and supports re-keying when the device changes.
5. **The legacy route.** `SecKeyCreateRandomKey` takes about twenty lines of
   dictionary setup; keep it for pre-iOS 13 targets and certificate work.
6. **`.privateKeyUsage`.** Any access control on an enclave key includes it,
   for example `[.privateKeyUsage, .biometryCurrentSet]`; leave it out and
   signing fails even though creation succeeded.

## Testing and CI

Hide the key behind a protocol so tests do not need hardware:

```swift
protocol DeviceSigner {
    var publicKeyDER: Data { get throws }
    func sign(_ payload: Data) throws -> Data
}

struct EnclaveSigner: DeviceSigner {
    let key: SecureEnclave.P256.Signing.PrivateKey
    init(blob: Data? = nil) throws {
        key = try blob.map { try .init(dataRepresentation: $0) } ?? .init()
    }
    var publicKeyDER: Data { key.publicKey.derRepresentation }
    func sign(_ payload: Data) throws -> Data { try key.signature(for: payload).derRepresentation }
}

struct SoftwareSigner: DeviceSigner {
    let key = P256.Signing.PrivateKey()
    var publicKeyDER: Data { key.publicKey.derRepresentation }
    func sign(_ payload: Data) throws -> Data { try key.signature(for: payload).derRepresentation }
}

final class StubSigner: DeviceSigner {
    var cannedSignature = Data([0x30, 0x01])
    var shouldFail = false
    private(set) var signCount = 0
    var publicKeyDER: Data { Data([0x04]) }
    func sign(_ payload: Data) throws -> Data {
        signCount += 1
        if shouldFail { throw EnclaveKeyError.noEnclave }
        return cannedSignature
    }
}

enum SignerFactory {
    static func make() throws -> any DeviceSigner {
        #if targetEnvironment(simulator)
        return SoftwareSigner()
        #else
        return SecureEnclave.isAvailable ? try EnclaveSigner() : SoftwareSigner()
        #endif
    }
}
```

Enclave and software P-256 keys produce signatures in the same format, so the
server's verification code does not care which one signed.

```swift
import XCTest

final class DeviceSignerTests: XCTestCase {
    func testStubCountsCalls() throws {
        let stub = StubSigner()
        let out = try stub.sign(Data("x".utf8))
        XCTAssertEqual(out, stub.cannedSignature)
        XCTAssertEqual(stub.signCount, 1)
    }

    func testEnclaveSignatureVerifies() throws {
        #if targetEnvironment(simulator)
        throw XCTSkip("No Secure Enclave in the Simulator")
        #else
        try XCTSkipUnless(SecureEnclave.isAvailable, "Device has no Secure Enclave")
        let signer = try EnclaveSigner()
        let message = Data("ping".utf8)
        let der = try signer.sign(message)
        let pub = try P256.Signing.PublicKey(derRepresentation: signer.publicKeyDER)
        XCTAssertTrue(pub.isValidSignature(try .init(derRepresentation: der), for: message))
        #endif
    }
}
```

CI facts:

- GitHub Actions macOS runners, Apple silicon and Intel alike, are virtual
  machines, and Apple's Virtualization framework does not pass the enclave
  through.
- Self-hosted physical Macs (an Apple silicon Mac mini, a T2 MacBook Pro) have
  one. Whether Xcode Cloud jobs can use it depends on the configuration.
- Run mocks in CI, run enclave integration tests on a device farm or
  self-hosted hardware, and mark those tests with `XCTSkip` guards.

More in [testing-security-code.md](testing-security-code.md).

## Operating Enclave Keys

- Treat enclave keys as device-bound and replaceable, not as a permanent
  identity.
- **New device:** notice the missing blob or the failed restore, create a key,
  register the new public key, and revoke the old one on the server.
- **Re-enrollment under `.biometryCurrentSet`:** catch the error, explain it,
  and provision a fresh key.
- **Rotation:** create the new key, register it, sign a hand-over token with
  the old key while it still works, then delete the old blob.
- **Incidents:** a compromised OS still cannot read enclave keys, but someone
  holding the device and knowing the passcode can authenticate. Wiping the device
  remotely, through MDM or Find My, erases the key hierarchy rooted in the UID
  and every key with it.

## Summary

From iOS 13 until iOS 26 the whole enclave surface was
`SecureEnclave.P256`. A sound architecture puts the key behind a protocol, uses
the enclave where present with a software P-256 fallback, stores the
`dataRepresentation` as a `WhenUnlockedThisDeviceOnly` generic password, adds a
biometric ACL for high-value keys, registers public keys with the server and
supports re-keying, and guards device-only tests with `XCTSkip`.

Checklist:

- Compile-time and runtime availability checks.
- No attempt to import a key.
- No symmetric encryption inside the enclave; ECDH, then HKDF, then `AES.GCM`.
- Device-bound design with server-side re-enrollment.
- The `dataRepresentation` stored as a `WhenUnlockedThisDeviceOnly` generic
  password, saved with add, then update.
- `.privateKeyUsage` alongside any biometric flags, and
  `NSFaceIDUsageDescription` in `Info.plist`.
- `.biometryCurrentSet` invalidation handled by re-keying and telling the
  server.
- A protocol with enclave, software and mock implementations.
- CryptoKit rather than the Security framework for new code.
- iOS 26 post-quantum enclave types are hardware-backed; custom end-to-end
  schemes use a hybrid; `URLSession` TLS upgrades by itself.
- Enclave tests skip in CI, since GitHub Actions VMs have no enclave.

Related: [certificate-trust.md](certificate-trust.md),
[credential-storage-patterns.md](credential-storage-patterns.md).
