```html SwiftUI: How to Implement App Attest (iOS 17+, 2026)

How to Implement App Attest in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: DCAppAttestService Updated: May 11, 2026
TL;DR

Use DCAppAttestService.shared to generate a hardware-bound key, attest it against a server challenge once, then assert every protected API request — so your backend can mathematically verify it's talking to an unmodified copy of your app on a real Apple device.

import DeviceCheck
import CryptoKit

let service = DCAppAttestService.shared
guard service.isSupported else { throw AttestError.unsupported }

// 1. Generate a key (store keyId in Keychain for reuse)
let keyId = try await service.generateKey()

// 2. Fetch a one-time challenge from your server
let challenge = try await fetchChallenge()          // Data from server

// 3. Hash the challenge, then attest
let hash = Data(SHA256.hash(data: challenge))
let attestation = try await service.attestKey(keyId, clientDataHash: hash)

// 4. Send attestation + keyId to server for verification
try await verifyAttestation(keyId: keyId, attestation: attestation)

Full implementation

The implementation below wraps DCAppAttestService in an @Observable AppAttestManager that handles the full lifecycle: generating and caching the key identifier, performing the one-time attestation, and generating per-request assertions. The SwiftUI view surfaces the current trust state so the UI can gate protected features until attestation succeeds.

import SwiftUI
import DeviceCheck
import CryptoKit

// MARK: - Errors

enum AttestError: LocalizedError {
    case unsupported
    case missingKeyId
    case serverChallengeFailed
    case assertionFailed

    var errorDescription: String? {
        switch self {
        case .unsupported:       return "App Attest is not supported on this device."
        case .missingKeyId:      return "No attested key found. Run attestation first."
        case .serverChallengeFailed: return "Could not fetch a challenge from the server."
        case .assertionFailed:   return "Failed to generate a request assertion."
        }
    }
}

// MARK: - Manager

@Observable
final class AppAttestManager {
    enum TrustState {
        case unchecked, unsupported, attesting, attested, failed(Error)
    }

    var trustState: TrustState = .unchecked

    private let service = DCAppAttestService.shared
    private let keyIdKey = "com.myapp.attestKeyId"

    // Retrieve or generate the key identifier
    private func resolvedKeyId() async throws -> String {
        if let stored = UserDefaults.standard.string(forKey: keyIdKey) {
            return stored
        }
        let keyId = try await service.generateKey()
        UserDefaults.standard.set(keyId, forKey: keyIdKey)
        return keyId
    }

    // One-time attestation: call this on first launch after login
    func attest() async {
        guard service.isSupported else {
            trustState = .unsupported
            return
        }
        trustState = .attesting
        do {
            let keyId   = try await resolvedKeyId()
            let challenge = try await fetchServerChallenge()          // ← your network call
            let hash    = Data(SHA256.hash(data: challenge))
            let receipt = try await service.attestKey(keyId, clientDataHash: hash)
            try await sendAttestationToServer(keyId: keyId, receipt: receipt) // ← your network call
            trustState = .attested
        } catch {
            UserDefaults.standard.removeObject(forKey: keyIdKey)     // reset on failure
            trustState = .failed(error)
        }
    }

    // Per-request assertion — call before every protected API call
    func assertion(for requestData: Data) async throws -> Data {
        guard case .attested = trustState else { throw AttestError.missingKeyId }
        guard let keyId = UserDefaults.standard.string(forKey: keyIdKey) else {
            throw AttestError.missingKeyId
        }
        let hash = Data(SHA256.hash(data: requestData))
        return try await service.generateAssertion(keyId, clientDataHash: hash)
    }

    // MARK: Stubs — replace with your URLSession calls
    private func fetchServerChallenge() async throws -> Data {
        // GET /api/attest/challenge → returns random Data
        guard let data = "replace-with-real-server-challenge".data(using: .utf8) else {
            throw AttestError.serverChallengeFailed
        }
        return data
    }

    private func sendAttestationToServer(keyId: String, receipt: Data) async throws {
        // POST /api/attest/verify { keyId, receipt (base64) }
    }
}

// MARK: - SwiftUI View

struct AppAttestDemoView: View {
    @State private var manager = AppAttestManager()
    @State private var assertionResult: String = ""

    var body: some View {
        VStack(spacing: 24) {
            trustBadge
            Button("Run Attestation") {
                Task { await manager.attest() }
            }
            .buttonStyle(.borderedProminent)
            .disabled(isAttesting)

            if case .attested = manager.trustState {
                Button("Generate Assertion") {
                    Task {
                        let payload = Data("GET /api/secure".utf8)
                        if let assertion = try? await manager.assertion(for: payload) {
                            assertionResult = assertion.base64EncodedString().prefix(48) + "…"
                        }
                    }
                }
                .buttonStyle(.bordered)
                if !assertionResult.isEmpty {
                    Text(assertionResult)
                        .font(.caption.monospaced())
                        .foregroundStyle(.secondary)
                        .multilineTextAlignment(.center)
                }
            }
        }
        .padding()
        .navigationTitle("App Attest")
    }

    @ViewBuilder
    private var trustBadge: some View {
        switch manager.trustState {
        case .unchecked:
            Label("Not yet attested", systemImage: "shield")
        case .unsupported:
            Label("Unsupported device", systemImage: "shield.slash").foregroundStyle(.red)
        case .attesting:
            Label("Attesting…", systemImage: "shield").foregroundStyle(.orange)
                .overlay(alignment: .trailing) { ProgressView().padding(.leading, 8) }
        case .attested:
            Label("Device attested", systemImage: "checkmark.shield.fill").foregroundStyle(.green)
        case .failed(let error):
            Label(error.localizedDescription, systemImage: "exclamationmark.shield")
                .foregroundStyle(.red).multilineTextAlignment(.center)
        }
    }

    private var isAttesting: Bool {
        if case .attesting = manager.trustState { return true }
        return false
    }
}

#Preview {
    NavigationStack {
        AppAttestDemoView()
    }
}

How it works

  1. Key generation (generateKey()). Apple's Secure Enclave creates an asymmetric key pair whose private key never leaves the device. The returned keyId — a stable string — is persisted in UserDefaults (or better, the Keychain) so the expensive one-time attestation isn't repeated on every launch.
  2. Server challenge. Your backend generates a cryptographically random nonce and returns it to the app. Hashing it with SHA-256 before passing it to attestKey(_:clientDataHash:) binds the attestation to this exact exchange, preventing replay attacks.
  3. Key attestation (attestKey(_:clientDataHash:)). Apple's servers validate the Secure Enclave key and produce an CBOR-encoded receipt signed by Apple's CA. Your backend sends this receipt to Apple's App Attest API (or validates it locally using Apple's root certificate) to confirm the key came from a genuine device running your unmodified app.
  4. Per-request assertions (generateAssertion(_:clientDataHash:)). After one-time attestation, every protected API call hashes its request body (or a canonical string like "METHOD /path") and passes it to generateAssertion. This produces a compact signature your server verifies, proving each request originates from the same attested key without repeating the full Apple round-trip.
  5. TrustState enum driving the UI. The @Observable manager exposes a trustState that cycles through .unchecked → .attesting → .attested (or .failed). SwiftUI's switch on this enum in trustBadge automatically re-renders the shield label and disables the button during in-flight attestation, giving users clear feedback.

Variants

Store the key ID in the Keychain instead of UserDefaults

UserDefaults is convenient for demonstration but can be cleared by the user. For production, persist the key ID in the Keychain so it survives app reinstalls only when iCloud Keychain is enabled — matching Apple's own recommendation.

import Security

enum KeychainHelper {
    static let service = "com.myapp.attestation"
    static let account = "attestKeyId"

    static func save(_ value: String) throws {
        let data = Data(value.utf8)
        let query: [String: Any] = [
            kSecClass as String:       kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String:   data
        ]
        SecItemDelete(query as CFDictionary)          // overwrite if exists
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.saveFailed(status) }
    }

    static func load() -> String? {
        let query: [String: Any] = [
            kSecClass as String:            kSecClassGenericPassword,
            kSecAttrService as String:      service,
            kSecAttrAccount as String:      account,
            kSecReturnData as String:       true,
            kSecMatchLimit as String:       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)
    }

    enum KeychainError: Error {
        case saveFailed(OSStatus)
    }
}

Attestation in a development environment

DCAppAttestService returns an error in the iOS Simulator and on development builds signed outside TestFlight/App Store. Gate your entire attest flow behind a compile-time flag — #if !targetEnvironment(simulator) — or check service.isSupported and fall through gracefully in debug builds. Apple also provides a separate development environment endpoint (https://data-development.appattest.apple.com) so you can exercise the full flow on a real device without burning production quota. Pass the environment URL from your server based on whether the receipt's development flag is set.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement app attest in SwiftUI for iOS 17+.
Use DCAppAttestService.
Wrap the service in an @Observable AppAttestManager with
  generateKey(), attestKey(), and generateAssertion() steps.
Persist the keyId in the Keychain (not UserDefaults).
Expose a TrustState enum for the SwiftUI view to observe.
Make it accessible (VoiceOver labels on the shield badge).
Add a #Preview with realistic sample data.

In Soarias, drop this prompt into the Build phase after your screens are scaffolded — the agent will wire AppAttestManager into your existing @Environment chain and add the DeviceCheck entitlement to your .entitlements file automatically.

Related

FAQ

Does App Attest work on iOS 16?

DCAppAttestService was introduced in iOS 14, so the APIs compile and run on iOS 16. However, this guide targets iOS 17+ because @Observable (used for AppAttestManager) and the #Preview macro require iOS 17 and Xcode 15+. If you need iOS 16 support, swap @Observable for ObservableObject / @Published and replace #Preview with PreviewProvider.

How often should I re-attest vs. assert?

Attest once per device per app installation — the key ID is stable until the user deletes the app or you explicitly rotate it. After that, generate a fresh assertion for every protected server request (or at minimum, every session). Attestation calls Apple's servers and counts against App Attest's rate limits; assertions are entirely local and instantaneous.

What's the UIKit / non-SwiftUI equivalent?

DCAppAttestService has no UI of its own — the service API is identical whether you use SwiftUI or UIKit. In UIKit, replace the @Observable manager with a regular NSObject class and use delegation or NotificationCenter to push state changes to your view controller. The generateKey(), attestKey, and generateAssertion calls are exactly the same.

Last reviewed: 2026-05-11 by the Soarias team.

```