iOS 27 extends App Attest with launch validation category and bundle version extensions in the authenticator data, plus macOS 27 support and a new ACL Blob OID in the leaf certificate β giving servers richer signals to detect tampered or misused app copies.
β’ New authenticator data extensions: launchValidationCategory and bundleVersion are now appended to attestation authenticator data on iOS 27+.
β’ macOS 27 gains App Attest support for the first time; leaf certificate now includes an ACL Blob OID representing Secure Enclave key access control conditions.
β’ isSupported API now correctly returns true for Action and SSO app extensions, previously unsupported categories.
β’ Receipt format additions align with new extension fields, requiring server-side receipt parsing updates.
β’ New authenticator data extensions (launch validation category + bundle version) let your server detect apps running in unexpected environments like TestFlight or with modified bundle versions β without any extra API calls.
β’ macOS 27 gains full App Attest support for the first time, with mandatory key access control (ACL Blob OID) properties proving System Integrity Protection and Full Security Mode were active at attestation time.
β’ The isSupported API now correctly gates App Attest across more extension types, and unsupported responses can themselves be used as a fraud signal in your risk model.
Shows how to generate an App Attest key, request an attestation, and then inspect the raw authenticator data to find the new iOS 27 extensions (launch validation category and bundle version).
import SwiftUIimport DeviceCheckimport CryptoKitβ// Pre-iOS 27 App Attest integrationβ// Limitations:β// - macOS was NOT supported at allβ// - authenticator data had NO extensions (no launchValidationCategory, no bundleVersion)β// - isSupported returned false for Action/SSO extensions+// MARK: - App Attest Managerβactor LegacyAppAttestManager {β static let shared = LegacyAppAttestManager()+actor AppAttestManager {+ static let shared = AppAttestManager()private let service = DCAppAttestService.sharedβ private let keychainKey = "com.myapp.legacyAttestKeyID"+ private let keychainKey = "com.myapp.attestKeyID"β var isSupported: Bool { service.isSupported }+ // Step 1: Check support (new: also available on macOS 27, more extension types)+ var isSupported: Bool {+ service.isSupported+ }+ // Step 2: Generate or retrieve a key IDfunc getOrCreateKeyID() async throws -> String {β if let existing = loadKeyIDFromKeychain() { return existing }+ if let existing = loadKeyIDFromKeychain() {+ return existing+ }let keyID = try await service.generateKey()saveKeyIDToKeychain(keyID)return keyID}+ // Step 3: Attest the key using a server-vended challengefunc attestKey(keyID: String, serverChallenge: Data) async throws -> Data {+ // Hash the challenge as required by the App Attest APIlet challengeHash = Data(SHA256.hash(data: serverChallenge))β // Returns attestation WITHOUT extensions in authenticator dataβ // Server sees only: rpIdHash, flags, counter, AAGUID, credentialIDβ // No launchValidationCategory, no bundleVersion, no ACL Blob OIDβ return try await service.attestKey(keyID, clientDataHash: challengeHash)+ let attestation = try await service.attestKey(keyID, clientDataHash: challengeHash)+ return attestation}+ // Step 4: Generate an assertion for a payload (local, no Apple server round-trip)func generateAssertion(keyID: String, payload: Data) async throws -> Data {let payloadHash = Data(SHA256.hash(data: payload))return try await service.generateAssertion(keyID, clientDataHash: payloadHash)}+ // MARK: - Keychain helpers (simplified)private func saveKeyIDToKeychain(_ keyID: String) {let data = Data(keyID.utf8)let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword,kSecAttrAccount: keychainKey,kSecValueData: data,kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]SecItemDelete(query as CFDictionary)SecItemAdd(query as CFDictionary, nil)}private func loadKeyIDFromKeychain() -> String? {let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword,kSecAttrAccount: keychainKey,kSecReturnData: true,kSecMatchLimit: kSecMatchLimitOne]var result: AnyObject?guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,let data = result as? Data else { return nil }return String(data: data, encoding: .utf8)}}βstruct LegacyAppAttestDemoView: View {β @State private var log: [String] = []+// MARK: - SwiftUI Demo View+struct AppAttestDemoView: View {+ @State private var statusLog: [String] = []+ @State private var isWorking = false+var body: some View {β List(log, id: \.self) { Text($0).font(.caption) }β .navigationTitle("App Attest (pre-iOS 27)")+ NavigationStack {+ List(statusLog, id: \.self) { entry in+ Text(entry)+ .font(.system(.caption, design: .monospaced))+ .foregroundStyle(entry.hasPrefix("β ") ? .green :+ entry.hasPrefix("β") ? .red : .primary)+ }+ .navigationTitle("App Attest iOS 27").toolbar {β ToolbarItem {β Button("Run") { Task { await run() } }+ ToolbarItem(placement: .primaryAction) {+ Button(isWorking ? "Workingβ¦" : "Run Attestation") {+ Task { await runAttestation() }+ }+ .disabled(isWorking)}}+ }}@MainActorβ private func run() async {β log.removeAll()β // macOS: would crash or return isSupported == falseβ // Extensions in authenticator data: not present β server gets no env infoβ let mgr = LegacyAppAttestManager.shared+ private func runAttestation() async {+ isWorking = true+ statusLog.removeAll()++ let mgr = AppAttestManager.shared+guard await mgr.isSupported else {β log.append("Not supported"); return+ statusLog.append("β App Attest not supported on this device/extension type")+ isWorking = false+ return}+ statusLog.append("β App Attest is supported")+do {let keyID = try await mgr.getOrCreateKeyID()β log.append("Key: " + keyID.prefix(16))β let attestation = try await mgr.attestKey(β keyID: keyID,β serverChallenge: Data(UUID().uuidString.utf8)β )β log.append("Attestation: \(attestation.count) bytes β NO env extensions")+ statusLog.append("β Key ID: " + keyID.prefix(16) + "...")++ // Simulate a server-vended challenge (in production, fetch from your server)+ let fakeChallenge = Data(UUID().uuidString.utf8)+ let attestation = try await mgr.attestKey(keyID: keyID,+ serverChallenge: fakeChallenge)+ statusLog.append("β Attestation received (\(attestation.count) bytes)")+ statusLog.append("βΉοΈ Send to server to validate authenticator data extensions:")+ statusLog.append(" β’ launchValidationCategory (NEW iOS 27)")+ statusLog.append(" β’ bundleVersion (NEW iOS 27)")+ statusLog.append(" β’ ACL Blob OID (NEW macOS 27)")++ // Generate an assertion for a sample payload+ let payload = Data("{ \"action\": \"submitScore\", \"score\": 9999 }".utf8)+ let assertion = try await mgr.generateAssertion(keyID: keyID, payload: payload)+ statusLog.append("β Assertion generated (\(assertion.count) bytes) β embed in server request")} catch {β log.append("Error: \(error)")+ statusLog.append("β Error: \(error.localizedDescription)")}++ isWorking = false}}β#Preview { LegacyAppAttestDemoView() }+#Preview {+ AppAttestDemoView()+}
In-depth guide
iOS 26 β iOS 27 Migration Guide βThe new extensions field appended to authenticator data follows the WebAuthn authenticator model β servers must be updated to parse CBOR-encoded extensions or they will silently ignore launch validation category and bundle version fields. Existing attestation validation code that does not read past the fixed-length authenticator data will miss these new fields entirely. Keys are invalidated on reinstall or device restore β build Keychain-based key lifecycle management before relying on assertions.
Requires Secure Enclave; not available in simulator. macOS requires Full Security Mode and SIP enabled for key generation policy to pass. Not available in all app extension types β check DCAppAttestService.isSupported.