Security

Secure iOS App Communication with the Backend – Part 3: App Attest and DeviceCheck

22 min readRafał Dubiel
#iOS#Security#Swift#App Attest#DeviceCheck

In the previous articles, we secured the transport layer (request signing, quantum TLS, SSL pinning). But there is one problem: all of these protections operate on the client side. What if an attacker modifies the app itself?

Frida can hook your code. Objection can disable pinning. An IPA can be decompiled, modified, and signed again. And then all of your client-side protections become useless.

App Attest solves this problem by moving verification to the server side. Instead of asking, "does the app detect an attack?", we ask, "does Apple confirm that this app is authentic?"

This is the final piece of the puzzle: request signing + quantum TLS + SSL pinning + device attestation.


What are App Attest and DeviceCheck?

Apple provides two complementary mechanisms within the DeviceCheck framework:

MechanismIntroducedPurposeHow it works
DeviceCheck (2-bit flags)iOS 11Tracking device state2 bits per device, stored on Apple servers
App AttestiOS 14Verifying app authenticityCryptographic attestation from the Secure Enclave

DeviceCheck: 2 bits that are enough

DeviceCheck lets you store 2 bits of data per device on Apple’s servers. It sounds like very little, but it is enough for:

  • Marking a device as "has used a free trial",
  • Flagging devices suspected of fraud,
  • Tracking whether a device has already been banned,
  • Detecting repeated app reinstalls.

Important: The bits are tied to the device, not the user or the installation. Reinstalling the app does not reset the bits.

App Attest: cryptographic proof of authenticity

App Attest lets your server verify that:

  1. A request comes from an unmodified version of your app,
  2. The app is running on a real device (not a simulator),
  3. The cryptographic key is stored in the Secure Enclave.

How does it work?

1. The app generates a key pair in the Secure Enclave,
2. Apple attests the key (confirms its authenticity),
3. The app signs requests with the private key,
4. The backend verifies the signatures with the public key.

When should you use App Attest?

App Attest is not free - it requires backend work and adds latency. But for some apps, it is essential.

Use caseRecommendation
Games with in-app purchasesProtects against modifying currency/item values
Fintech / paymentsVerification of critical transactions
Apps with premium contentPrevents unlocking without payment
Loyalty programsProtects against farming points
Social mediaConsider it for sensitive operations
Content appsProbably overkill

App Attest limitations (as of 2026)

Before you implement it, you need to know the limitations:

  1. It does not detect jailbreak - App Attest verifies the app, not the device. A jailbroken device may still pass attestation.

  2. It does not protect against all attacks - sophisticated attackers may still attempt replay attacks or manipulate outcomes.

  3. It requires a backend - all verification must happen server-side. Client-side validation is useless.

  4. Rate limiting - Apple limits the number of attestations (DCError.serverUnavailable when limits are exceeded). For large apps, a gradual rollout is required (Apple recommends anywhere from 1 day for a few million users to 30 days for a billion). Handle DCError.rateLimited - do not block the user, use a fallback.

  5. It does not work on the simulator - you must test on a real device. Starting with Xcode 26+, however, you can perform attestation on a device using a developer provisioning profile, which makes end-to-end testing easier.

  6. No cross-platform support - iOS/iPadOS only. Android requires a separate solution (Play Integrity API).

Corellium’s article "iOS Jailbreaking Is Dead" reports that iOS 26 with Memory Integrity Enforcement significantly limits the possibility of creating public physical jailbreaks. The era of easy jailbreaks is effectively coming to an end, although it cannot be ruled out that isolated exploits will appear in the future. This strengthens App Attest’s position - fewer jailbroken devices means fewer potential attack vectors.


Part 1: client-side implementation

Attestation flow (one-time, on first launch):

1. App → Backend: Ask for a challenge,
2. Backend → App: Return a random challenge (e.g. UUID),
3. App: Generate a key in the Secure Enclave,
4. App → Apple: Request key attestation,
5. Apple → App: Return the attestation object,
6. App → Backend: Send attestation + keyId + challenge,
7. Backend: Verify the attestation, store the public key.

Assertion flow (for every sensitive request):

1. App → Backend: Ask for a challenge (or use a timestamp/payload hash),
2. App: Sign the request with the private key,
3. App → Backend: Send the request + assertion,
4. Backend: Verify the signature, check the counter.

Swift implementation

import DeviceCheck
import CryptoKit

/// App Attest manager - handles attestation and assertions
actor AppAttestManager {

    // MARK: - Singleton

    static let shared = AppAttestManager()

    // MARK: - Properties

    private let service = DCAppAttestService.shared
    private let keychain = KeychainManager.shared
    private let apiClient: APIClient

    private var keyId: String?
    private var isAttested = false

    // MARK: - Constants

    private let keyIdKeychainKey = "com.app.appattest.keyId"
    private let attestedFlagKey = "com.app.appattest.attested"

    // MARK: - Initialization

    private init(apiClient: APIClient = .shared) {
        self.apiClient = apiClient

        // Load the stored keyId
        self.keyId = keychain.getString(keyIdKeychainKey)
        self.isAttested = UserDefaults.standard.bool(forKey: attestedFlagKey)
    }

    // MARK: - Public API

    /// Checks whether App Attest is available
    var isSupported: Bool {
        service.isSupported
    }

    /// Performs full attestation (one-time)
    func performAttestation() async throws {
        // Check whether it has already been attested
        if isAttested, keyId != nil {
            return
        }

        guard isSupported else {
            throw AppAttestError.notSupported
        }

        // 1. Fetch a challenge from the backend
        let challenge = try await apiClient.getAttestationChallenge()

        // 2. Generate a key in the Secure Enclave (if it does not exist)
        let keyId = try await getOrCreateKeyId()

        // 3. Ask Apple for attestation
        let challengeHash = Data(SHA256.hash(data: challenge))
        let attestationObject = try await service.attestKey(keyId, clientDataHash: challengeHash)

        // 4. Send it to the backend for verification
        try await apiClient.verifyAttestation(
            keyId: keyId,
            attestation: attestationObject,
            challenge: challenge
        )

        // 5. Persist state
        self.keyId = keyId
        self.isAttested = true
        UserDefaults.standard.set(true, forKey: attestedFlagKey)
    }

    /// Generates an assertion for a sensitive request
    func generateAssertion(for requestData: Data) async throws -> Data {
        guard let keyId = keyId, isAttested else {
            throw AppAttestError.notAttested
        }

        // Hash the request data (or a challenge from the server)
        let clientDataHash = Data(SHA256.hash(data: requestData))

        // Generate the assertion
        let assertion = try await service.generateAssertion(keyId, clientDataHash: clientDataHash)

        return assertion
    }

    /// Resets state (e.g. after logout or an error)
    func reset() {
        keyId = nil
        isAttested = false
        keychain.delete(keyIdKeychainKey)
        UserDefaults.standard.removeObject(forKey: attestedFlagKey)
    }

    // MARK: - Private

    private func getOrCreateKeyId() async throws -> String {
        // Check whether we have a stored keyId
        if let existingKeyId = keyId {
            return existingKeyId
        }

        // Generate a new key
        let newKeyId = try await service.generateKey()

        // Store it in the keychain
        try keychain.setString(newKeyId, forKey: keyIdKeychainKey)

        return newKeyId
    }
}

// MARK: - Errors

enum AppAttestError: Error, LocalizedError {
    case notSupported
    case notAttested
    case keyGenerationFailed
    case attestationFailed(underlying: Error)
    case assertionFailed(underlying: Error)
    case serverRejected(reason: String)

    var errorDescription: String? {
        switch self {
        case .notSupported:
            return "App Attest is not supported on this device"
        case .notAttested:
            return "Device has not been attested yet"
        case .keyGenerationFailed:
            return "Failed to generate attestation key"
        case .attestationFailed(let error):
            return "Attestation failed: \(error.localizedDescription)"
        case .assertionFailed(let error):
            return "Assertion failed: \(error.localizedDescription)"
        case .serverRejected(let reason):
            return "Server rejected attestation: \(reason)"
        }
    }
}

Networking integration

/// URLSession wrapper with App Attest
final class SecureAPIClient {

    private let attestManager = AppAttestManager.shared
    private let session: URLSession
    private let baseURL: URL

    init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
    }

    /// Performs a sensitive request with an assertion
    func performSecureRequest<T: Decodable>(
        endpoint: String,
        method: String = "POST",
        body: Encodable? = nil
    ) async throws -> T {

        // Make sure we have attestation
        try await attestManager.performAttestation()

        // Prepare the request
        var request = URLRequest(url: baseURL.appendingPathComponent(endpoint))
        request.httpMethod = method
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        // Serialize the body
        var requestData = Data()
        if let body = body {
            requestData = try JSONEncoder().encode(body)
            request.httpBody = requestData
        }

        // Generate the assertion
        let assertion = try await attestManager.generateAssertion(for: requestData)

        // Add the assertion to the headers
        request.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Assertion")
        request.setValue(
            Data(SHA256.hash(data: requestData)).base64EncodedString(),
            forHTTPHeaderField: "X-Client-Data-Hash"
        )

        // Perform the request
        let (data, response) = try await session.data(for: request)

        // Check the response
        guard let httpResponse = response as? HTTPURLResponse else {
            throw URLError(.badServerResponse)
        }

        // Handle attestation errors
        if httpResponse.statusCode == 403 {
            if let errorMessage = String(data: data, encoding: .utf8),
               errorMessage.contains("attestation") {
                // Reset and try again
                await attestManager.reset()
                throw AppAttestError.serverRejected(reason: errorMessage)
            }
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw URLError(.badServerResponse)
        }

        return try JSONDecoder().decode(T.self, from: data)
    }
}

Graceful degradation

Not all devices support App Attest. You need a plan B:

/// Fallback strategy for devices without App Attest
enum AttestationStrategy {
    case appAttest           // Full attestation
    case deviceCheck         // DeviceCheck only (2-bit flags)
    case riskBased           // Additional verification (e.g. CAPTCHA, SMS)
    case degraded            // Limited functionality
}

actor AttestationStrategyManager {

    static let shared = AttestationStrategyManager()

    func determineStrategy() async -> AttestationStrategy {
        // 1. Check App Attest
        if DCAppAttestService.shared.isSupported {
            return .appAttest
        }

        // 2. Check DeviceCheck
        if DCDevice.current.isSupported {
            return .deviceCheck
        }

        // 3. Fall back to risk-based verification
        return .riskBased
    }

    func executeStrategy(_ strategy: AttestationStrategy) async throws {
        switch strategy {
        case .appAttest:
            try await AppAttestManager.shared.performAttestation()

        case .deviceCheck:
            try await performDeviceCheckOnly()

        case .riskBased:
            // Additional verification
            try await requestAdditionalVerification()

        case .degraded:
            // Limit functionality
            NotificationCenter.default.post(
                name: .attestationDegraded,
                object: nil
            )
        }
    }

    private func performDeviceCheckOnly() async throws {
        guard DCDevice.current.isSupported else { return }

        let token = try await DCDevice.current.generateToken()
        // Send the token to the backend for verification / bit storage
    }

    private func requestAdditionalVerification() async throws {
        // E.g. CAPTCHA, SMS verification, email confirmation
    }
}

extension Notification.Name {
    static let attestationDegraded = Notification.Name("attestationDegraded")
}

Part 2: server-side implementation

Verifying the attestation object

The attestation object is a CBOR-encoded structure compliant with WebAuthn. Verification requires:

  1. Decoding CBOR,
  2. Validating the certificate chain (Apple Root CA → Intermediate → Credential),
  3. Checking the nonce (challenge),
  4. Extracting the public key,

Python (Flask) example:

# verify_attestation.py
import cbor2
import hashlib
import base64
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509 import load_der_x509_certificate

# Apple App Attestation Root CA
# Download from: https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
APPLE_ROOT_CA = """-----BEGIN CERTIFICATE-----
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
...
-----END CERTIFICATE-----"""

def verify_attestation(
    attestation_base64: str,
    key_id: str,
    challenge: bytes,
    team_id: str,
    bundle_id: str
) -> dict:
    """
    Verifies the attestation object according to Apple documentation.
    Returns the public key if verification succeeded.
    """

    # 1. Decode CBOR
    attestation_bytes = base64.b64decode(attestation_base64)
    attestation = cbor2.loads(attestation_bytes)

    # Structure: { "fmt": "apple-appattest", "attStmt": {...}, "authData": bytes }
    fmt = attestation.get("fmt")
    if fmt != "apple-appattest":
        raise ValueError(f"Unexpected format: {fmt}")

    att_stmt = attestation.get("attStmt", {})
    auth_data = attestation.get("authData")

    # 2. Extract certificates from x5c
    x5c = att_stmt.get("x5c", [])
    if len(x5c) < 2:
        raise ValueError("Certificate chain too short")

    cred_cert = load_der_x509_certificate(x5c[0])
    intermediate_cert = load_der_x509_certificate(x5c[1])

    # 3. Verify the certificate chain
    # (in production use full validation with Apple Root CA)
    verify_certificate_chain(cred_cert, intermediate_cert)

    # 4. Verify the nonce
    # Nonce = SHA256(authData || SHA256(challenge))
    client_data_hash = hashlib.sha256(challenge).digest()
    nonce_data = auth_data + client_data_hash
    expected_nonce = hashlib.sha256(nonce_data).digest()

    # The nonce is in certificate extension 1.2.840.113635.100.8.2
    actual_nonce = extract_nonce_from_cert(cred_cert)

    if actual_nonce != expected_nonce:
        raise ValueError("Nonce mismatch")

    # 5. Verify the App ID
    # App ID = SHA256(teamId.bundleId)
    app_id = f"{team_id}.{bundle_id}"
    expected_rp_id_hash = hashlib.sha256(app_id.encode()).digest()

    # RP ID Hash is the first 32 bytes of authData
    actual_rp_id_hash = auth_data[:32]

    if actual_rp_id_hash != expected_rp_id_hash:
        raise ValueError("App ID mismatch")

    # 6. Check authenticator data flags
    flags = auth_data[32]
    # Bit 0: User Present, Bit 2: User Verified, Bit 6: Attested Credential Data
    if not (flags & 0x40):  # AT flag
        raise ValueError("Attested credential data flag not set")

    # 7. Check the counter (should be 0 for attestation)
    counter = int.from_bytes(auth_data[33:37], 'big')
    if counter != 0:
        raise ValueError(f"Unexpected counter value: {counter}")

    # 8. Extract the public key from the certificate
    public_key = cred_cert.public_key()

    # Serialize to a storable format
    public_key_bytes = public_key.public_bytes(
        encoding=serialization.Encoding.X962,
        format=serialization.PublicFormat.UncompressedPoint
    )

    return {
        "key_id": key_id,
        "public_key": base64.b64encode(public_key_bytes).decode(),
        "counter": counter,
        "receipt": base64.b64encode(att_stmt.get("receipt", b"")).decode()
    }


def extract_nonce_from_cert(cert: x509.Certificate) -> bytes:
    """Extracts the nonce from extension OID 1.2.840.113635.100.8.2"""
    OID_NONCE = x509.ObjectIdentifier("1.2.840.113635.100.8.2")

    try:
        ext = cert.extensions.get_extension_for_oid(OID_NONCE)
        # The extension value contains an ASN.1 SEQUENCE with an OCTET STRING
        # Parsing details depend on the specific structure
        return parse_nonce_extension(ext.value.value)
    except x509.ExtensionNotFound:
        raise ValueError("Nonce extension not found")


def verify_certificate_chain(cred_cert, intermediate_cert):
    """
    Verifies the certificate chain.
    In production, use full validation with Apple Root CA.
    """
    # TODO: Full certificate chain validation implementation
    # 1. Check whether the intermediate is signed by Apple Root CA
    # 2. Check whether the credential cert is signed by the intermediate
    # 3. Check validity dates
    # 4. Check CRL/OCSP
    pass

Receipt validation and fraud risk assessment

The attestation object contains a receipt - an encoded blob that you can send to Apple for risk assessment. Apple returns a metric indicating whether the device/installation exhibits suspicious behavior.

# fraud_assessment.py
import requests
import jwt
import time

APPLE_RECEIPT_URL = "https://data.appattest.apple.com/v1/attestationData"
# Development: https://data-development.appattest.apple.com/v1/attestationData

def assess_fraud_risk(receipt: bytes, team_id: str, key_id: str, private_key: bytes) -> dict:
    """
    Sends the receipt to Apple and receives a risk assessment.

    Possible responses:
    - "valid": No suspicion
    - "unknown": Not enough data
    - "risky": Suspicious behavior (e.g. many attestations from one device)
    """

    # Generate a JWT for Apple
    now = int(time.time())
    token = jwt.encode(
        {"iss": team_id, "iat": now, "exp": now + 3600},
        private_key,
        algorithm="ES256",
        headers={"kid": key_id}
    )

    response = requests.post(
        APPLE_RECEIPT_URL,
        data=receipt,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/octet-stream"
        }
    )

    if response.status_code == 200:
        return response.json()
    else:
        raise Exception(f"Receipt validation failed: {response.status_code}")

Recommendation: Apple strongly promotes using receipt validation to detect fraud. For fintech and e-commerce apps, it is worth sending the receipt with every attestation and reacting to a "risky" status - for example by requiring additional verification (2FA, CAPTCHA). More in Apple’s documentation: Assessing Fraud Risk.

Verifying the assertion

# verify_assertion.py
import cbor2
import hashlib
import base64
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes

def verify_assertion(
    assertion_base64: str,
    client_data: bytes,
    stored_public_key: bytes,
    stored_counter: int,
    team_id: str,
    bundle_id: str
) -> dict:
    """
    Verifies the assertion for a specific request.
    """

    # 1. Decode CBOR
    assertion_bytes = base64.b64decode(assertion_base64)
    assertion = cbor2.loads(assertion_bytes)

    # Structure: { "signature": bytes, "authenticatorData": bytes }
    signature = assertion.get("signature")
    auth_data = assertion.get("authenticatorData")

    if not signature or not auth_data:
        raise ValueError("Missing signature or authenticatorData")

    # 2. Verify the App ID (RP ID Hash)
    app_id = f"{team_id}.{bundle_id}"
    expected_rp_id_hash = hashlib.sha256(app_id.encode()).digest()
    actual_rp_id_hash = auth_data[:32]

    if actual_rp_id_hash != expected_rp_id_hash:
        raise ValueError("App ID mismatch")

    # 3. Check the counter (must be > previous)
    counter = int.from_bytes(auth_data[33:37], 'big')

    if counter <= stored_counter:
        raise ValueError(f"Counter replay detected: {counter} <= {stored_counter}")

    # 4. Build the nonce for signature verification
    # nonce = SHA256(authenticatorData || SHA256(clientData))
    client_data_hash = hashlib.sha256(client_data).digest()
    nonce = hashlib.sha256(auth_data + client_data_hash).digest()

    # 5. Verify the ECDSA signature
    public_key = ec.EllipticCurvePublicKey.from_encoded_point(
        ec.SECP256R1(),
        stored_public_key
    )

    try:
        public_key.verify(
            signature,
            nonce,
            ec.ECDSA(hashes.SHA256())
        )
    except Exception as e:
        raise ValueError(f"Signature verification failed: {e}")

    # 6. Return the new counter to store
    return {
        "verified": True,
        "new_counter": counter
    }

Flask endpoint

# app.py
from flask import Flask, request, jsonify
from datetime import datetime
import secrets
import base64
import uuid
import redis

app = Flask(__name__)
redis_client = redis.Redis()

# Configuration
TEAM_ID = "ABCDE12345"
BUNDLE_ID = "com.example.app"

# NOTE: In production, public keys and counters are critical data!
# Redis is OK as a cache, but use a durable database:
# - PostgreSQL with encryption at rest
# - AWS DynamoDB with KMS encryption
# - Cloud SQL with automatic backups
# Counter replay protection requires ACID transactions!

@app.route("/api/attestation/challenge", methods=["POST"])
def get_challenge():
    """Generates a challenge for attestation."""
    challenge = secrets.token_bytes(32)
    challenge_id = secrets.token_hex(16)

    # Store the challenge with a short TTL (5 minutes)
    redis_client.setex(f"challenge:{challenge_id}", 300, challenge)

    return jsonify({
        "challenge_id": challenge_id,
        "challenge": base64.b64encode(challenge).decode()
    })


@app.route("/api/attestation/verify", methods=["POST"])
def verify_attestation_endpoint():
    """Verifies attestation and stores the public key."""
    data = request.json

    key_id = data.get("key_id")
    attestation = data.get("attestation")
    challenge_id = data.get("challenge_id")

    # Fetch the challenge
    challenge = redis_client.get(f"challenge:{challenge_id}")
    if not challenge:
        return jsonify({"error": "Challenge expired or invalid"}), 400

    # Delete the challenge (one-time use)
    redis_client.delete(f"challenge:{challenge_id}")

    try:
        result = verify_attestation(
            attestation_base64=attestation,
            key_id=key_id,
            challenge=challenge,
            team_id=TEAM_ID,
            bundle_id=BUNDLE_ID
        )

        # Store the public key and counter
        redis_client.hset(f"attestation:{key_id}", mapping={
            "public_key": result["public_key"],
            "counter": result["counter"],
            "receipt": result["receipt"],
            "created_at": datetime.utcnow().isoformat()
        })

        return jsonify({"status": "attested"})

    except ValueError as e:
        return jsonify({"error": str(e)}), 400


@app.route("/api/secure/transfer", methods=["POST"])
def secure_transfer():
    """Example endpoint protected by App Attest."""

    # Fetch the assertion from the headers
    assertion_b64 = request.headers.get("X-App-Assertion")
    client_data_hash_b64 = request.headers.get("X-Client-Data-Hash")
    key_id = request.headers.get("X-Key-Id")

    if not all([assertion_b64, client_data_hash_b64, key_id]):
        return jsonify({"error": "Missing attestation headers"}), 403

    # Fetch stored attestation data
    attestation_data = redis_client.hgetall(f"attestation:{key_id}")
    if not attestation_data:
        return jsonify({"error": "Unknown key ID"}), 403

    try:
        # Verify the assertion
        result = verify_assertion(
            assertion_base64=assertion_b64,
            client_data=request.get_data(),
            stored_public_key=base64.b64decode(attestation_data[b"public_key"]),
            stored_counter=int(attestation_data[b"counter"]),
            team_id=TEAM_ID,
            bundle_id=BUNDLE_ID
        )

        # Update the counter
        redis_client.hset(f"attestation:{key_id}", "counter", result["new_counter"])

        # Process the request normally
        return process_transfer(request.json)

    except ValueError as e:
        return jsonify({"error": f"Attestation failed: {e}"}), 403

Part 3: DeviceCheck - tracking device state

When should you use DeviceCheck?

DeviceCheck (2-bit flags) is simpler than App Attest and is suitable for:

  • Tracking whether a user has used a trial,
  • Flagging devices suspected of fraud,
  • Simple per-device rate limiting,
  • Detecting app reinstalls,

Client-side implementation

import DeviceCheck

/// DeviceCheck manager
actor DeviceCheckManager {

    static let shared = DeviceCheckManager()

    private let device = DCDevice.current

    /// Checks whether DeviceCheck is available
    var isSupported: Bool {
        device.isSupported
    }

    /// Generates a token to send to the backend
    func generateToken() async throws -> Data {
        guard isSupported else {
            throw DeviceCheckError.notSupported
        }

        return try await device.generateToken()
    }
}

enum DeviceCheckError: Error {
    case notSupported
}

Server-side implementation

# devicecheck.py
import jwt
import time
import requests
from cryptography.hazmat.primitives import serialization

# Configuration - download from Apple Developer Portal
KEY_ID = "ABC123DEF4"
TEAM_ID = "ABCDE12345"
PRIVATE_KEY_PATH = "AuthKey_ABC123DEF4.p8"

# Apple endpoints
APPLE_DEVICECHECK_URL = "https://api.devicecheck.apple.com/v1"
# For development: https://api.development.devicecheck.apple.com/v1


def generate_jwt_token() -> str:
    """Generates a JWT token for authorization with Apple."""
    with open(PRIVATE_KEY_PATH, "rb") as f:
        private_key = f.read()

    now = int(time.time())

    payload = {
        "iss": TEAM_ID,
        "iat": now,
        "exp": now + 3600  # 1 hour
    }

    headers = {
        "kid": KEY_ID,
        "alg": "ES256"
    }

    return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)


def query_two_bits(device_token: str) -> dict:
    """Fetches the 2 bits for a device."""
    jwt_token = generate_jwt_token()

    response = requests.post(
        f"{APPLE_DEVICECHECK_URL}/query_two_bits",
        json={
            "device_token": device_token,
            "transaction_id": str(uuid.uuid4()),
            "timestamp": int(time.time() * 1000)
        },
        headers={
            "Authorization": f"Bearer {jwt_token}",
            "Content-Type": "application/json"
        }
    )

    if response.status_code == 200:
        return response.json()
    elif response.status_code == 200 and response.text == "":
        # The bits have never been set
        return {"bit0": False, "bit1": False, "last_update_time": None}
    else:
        raise Exception(f"DeviceCheck error: {response.status_code}")


def update_two_bits(device_token: str, bit0: bool, bit1: bool) -> bool:
    """Updates the 2 bits for a device."""
    jwt_token = generate_jwt_token()

    response = requests.post(
        f"{APPLE_DEVICECHECK_URL}/update_two_bits",
        json={
            "device_token": device_token,
            "transaction_id": str(uuid.uuid4()),
            "timestamp": int(time.time() * 1000),
            "bit0": bit0,
            "bit1": bit1
        },
        headers={
            "Authorization": f"Bearer {jwt_token}",
            "Content-Type": "application/json"
        }
    )

    return response.status_code == 200

Example uses for the 2 bits

# Bit strategy for trial management
#
# bit0 | bit1 | Meaning
# -----|------|----------
#  0   |  0   | New device, has never used a trial
#  1   |  0   | Trial active
#  1   |  1   | Trial used up
#  0   |  1   | Banned device

def check_trial_eligibility(device_token: str) -> str:
    """Checks whether the device can use a trial."""
    try:
        bits = query_two_bits(device_token)

        bit0 = bits.get("bit0", False)
        bit1 = bits.get("bit1", False)

        if bit0 == False and bit1 == False:
            return "eligible"  # Can use a trial
        elif bit0 == True and bit1 == False:
            return "active"    # Trial is active
        elif bit0 == True and bit1 == True:
            return "expired"   # Trial already used
        else:  # bit0=False, bit1=True
            return "banned"    # Banned device

    except Exception as e:
        # Fallback - allow the trial with extra verification
        return "unknown"


def start_trial(device_token: str) -> bool:
    """Starts a trial for the device."""
    return update_two_bits(device_token, bit0=True, bit1=False)


def end_trial(device_token: str) -> bool:
    """Ends a trial for the device."""
    return update_two_bits(device_token, bit0=True, bit1=True)


def ban_device(device_token: str) -> bool:
    """Bans the device."""
    return update_two_bits(device_token, bit0=False, bit1=True)

The attacker’s perspective: how they try to bypass App Attest

AttackDescriptionDefenseDifficulty (2026 with MIE)
Replay attackCapturing a valid assertion and reusing itCounter in authenticatorData - the backend rejects any counter <= the stored oneEasy to block
Token farmingUsing many real devices to generate tokens for resaleDeviceCheck flagging, per-device rate limiting, behavioral analysis (many attestations from one IP)Medium - requires many devices
Proxying through a real deviceRouting requests through a real device with a real appBinding the assertion to the payload (hash), short-lived challenges, IP/geolocation verificationMedium - requires infrastructure
Jailbreak + FridaHooking DCAppAttestService on a jailbroken deviceKeys in the Secure Enclave (non-exportable), Apple detects tampering, client-side detection as an additional layerVery difficult with MIE
Emulation/SimulationEmulating an iOS deviceApp Attest requires the Secure Enclave - impossible to emulatePractically impossible

Memory Integrity Enforcement in iOS 26 makes attacks that require jailbreaks (Frida, Objection, hooking) significantly harder. The era of public physical jailbreaks is effectively coming to an end, which strengthens the effectiveness of App Attest.


Integration with the previous layers

Full security stack

/// Complete secure request with all layers
final class DefenseInDepthClient {

    private let attestManager = AppAttestManager.shared
    private let pinStore: DynamicPinStore
    private let requestSigner: RequestSigner

    func performSecureRequest<T: Decodable>(
        endpoint: String,
        body: Encodable
    ) async throws -> T {

        // 1. App Attest - make sure we are attested
        try await attestManager.performAttestation()

        // 2. Prepare the request
        let url = baseURL.appendingPathComponent(endpoint)
        var request = URLRequest(url: url)
        request.httpMethod = "POST"

        let bodyData = try JSONEncoder().encode(body)
        request.httpBody = bodyData

        // 3. Request Signing - sign the request
        let signedRequest = try await requestSigner.sign(request)

        // 4. App Attest Assertion - add the assertion
        let assertion = try await attestManager.generateAssertion(for: bodyData)
        signedRequest.setValue(
            assertion.base64EncodedString(),
            forHTTPHeaderField: "X-App-Assertion"
        )

        // 5. SSL Pinning - perform through a pinned session
        let delegate = DynamicSSLPinningDelegate(pinStore: pinStore)
        let session = URLSession(
            configuration: .default,
            delegate: delegate,
            delegateQueue: nil
        )

        let (data, _) = try await session.data(for: signedRequest)

        return try JSONDecoder().decode(T.self, from: data)
    }
}

Security layers diagram

Diagram

Ready-made solutions vs DIY

If implementing App Attest seems complex, you have options:

Free / Open Source:

  • Firebase App Check - a wrapper around App Attest with a simple API (iOS 14+), with automatic fallback to DeviceCheck (iOS 11–13),
  • apple-appattest (Swift package) - a server-side library in Swift,
  • devicecheck-appattest (Kotlin) - a server-side library for the JVM,

Paid (full defense-in-depth):

  • Approov - App Attest + additional runtime protection,
  • Appdome - no-code integration, anti-Frida,
  • Guardsquare - obfuscation + attestation + monitoring,
SolutionPriceApp AttestDeviceCheckCross-platformEffortNotes
DIY (this article)FreeYESYESNOHighFull control
Firebase App CheckFreeYES (iOS 14+)YES (fallback)YES Android (Play Integrity)MediumAuto-fallback on older iOS
Approov$$$YESYESYESLowRuntime protection

Summary: your App Attest checklist

Client-side:

  • You check isSupported before using App Attest,
  • You have graceful degradation for devices without support,
  • keyId is stored in the Keychain (not UserDefaults),
  • Attestation is performed once, assertions for every sensitive request,
  • You handle errors (no network, rate limiting, invalid key),
  • You have a reset mechanism (after logout, on errors),

Server-side:

  • You verify the full certificate chain (Apple Root CA),
  • You check the nonce/challenge (one-time use),
  • App ID validation (team ID + bundle ID),
  • The counter is checked and updated,
  • The public key is stored securely,
  • Rate limiting on attestations,

Operations:

  • Monitoring attestation errors (dashboard + alerts),
  • Gradual rollout for large apps,
  • Fallback plan for devices without App Attest,
  • Testing on real devices (not the simulator).

Series summary

Across three articles, we built a complete security stack for iOS-to-backend communication:

ArticleLayerWhat it protects
Part 1Request Signing + Quantum TLSRequest integrity, transport encryption, protection against "harvest now, decrypt later"
Part 2Dynamic SSL PinningMITM via fake certificates
Part 3App Attest + DeviceCheckApp modification, fraud, abuse

Key takeaways:

  1. Defense-in-depth - no single layer is sufficient,
  2. Server-side verification - do not trust client-side,
  3. Graceful degradation - not all users have the latest devices,
  4. Monitoring - without visibility, you do not know you are under attack.

I hope this series helps you build more secure applications. If you have questions, you can find me on LinkedIn.

Rafał Dubiel

Rafał Dubiel

Senior iOS Developer with 8+ years of experience building mobile applications. Passionate about clean architecture, Swift, and sharing knowledge with the community.

Share this article: