iubenda mobile SDK – iOS – Working with App Tracking Transparency (ATT)

Important

All ATT lifecycle handling belongs to the host app. automaticHandlingOfAtt is being deprecated and will be removed from the SDK. Keep it disabled and use the fully manual flow described on this page. Never show the ATT system alert and the iubenda consent layer at the same time, or one on top of the other.

App Tracking Transparency (ATT) and the iubenda CMP consent flow are separate systems that you need to coordinate.

  • ATT is Apple’s permission for cross-app and cross-website tracking, including access to the IDFA.
  • The iubenda CMP collects the choices privacy laws require for purposes such as analytics, personalization, measurement, advertising, and app functionality.

An ATT denial does not replace the CMP flow. A user who selects Ask App Not to Track may still need to choose whether other SDKs and processing activities are allowed under applicable privacy law.

Do not initialize advertising or analytics SDKs before the required ATT and CMP choices are available. Initialize the iubenda CMP itself at launch, but present its consent UI only after ATT has been handled and the user’s ATT choice is available.

Step 1: Prerequisites

  • The iubenda CMP supports iOS 12 and later.
  • ATT is available on iOS 14 and later.
  • Add AppTrackingTransparency.framework to the app target.
  • Add NSUserTrackingUsageDescription to the app’s Info.plist.
<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalized ads and improve your experience.</string>

The text must accurately explain how your app uses tracking data. Apple may reject vague or misleading wording.

Import the required modules:

import UIKit
import AppTrackingTransparency
import iubenda

Initialize the iubenda CMP during app startup:

let config = IubendaCMPConfiguration()
config.gdprEnabled = true
config.siteId = "YOUR_SITE_ID"
config.cookiePolicyId = "YOUR_COOKIE_POLICY_ID"
config.jsonFile = Bundle.main.path(forResource: "config", ofType: "json")
config.automaticHandlingOfAtt = false

IubendaCMP.initialize(with: config)

automaticHandlingOfAtt defaults to false. Do not enable it. The app must request ATT, process every result, and then launch the CMP.

Step 2: Check ATT status

Check the current ATT status before initializing any SDK that performs tracking:

private enum AppATTStatus: Equatable {
    case notApplicable
    case notDetermined
    case restricted
    case denied
    case authorized
}

private func currentATTStatus() -> AppATTStatus {
    guard #available(iOS 14, *) else {
        return .notApplicable
    }

    return mapATTStatus(ATTrackingManager.trackingAuthorizationStatus)
}

@available(iOS 14, *)
private func mapATTStatus(
    _ status: ATTrackingManager.AuthorizationStatus
) -> AppATTStatus {
    switch status {
    case .notDetermined:
        return .notDetermined
    case .restricted:
        return .restricted
    case .denied:
        return .denied
    case .authorized:
        return .authorized
    @unknown default:
        return .denied
    }
}

The native ATT statuses are:

  • .authorized: tracking and IDFA access are permitted, subject to the user’s CMP choices.
  • .denied: tracking is not permitted.
  • .restricted: tracking is unavailable, for example because of parental controls or device management.
  • .notDetermined: the app has not requested ATT permission yet.
Heads up

Use ATTrackingManager directly. Do not use the SDK’s requestAttConsent(from:callback:), getATTStatus(from:callback:), or automaticHandlingOfAtt. These APIs are being deprecated as ATT responsibility moves entirely to the host app.

Step 3: Request ATT authorization

Request ATT only while the app is active and only when the status is .notDetermined:

private func startPrivacyFlow() {
    guard UIApplication.shared.applicationState == .active else {
        return
    }

    guard !didStartPrivacyFlow else {
        return
    }

    didStartPrivacyFlow = true

    guard #available(iOS 14, *) else {
        openCMP(attStatus: .notApplicable)
        return
    }

    let status = ATTrackingManager.trackingAuthorizationStatus

    guard status == .notDetermined else {
        handleATTStatus(mapATTStatus(status))
        return
    }

    ATTrackingManager.requestTrackingAuthorization { [weak self] status in
        DispatchQueue.main.async {
            guard let self = self else {
                return
            }

            self.handleATTStatus(self.mapATTStatus(status))
        }
    }
}

Call startPrivacyFlow() from a visible view controller and retry when the app becomes active. The method guards against repeated execution:

private var didStartPrivacyFlow = false

override func viewDidLoad() {
    super.viewDidLoad()

    observeConsentChanges()
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(appDidBecomeActive),
        name: UIApplication.didBecomeActiveNotification,
        object: nil
    )
}

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    startPrivacyFlow()
}

@objc private func appDidBecomeActive() {
    startPrivacyFlow()
}

Step 4: Handle the ATT result

Route every resolved ATT state to the CMP flow. ATT denial must block tracking, but it must not be treated as a rejection of every independent CMP purpose:

private func handleATTStatus(_ status: AppATTStatus) {
    switch status {
    case .authorized:
        openCMP(attStatus: status)

    case .denied, .restricted:
        stopTrackingSDKs()
        // Decide before any background setStorePreferences call. A successful background
        // save can mark a preference as stored and make needsCMPConsent() false afterward.
        let shouldOpenCMP = needsCMPConsent()

        // Never open the CMP only inside the setStorePreferences callback. That callback
        // depends on a headless WebView and may not run until a CMP layer is shown (or at all
        // on failure). Run optional purpose mapping in parallel; open the CMP from here.
        rejectAdvertisingPurposesIfRequired { [weak self] in
            guard let self = self else { return }
            self.applySDKPermissions(attStatus: status)
        }

        if shouldOpenCMP {
            IubendaCMP.openPreferences(from: self)
        } else {
            applySDKPermissions(attStatus: status)
        }

    case .notDetermined:
        // Do not initialize trackers. Retry only when the app is active.
        break

    case .notApplicable:
        openCMP(attStatus: status)
    }
}

Step 5: Launch the iubenda CMP

Use IubendaCMP.askConsent(from:) for the normal consent flow.

Current SDK behavior makes askConsent(from:) a no-op while the native ATT status is .denied or .restricted. In those states, use IubendaCMP.openPreferences(from:) when a valid CMP preference is not already stored:

private func needsCMPConsent() -> Bool {
    let hasStoredPreference = IubendaCMP.canOpenPreferences()
        || IubendaCMP.isPreferenceExpressed()

    return !hasStoredPreference
        || IubendaCMP.shouldGetConsentForInvalidateConsentBefore()
        || IubendaCMP.shouldGetConsentForInvalidateConsentInterval()
        || IubendaCMP.shouldGetConsentForGoogleAdditionalConsentMode()
}

private func openCMP(
    attStatus: AppATTStatus
) {
    guard needsCMPConsent() else {
        applySDKPermissions(attStatus: attStatus)
        return
    }

    if attStatus == .denied || attStatus == .restricted {
        IubendaCMP.openPreferences(from: self)
    } else {
        IubendaCMP.askConsent(from: self)
    }
}

Unlike some CMP SDKs, this SDK has no setATTStatus property. Pass the native ATT status through your own privacy coordinator and combine it with CMP preferences when deciding which SDKs to enable.

Step 6: ATT and CMP decision table

ATT status IDFA CMP behavior SDK behavior
.notDetermined No Wait for ATT to resolve, then open the CMP. Do not initialize trackers.
.authorized Yes Collect granular CMP choices with askConsent(from:). Enable a tracker only when its CMP purposes are also allowed.
.denied No If needed, collect CMP choices with openPreferences(from:). Block cross-app tracking and IDFA use. Evaluate non-tracking SDKs from CMP choices.
.restricted No Same CMP handling as .denied. Block cross-app tracking and IDFA use.
iOS earlier than 14 N/A Skip ATT and use the normal CMP flow. Gate SDKs using CMP choices.

ATT authorization is necessary but not sufficient for tracking. For example, .authorized does not allow an analytics or advertising SDK when the corresponding CMP purpose was rejected.

Step 7: ATT-aware API recipes

Reject configured advertising purposes after ATT denial (optional)

Recommended default: do not map ATT denial to CMP purposes. Block IDFA and tracking SDKs in your app when ATT is .denied or .restricted, and collect legal CMP choices through openPreferences(from:) when needed. ATT and CMP cover different scopes.

Only enable purpose mapping when legal, product, and CMP configuration explicitly require it.

Do not use this on a fresh install

setStorePreferences persists consent in a background WebView. On first launch, getStorePreferences() can carry default fields (for example an empty tcfv2 string) that must not be sent back with a partial purposes map. Doing so can store a full TCF choice without the user ever seeing the CMP, so needsCMPConsent() becomes false and the banner is skipped.

Apply purpose mapping only when the user already has stored CMP data:

private func shouldApplyPurposeRejectionOnATTDenial() -> Bool {
    IubendaCMP.isPreferenceExpressed() || IubendaCMP.canOpenPreferences()
}

Build a minimal payload. Do not start from getStorePreferences() on first launch, and omit tcfv2 and gac unless you intentionally set them:

private let advertisingPurposeIDs = [4, 5] // Example IDs only - use your configuration

/// Set to true only when legal/product requires mapping ATT denial to CMP purposes.
private let mapATTDenialToAdvertisingPurposes = false

private func buildRejectionOnlyStorePreferences() -> StorePreferences {
    let preferences = StorePreferences()
    preferences.consent = false
    preferences.tcfv2 = nil
    preferences.gac = nil
    var purposes: [String: Bool] = [:]
    for purposeID in advertisingPurposeIDs {
        purposes[String(purposeID)] = false
    }
    preferences.purposes = purposes
    return preferences
}

private func rejectAdvertisingPurposesIfRequired(
    completion: @escaping () -> Void
) {
    guard mapATTDenialToAdvertisingPurposes,
          shouldApplyPurposeRejectionOnATTDenial() else {
        completion()
        return
    }

    // setStorePreferences uses a headless WebView. The callback may be delayed or never
    // called if the WebView fails (network, WebKit). Do not block the privacy flow on it.
    var didCallCompletion = false
    let finish: () -> Void = {
        guard !didCallCompletion else { return }
        didCallCompletion = true
        DispatchQueue.main.async { completion() }
    }

    DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: finish)

    IubendaCMP.setStorePreferences(
        storePreferences: buildRejectionOnlyStorePreferences()
    ) { _ in
        finish()
    }
}

Purpose IDs are configuration-specific. The SDK has no rejectPurposes(_:updateVendor:) equivalent, and this recipe does not update vendor choices.

Wire rejectAdvertisingPurposesIfRequired as in Step 4: optional background work in parallel with opening the CMP, not as a gate before openPreferences(from:).

Optional pre-grant after ATT authorization

Do not treat ATT authorization as legal consent by default. Only pre-grant configured purposes if your legal basis, CMP configuration, and user flow explicitly permit it.

Use the same minimal StorePreferences builder pattern (explicit purpose map, and do not rely on getStorePreferences() defaults on first launch). Do not call openCMP(attStatus:) only inside the setStorePreferences callback. Use the same bounded finish() pattern as above, or open the CMP from Step 4 without waiting on the callback.

React to CMP choices

Listen for CMP changes:

private func observeConsentChanges() {
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(consentDidChange),
        name: NSNotification.Name.ConsentChanged,
        object: nil
    )
}

@objc private func consentDidChange() {
    applySDKPermissions(attStatus: currentATTStatus())
}

Register this observer before starting the privacy flow, as shown in viewDidLoad() in Step 3.

Combine ATT with the user’s CMP purpose choices:

private func applySDKPermissions(
    attStatus: AppATTStatus
) {
    let attAllowsTracking = attStatus == .authorized
        || attStatus == .notApplicable

    // Replace these IDs with purpose IDs from your iubenda configuration.
    let analyticsAllowed = IubendaCMP.isPurposeEnabled(id: 3)
    let advertisingAllowed = IubendaCMP.isPurposeEnabled(id: 4)

    if analyticsAllowed {
        initializeNonTrackingAnalyticsIfNeeded()
    } else {
        disableAnalytics()
    }

    if attAllowsTracking && advertisingAllowed {
        initializeAdvertisingSDKIfNeeded()
    } else {
        disableAdvertisingAndIDFAUse()
    }
}

Purpose IDs are configuration-specific. Do not copy the example IDs without checking your iubenda setup.

Do not automatically map ATT denial to rejection of every advertising, analytics, or vendor preference. ATT and CMP choices cover different scopes.

Step 8: Re-check ATT when the app resumes

Users can change ATT permission in Settings. Re-check it whenever the app becomes active:

func applicationDidBecomeActive(_ application: UIApplication) {
    applySDKPermissions(attStatus: currentATTStatus())
}

For scene-based apps, perform the same check in the scene delegate:

func sceneDidBecomeActive(_ scene: UIScene) {
    applySDKPermissions(attStatus: currentATTStatus())
}

Do not call requestTrackingAuthorization repeatedly. iOS displays the system alert at most once per installation, and later changes happen in Settings.

Read ATTrackingManager.trackingAuthorizationStatus directly. Do not use the deprecated SDK ATT helpers to request or re-check status.

When the user revokes ATT in Settings, update trackers with applySDKPermissions only. Do not clear existing CMP choices solely because ATT changed (see Step 9). If you use the optional purpose mapping from Step 7, a pending setStorePreferences operation may remain queued until a CMP WebView completes. Prefer skipping background purpose mapping on resume, or present openPreferences(from:) once if you must flush a queued store operation.

Step 9: Edge cases and fallbacks

  • Restricted: treat tracking as unavailable, but continue the CMP flow for independent purposes.
  • iOS earlier than 14: skip ATT and open the CMP.
  • App inactive: wait until the app is active before requesting ATT.
  • ATT denied: never access IDFA or initialize SDK features that perform tracking.
  • CMP rejected: keep affected SDKs disabled even when ATT is authorized.
  • No network: askConsent(from:) may not show the CMP when skipNoticeWhenOffline is enabled. Retry when connectivity returns.
  • Existing users: do not erase a user’s CMP choices solely because ATT is denied. Re-evaluate trackers from both current states.
  • Unknown future ATT status: fail closed for tracking and continue collecting any independently required CMP choices.
  • setStorePreferences: runs in a background WebView and sets a pending task until consent is saved. Failures or WebKit issues can delay or prevent the callback, so do not chain presenting the CMP solely on that callback.
  • Fresh install and ATT denied: block tracking in app code, and use openPreferences(from:) when the CMP is required. Avoid Step 7 purpose mapping until the user has stored CMP preferences.

Step 10: Testing and debugging

  • Use a fresh installation to test the first ATT prompt.
  • Test .authorized, .denied, .restricted, and .notDetermined.
  • Test iOS earlier than 14 if your app still supports it.
  • Change ATT under Settings > Privacy & Security > Tracking.
  • Confirm the CMP never appears on top of the ATT alert.
  • Confirm no tracking SDK starts before both required choices resolve.
  • Confirm ATT denial does not prevent the CMP from collecting choices for non-tracking purposes.
  • Confirm CMP rejection keeps affected SDKs disabled even when ATT is authorized.
  • Confirm reopening the CMP with IubendaCMP.openPreferences(from:) updates SDK behavior.
  • Observe NSNotification.Name.ConsentChanged and log both ATT and CMP states during development.
  • Fresh install, ATT denied: deny ATT without Step 7 mapping, then confirm the CMP can still open when required and that no TCF or consent string is stored without user interaction.
  • Fresh install, ATT denied, with Step 7 mapping enabled: confirm you do not get a stored full TCF consent without the user seeing the CMP. Use this as a regression test if you enable mapping.
  • setStorePreferences callback: with airplane mode or a slow network, confirm the privacy flow still opens the CMP when Step 4 does not wait on the callback.
  • Settings ATT revoke: after .authorized, revoke tracking and confirm trackers stay off and existing CMP choices remain unless the user changes them in the CMP.

Resetting the simulator’s privacy settings or reinstalling the app may be required to return ATT to .notDetermined.

Step 11: Best practices

  • Never initialize trackers before both ATT and CMP requirements are satisfied.
  • Keep all ATT lifecycle handling in the host app.
  • Never overlap the ATT system alert and the iubenda CMP UI.
  • Use pre-prompts carefully and never mislead users.
  • Treat ATT authorization as necessary but not sufficient for tracking.
  • Do not infer CMP consent from ATT authorization or rejection.
  • Allow users to reopen CMP preferences at any time.
  • Re-check ATT when the app resumes.
  • Expect App Review rejection if tracking occurs before ATT permission.

Step 12: Developer checklist

  • Add AppTrackingTransparency.framework.
  • Add an accurate NSUserTrackingUsageDescription.
  • Initialize IubendaCMP without initializing tracking SDKs.
  • Keep automaticHandlingOfAtt = false, and do not use the deprecated SDK ATT helpers.
  • Request ATT only when the app is active and the status is .notDetermined.
  • Open the CMP only after ATT resolves.
  • Default: do not map ATT denial to CMP purposes. Gate tracking with ATT and legal choices with the CMP.
  • If required, reject only explicitly mapped advertising purposes after ATT denial, and only when stored CMP preferences already exist. Never use getStorePreferences() alone on first launch.
  • Do not present the CMP only inside a setStorePreferences completion handler.
  • Use openPreferences(from:) when CMP consent is needed while ATT is denied or restricted.
  • Observe NSNotification.Name.ConsentChanged.
  • Enable each SDK only when its ATT and CMP requirements are both satisfied.
  • Re-check ATT on app resume without clearing CMP preferences.
  • Provide a way to reopen CMP preferences.
  • Test every ATT and CMP combination.

References