Secure iOS App Communication with the Backend – Part 3: App Attest and 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:
| Mechanism | Introduced | Purpose | How it works |
|---|---|---|---|
| DeviceCheck (2-bit flags) | iOS 11 | Tracking device state | 2 bits per device, stored on Apple servers |
| App Attest | iOS 14 | Verifying app authenticity | Cryptographic 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:
- A request comes from an unmodified version of your app,
- The app is running on a real device (not a simulator),
- 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 case | Recommendation |
|---|---|
| Games with in-app purchases | Protects against modifying currency/item values |
| Fintech / payments | Verification of critical transactions |
| Apps with premium content | Prevents unlocking without payment |
| Loyalty programs | Protects against farming points |
| Social media | Consider it for sensitive operations |
| Content apps | Probably overkill |
App Attest limitations (as of 2026)
Before you implement it, you need to know the limitations:
-
It does not detect jailbreak - App Attest verifies the app, not the device. A jailbroken device may still pass attestation.
-
It does not protect against all attacks - sophisticated attackers may still attempt replay attacks or manipulate outcomes.
-
It requires a backend - all verification must happen server-side. Client-side validation is useless.
-
Rate limiting - Apple limits the number of attestations (
DCError.serverUnavailablewhen 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). HandleDCError.rateLimited- do not block the user, use a fallback. -
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.
-
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:
- Decoding CBOR,
- Validating the certificate chain (Apple Root CA → Intermediate → Credential),
- Checking the nonce (challenge),
- 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
| Attack | Description | Defense | Difficulty (2026 with MIE) |
|---|---|---|---|
| Replay attack | Capturing a valid assertion and reusing it | Counter in authenticatorData - the backend rejects any counter <= the stored one | Easy to block |
| Token farming | Using many real devices to generate tokens for resale | DeviceCheck flagging, per-device rate limiting, behavioral analysis (many attestations from one IP) | Medium - requires many devices |
| Proxying through a real device | Routing requests through a real device with a real app | Binding the assertion to the payload (hash), short-lived challenges, IP/geolocation verification | Medium - requires infrastructure |
| Jailbreak + Frida | Hooking DCAppAttestService on a jailbroken device | Keys in the Secure Enclave (non-exportable), Apple detects tampering, client-side detection as an additional layer | Very difficult with MIE |
| Emulation/Simulation | Emulating an iOS device | App Attest requires the Secure Enclave - impossible to emulate | Practically 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
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,
| Solution | Price | App Attest | DeviceCheck | Cross-platform | Effort | Notes |
|---|---|---|---|---|---|---|
| DIY (this article) | Free | YES | YES | NO | High | Full control |
| Firebase App Check | Free | YES (iOS 14+) | YES (fallback) | YES Android (Play Integrity) | Medium | Auto-fallback on older iOS |
| Approov | $$$ | YES | YES | YES | Low | Runtime protection |
Summary: your App Attest checklist
Client-side:
- You check
isSupportedbefore using App Attest, - You have graceful degradation for devices without support,
keyIdis 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:
| Article | Layer | What it protects |
|---|---|---|
| Part 1 | Request Signing + Quantum TLS | Request integrity, transport encryption, protection against "harvest now, decrypt later" |
| Part 2 | Dynamic SSL Pinning | MITM via fake certificates |
| Part 3 | App Attest + DeviceCheck | App modification, fraud, abuse |
Key takeaways:
- Defense-in-depth - no single layer is sufficient,
- Server-side verification - do not trust client-side,
- Graceful degradation - not all users have the latest devices,
- 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
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: