How to Implement App Attest in SwiftUI
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
-
Key generation (
generateKey()). Apple's Secure Enclave creates an asymmetric key pair whose private key never leaves the device. The returnedkeyId— a stable string — is persisted inUserDefaults(or better, the Keychain) so the expensive one-time attestation isn't repeated on every launch. -
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. -
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. -
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 togenerateAssertion. This produces a compact signature your server verifies, proving each request originates from the same attested key without repeating the full Apple round-trip. -
TrustStateenum driving the UI. The@Observablemanager exposes atrustStatethat cycles through.unchecked → .attesting → .attested(or.failed). SwiftUI'sswitchon this enum intrustBadgeautomatically 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
-
Simulator always returns
isSupported == false. App Attest requires the Secure Enclave, which the Simulator doesn't emulate. Build a stub that bypasses attestation in the Simulator using#if targetEnvironment(simulator), and test the real flow only on a physical device with a TestFlight or App Store build. -
Re-generating the key on every launch breaks attestation.
generateKey()creates a new key each call. If you call it again after the first attestation, the old receipt is invalid and you'll burn another server round-trip. Always check for a storedkeyIdfirst, and only generate a new one when none exists or the old key is explicitly invalidated. -
Assertion payload must exactly match what the server hashes. If your app hashes
"POST /api/orders"but your server hashes the raw body bytes, the assertion will fail every time. Agree on a canonical representation — typicallySHA256(requestBody)— and document it as a contract between client and server before you write either side. -
App Attest risk metrics, not a binary guarantee. Apple's receipt includes a
receipt.riskMetricfield. A low value doesn't mean the device is jailbroken — it means Apple has seen fewer attestations from this device. Don't hard-block users based solely on a low risk metric; use it as a signal alongside other server-side heuristics.
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.