---
name: device-integrity
description: "DeviceCheck and App Attest: DCDevice tokens and per-device bits, DCAppAttestService key generation, attestation and assertions. Use when adding fraud prevention, detecting compromised devices, validating app authenticity with Apple's servers, protecting sensitive endpoints with attested requests, or designing backend device verification. Not for sign-in, OAuth, JWT sessions, TLS or pinning."
metadata:
  source: multi-agent-pipeline
---

# Device Integrity

Two frameworks let a backend decide whether a request really originates from
real Apple hardware running an unmodified build of your app:

- **DeviceCheck** (`DCDevice`) hands your server a token it can trade with Apple
  for two developer-defined bits per device. Good for simple flags such as
  "this phone already redeemed the trial".
- **App Attest** (`DCAppAttestService`) creates a hardware key inside the Secure
  Enclave, has Apple certify it, and then signs chosen requests with it. Good
  for cryptographic proof on sensitive calls.

Server algorithms, retries, rejected-key recovery, an integration outline and
rollout advice live in the [patterns reference](references/device-integrity-patterns.md).
The `IntegrityError` enum used below is defined there too.

## DeviceCheck tokens (`DCDevice`, iOS 11+)

`DCDevice` produces an ephemeral token that identifies the device to Apple.
Each token is single-use: create a fresh one for every server operation and
never cache it. The server forwards the token to Apple to read or change the
two bits.

```swift
import DeviceCheck

func sendJSON(_ fields: [String: String], to url: URL) async throws -> Bool {
    var req = URLRequest(url: url)
    req.httpMethod = "POST"
    req.addValue("application/json", forHTTPHeaderField: "Content-Type")
    let payload = try JSONEncoder().encode(fields)
    let (_, reply) = try await URLSession.shared.upload(for: req, from: payload)
    return (reply as? HTTPURLResponse).map { $0.statusCode == 200 } ?? false
}

func claimTrial(at endpoint: URL) async throws {
    guard DCDevice.current.isSupported else { throw IntegrityError.deviceCheckUnsupported }
    let token = try await DCDevice.current.generateToken()   // new token every call
    let accepted = try await sendJSON(["device_token": token.base64EncodedString()], to: endpoint)
    if !accepted { throw IntegrityError.serverVerificationFailed }
}
```

### What the server does

All three calls live under `/v1/` on the DeviceCheck host:

| Path | Purpose |
|---|---|
| `query_two_bits` | Returns the current bit values |
| `update_two_bits` | Changes one or both bits |
| `validate_device_token` | Confirms a token is real, bits untouched |

The server signs a JWT for each call with a DeviceCheck private key created in
the Developer portal. Hosts: `api.devicecheck.apple.com` for production traffic,
and `api.development.devicecheck.apple.com` only while testing.

### The two bits

Apple keeps two Booleans per device for each developer team, and you decide
what they mean. For example bit 0 might record "promo redeemed" and bit 1
"flagged for abuse". They survive deleting and reinstalling the app; only
your server can reset them through the API.

## App Attest (`DCAppAttestService`, iOS 14+)

App Attest ties a specific app instance on a specific device to a Secure
Enclave key. It runs in three phases:

1. **Key generation** - the Secure Enclave makes a key pair.
2. **Attestation** - Apple vouches that this key sits on real hardware inside
   your app.
3. **Assertion** - sign chosen requests with the attested key.

### Support check

```swift
let attest = DCAppAttestService.shared
if !attest.isSupported {
    // Fall back to a DCDevice token or another risk signal.
}
```

The service is unavailable in the Simulator and on some hardware. For app
extensions only Action, extensible single sign-on and watchOS extensions are
supported; treat any other extension type as unsupported even when
`isSupported` returns true.

### Key generation

Make one key for each signed-in account on a given device. Its private half never leaves
the Secure Enclave and the returned `keyId` is your only handle to it, so store
it and reuse it for that account on that device. Never share a key between
users, and avoid regenerating: every new key moves Apple's per-device key
count, which feeds risk metrics. Treat a `keyId` as usable only once your
server accepts its attestation; if verification fails, discard it and make a
new key before retrying.

```swift
import DeviceCheck
import Foundation
import Security

actor AttestKeyStore {
    private let service = DCAppAttestService.shared
    private let accountID: String
    var keyID: String?

    init(accountID: String) { self.accountID = accountID }

    private var keychainAccount: String { "attest-key.\(accountID)" }

    var baseQuery: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: Bundle.main.bundleIdentifier ?? "attest",
            kSecAttrAccount as String: keychainAccount,
        ]
    }

    func generateKeyIfNeeded() async throws -> String {
        if let keyID { return keyID }
        if let stored = loadKeyID() {
            keyID = stored
            return stored
        }
        let fresh = try await service.generateKey()
        try saveKeyID(fresh)
        keyID = fresh
        return fresh
    }

    private func saveKeyID(_ value: String) throws {
        let data = Data(value.utf8)
        var item = baseQuery
        item[kSecValueData as String] = data
        item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        let status = SecItemAdd(item as CFDictionary, nil)
        if status == errSecSuccess { return }
        guard status == errSecDuplicateItem else { throw IntegrityError.keyNotGenerated }
        let updated = SecItemUpdate(baseQuery as CFDictionary,
                                    [kSecValueData as String: data] as CFDictionary)
        guard updated == errSecSuccess else { throw IntegrityError.keyNotGenerated }
    }

    private func loadKeyID() -> String? {
        var query = baseQuery
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne
        var found: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &found)
        guard status == errSecSuccess, let bytes = found as? Data else { return nil }
        return String(data: bytes, encoding: .utf8)
    }
}
```

`saveKeyID` adds the item and falls back to `SecItemUpdate` on
`errSecDuplicateItem`, the same add-or-update rule the `swift-security` skill
sets for every keychain save. Save, load and delete all build on the same
`baseQuery`, so the account attribute always carries the account suffix. The delete path in the
[rejected-key section](references/device-integrity-patterns.md#rejected-keys)
reuses it for that reason.

### Attestation

Attestation proves the key was created on a real device inside your genuine
app. Do it once per key. The server records the public key and receipt it verified;
the app keeps using the `keyId` for assertions once the server has accepted it.

Client order: ask the server for a one-time challenge, hash it with SHA-256,
call `attestKey(_:clientDataHash:)`, and upload the result.

```swift
import CryptoKit

extension AttestKeyStore {
    func attest(challenge: Data, uploadTo url: URL) async throws {
        let keyID = try await generateKeyIfNeeded()
        let hash = Data(SHA256.hash(data: challenge))
        let attestation = try await DCAppAttestService.shared.attestKey(keyID, clientDataHash: hash)

        let accepted = try await sendJSON([
            "key_id": keyID,
            "attestation": attestation.base64EncodedString(),
            "challenge": challenge.base64EncodedString(),
        ], to: url)
        guard accepted else { throw IntegrityError.attestationVerificationFailed }
    }
}
```

On the server: parse the CBOR object, walk its certificates up to Apple's
App Attest root, check the nonce, then persist the public key and receipt. The
nonce is **not** `SHA256(challenge)`. It is `SHA256(authData || SHA256(challenge))`,
and it has to match the octet string stored under OID `1.2.840.113635.100.8.2`
in the leaf certificate.

### Assertion

An assertion signs one sensitive request and embeds a server-issued, one-time
challenge so a captured request cannot be replayed. Call
`generateAssertion(_:clientDataHash:)` with the SHA-256 of encoded client data
that holds the challenge plus request context.

```swift
struct SignedRequestContext: Codable {
    let challenge: String
    let method: String
    let path: String
    let bodyDigest: String
}

extension AttestKeyStore {
    func signed(_ original: URLRequest, body: Data, challenge: Data) async throws -> URLRequest {
        var request = original
        let keyID = try await generateKeyIfNeeded()
        let context = SignedRequestContext(
            challenge: challenge.base64EncodedString(),
            method: request.httpMethod ?? "GET",
            path: request.url?.path ?? "/",
            bodyDigest: SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()
        )
        let encoded = try JSONEncoder().encode(context)
        let assertion = try await DCAppAttestService.shared.generateAssertion(
            keyID, clientDataHash: Data(SHA256.hash(data: encoded))
        )
        let headers = [
            "X-App-Attest-Assertion": assertion.base64EncodedString(),
            "X-App-Attest-Client-Data": encoded.base64EncodedString(),
        ]
        headers.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) }
        request.httpBody = body
        return request
    }
}
```

Fetch a new assertion challenge from your challenge endpoint for every request.

Server side: decode the CBOR assertion, inspect authenticator data and its
counter, hash the client data it received to get `clientDataHash` again, check
that the stored public key signed `SHA256(authenticatorData || clientDataHash)`,
and make sure the challenge and the request context both line up.

## Security boundaries

App Attest says "this request came from a genuine instance of the app". It
says nothing about who the user is. Sign-in, OAuth or JWT sessions, how API
tokens are designed, paid-feature and subscription checks, TLS and certificate
pinning all remain separate jobs. Keep enforcing authentication and authorization after the
attestation check passes. Hand-offs:

- Apple ID sign-in, passkeys, OAuth flows, credential state: `authentication`
- Keychain policy, Secure Enclave, pinning, storage hardening: `swift-security`
- Hashing and signing primitives: `cryptokit`
- Transport, request pipelines, TLS configuration: `ios-networking`

## Errors

| `DCError.Code` | Meaning | Action |
|---|---|---|
| `.serverUnavailable` | Apple's service is temporarily unreachable | Retry with exponential backoff |
| `.invalidKey` | Key already attested, assertion with an unattested key, or key rejected | Find out which, then recover |
| `.featureUnsupported` | Device or OS lacks App Attest | Use `DCDevice` tokens instead |
| `.invalidInput` | Malformed `clientDataHash` or `keyId` | Fix the caller |

When `attestKey` reports `.serverUnavailable`, try again later passing the
**same** `keyId` and the **same** `clientDataHash`. Any other attestation
failure means the key identifier is spent: drop it and make a fresh key first.

## Environment entitlement

The App Attest environment entitlement
(`com.apple.developer.devicecheck.appattest-environment`) takes `development`
for testing or `production` for release. Without it, development builds talk
to the App Attest sandbox. TestFlight, App Store and Enterprise Program builds
ignore the value and always use production.

## Common mistakes

- Generating a key on every launch instead of once per account per device.
- Caching or reusing a `DCDevice` token.
- No fallback path for unsupported devices or extension types.
- Trusting attestation on the client. Only the server can verify it.
- Signing just the raw body. Include the one-time challenge and request context.
- Checking the nonce as `SHA256(challenge)`.
- No replay defense. The backend has to accept each challenge once and keep the per-key counter moving forward.
- Mixing environments. Sandbox keys and receipts fail against production and the reverse.
- Regenerating on `invalidKey` without knowing why. Establish whether the key
  was already attested, never attested, or rejected, and only replace it when
  its state is known to be bad.

## Review checklist

- [ ] A new `DCDevice` token per operation; none cached
- [ ] `isSupported` checked, with a fallback for devices and extension types that lack App Attest
- [ ] One key per account per device; `keyId` stored only for that account on that device
- [ ] Keychain save, load and delete all use the same account-scoped query
- [ ] One attestation per key; server keeps the public key and receipt
- [ ] Attestation verifier covers: certificates, App ID hash, `aaguid` for the environment, credential ID, and a nonce of `SHA256(authData || SHA256(challenge))`
- [ ] Each assertion signs a challenge plus request context; the backend checks the signature, RP ID, counter, the challenge and how it binds to the request
- [ ] Protected endpoints still require user authentication and authorization
- [ ] `DCError` handled: `.serverUnavailable` retried with same key and hash, bad keys discarded and regenerated
- [ ] Environment entitlement agrees with sandbox or production server routing
- [ ] Rollout gated behind a feature flag

## References

- [Patterns reference](references/device-integrity-patterns.md) - server verification steps, architecture, retries, rejected keys, rollout
- Apple documentation: [DeviceCheck](https://developer.apple.com/documentation/devicecheck),
  [DCDevice](https://developer.apple.com/documentation/devicecheck/dcdevice),
  [DCAppAttestService](https://developer.apple.com/documentation/devicecheck/dcappattestservice),
  plus the DeviceCheck articles on app integrity, server-side validation,
  attestation objects and the environment entitlement (see the patterns reference)
