How to implement App Tracking Transparency in SwiftUI
Add NSUserTrackingUsageDescription to your Info.plist,
then call ATTrackingManager.requestTrackingAuthorization(completionHandler:)
inside an .onReceive(NotificationCenter…) handler that fires when
the scene becomes active — the system presents the ATT prompt exactly once.
import AppTrackingTransparency
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.onReceive(
NotificationCenter.default.publisher(
for: UIApplication.didBecomeActiveNotification)
) { _ in
Task {
await ATTrackingManager
.requestTrackingAuthorization()
}
}
}
}
Full implementation
The implementation wraps ATT state in an @Observable class
so your entire SwiftUI hierarchy can react to changes in authorization status.
The prompt is triggered the first time the app transitions to the active state after install;
on subsequent launches ATTrackingManager.trackingAuthorizationStatus
returns the cached answer without re-prompting, so we read that synchronously on startup and
only call the async authorization request when the status is still notDetermined.
// TrackingManager.swift
import AppTrackingTransparency
import Observation
@Observable
final class TrackingManager {
// MARK: – Published state
var status: ATTrackingManager.AuthorizationStatus = .notDetermined
// Convenience booleans for UI bindings
var isAuthorized: Bool { status == .authorized }
var isDenied: Bool { status == .denied || status == .restricted }
// MARK: – Initialiser – read cached status immediately
init() {
status = ATTrackingManager.trackingAuthorizationStatus
}
// MARK: – Request (only shows OS prompt when status == .notDetermined)
@MainActor
func requestAuthorization() async {
guard status == .notDetermined else { return }
status = await ATTrackingManager.requestTrackingAuthorization()
}
}
// ContentView.swift
import SwiftUI
import AppTrackingTransparency
struct ContentView: View {
@Environment(TrackingManager.self) private var tracking
var body: some View {
NavigationStack {
VStack(spacing: 24) {
TrackingStatusBadge(status: tracking.status)
if tracking.isDenied {
VStack(spacing: 12) {
Text("Tracking permission denied.")
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Open Settings") {
guard let url = URL(string: UIApplication.openSettingsURLString)
else { return }
UIApplication.shared.open(url)
}
.buttonStyle(.borderedProminent)
}
}
}
.padding()
.navigationTitle("Privacy")
}
// Trigger on every foreground transition; requestAuthorization
// is a no-op once the status is no longer .notDetermined.
.onReceive(
NotificationCenter.default.publisher(
for: UIApplication.didBecomeActiveNotification)
) { _ in
Task {
await tracking.requestAuthorization()
}
}
}
}
// TrackingStatusBadge.swift
import SwiftUI
import AppTrackingTransparency
struct TrackingStatusBadge: View {
let status: ATTrackingManager.AuthorizationStatus
private var label: String {
switch status {
case .authorized: return "Authorized"
case .denied: return "Denied"
case .restricted: return "Restricted"
case .notDetermined: return "Not Determined"
@unknown default: return "Unknown"
}
}
private var color: Color {
switch status {
case .authorized: return .green
case .denied: return .red
case .restricted: return .orange
case .notDetermined: return .secondary
@unknown default: return .secondary
}
}
var body: some View {
Label(label, systemImage: "hand.raised.fill")
.foregroundStyle(color)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(color.opacity(0.12), in: Capsule())
.accessibilityLabel("Tracking status: \(label)")
}
}
// App entry point
import SwiftUI
@main
struct MyApp: App {
@State private var tracking = TrackingManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(tracking)
}
}
}
#Preview {
ContentView()
.environment(TrackingManager())
}
How it works
-
TrackingManagerreads the cached status on init.ATTrackingManager.trackingAuthorizationStatusis synchronous and returns the previously stored decision, so the UI renders with the correct state before the scene even becomes active. -
.onReceive(UIApplication.didBecomeActiveNotification)fires the request. Hooking into the active-state notification (rather thanonAppear) guarantees the ATT alert appears after the app's own UI is fully visible — Apple's HIG requirement — and re-fires harmlessly on subsequent launches becauserequestAuthorization()early-returns whenstatus != .notDetermined. -
@Observable TrackingManagerpropagates state app-wide. Injected via.environment(tracking)at the root, any child view that readstracking.isAuthorizedautomatically re-renders when the status changes — no extra@PublishedorObservableObjectboilerplate needed in iOS 17+. -
The "Open Settings" deep-link handles the denied case.
UIApplication.openSettingsURLStringtakes the user directly to your app's Privacy settings page, where they can flip the switch without leaving the flow. -
@unknown defaultfuture-proofs the switch statements. Apple may add newAuthorizationStatuscases in future OS releases; exhaustive@unknown defaultbranches prevent compiler warnings and unhandled crashes.
Variants
Delay the prompt until after onboarding
Show the ATT prompt after your own onboarding screen so users understand why you're asking before the OS dialog appears. Gate the request behind a boolean preference.
// In TrackingManager
var onboardingComplete: Bool {
get { UserDefaults.standard.bool(forKey: "onboardingComplete") }
set { UserDefaults.standard.set(newValue, forKey: "onboardingComplete") }
}
@MainActor
func requestIfReady() async {
guard onboardingComplete else { return }
await requestAuthorization()
}
// In your onboarding completion handler:
Button("Get Started") {
tracking.onboardingComplete = true
Task { await tracking.requestAuthorization() }
}
Check status before making an ad SDK call
Many ad SDKs (Google AdMob, Meta Audience Network) require you to pass the IDFA only when
status == .authorized. Wrap the SDK initialisation call inside a status check
so the SDK degrades gracefully to contextual ads on denial:
import AdSupport
func configureAdSDK(status: ATTrackingManager.AuthorizationStatus) {
let idfa = status == .authorized
? ASIdentifierManager.shared().advertisingIdentifier.uuidString
: "00000000-0000-0000-0000-000000000000" // zeroed-out IDFA
// Pass idfa to your ad SDK initialiser here
print("Configuring ads with IDFA:", idfa)
}
Common pitfalls
-
Missing
NSUserTrackingUsageDescriptionin Info.plist crashes at runtime. iOS raises an exception the moment you callrequestTrackingAuthorizationwithout this key. In Xcode 16 add it under Target → Info → Custom iOS Target Properties; or add it directly toInfo.plistas a String value. The text appears verbatim in the OS alert, so make it user-facing and clear (e.g. "We use this to show relevant ads and measure campaign performance."). -
Calling the prompt from
onAppearis rejected by App Review. Apple requires the ATT prompt to appear only after the app's UI is fully presented.onAppearon the root view fires before the transition animation completes. UseUIApplication.didBecomeActiveNotificationor add a shortTask { try? await Task.sleep(for: .seconds(0.5)) }buffer. -
The Simulator always returns
.authorized; test on a real device. To test the denied flow on a physical device, reset permissions via Settings → Privacy & Security → Tracking, or re-install the app. The system only shows the prompt once per install, so you cannot trigger it twice without resetting. -
Skipping
@unknown defaultcauses a compiler warning treated as error in strict mode. Swift 5.10 strict concurrency and exhaustive enums mean any switch onATTrackingManager.AuthorizationStatuswithout@unknown defaultwill warn. In new Xcode projects warnings-as-errors is enabled by default.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement app tracking transparency in SwiftUI for iOS 17+. Use AppTrackingTransparency and ATTrackingManager. Create an @Observable TrackingManager injected via .environment(). Trigger requestTrackingAuthorization() on UIApplication.didBecomeActiveNotification. Handle all ATTrackingManager.AuthorizationStatus cases including @unknown default. Show an "Open Settings" button in the denied state. Make it accessible (VoiceOver labels on the status badge). Add NSUserTrackingUsageDescription to Info.plist with a user-facing string. Add a #Preview with realistic sample data.
Drop this prompt into the Soarias Build phase after your screen scaffolding is generated — ATT wiring is a single-session task that slots cleanly between UI build-out and App Store submission prep.
Related
FAQ
Does App Tracking Transparency work on iOS 16?
The AppTrackingTransparency framework was introduced in iOS 14, so the framework itself
compiles and runs on iOS 16. However, this guide targets iOS 17+ because it uses the
@Observable macro (iOS 17+) and the await ATTrackingManager.requestTrackingAuthorization()
async/await overload added in iOS 16. If you need iOS 15 support, use the callback-based
requestTrackingAuthorization(completionHandler:) variant and replace
@Observable with ObservableObject + @Published.
Does my app need ATT even if I don't use third-party ad SDKs?
You need ATT any time your app (or a third-party SDK embedded in it) accesses
ASIdentifierManager.advertisingIdentifier (the IDFA), tracks users across
apps or websites you don't own, or links behavioral data to a third-party identity.
If you use analytics SDKs like Firebase, Mixpanel, or any ad network framework — even
for attribution only — you almost certainly need ATT. Apps that collect zero cross-app
data and don't read the IDFA can omit the entire flow, but you must still declare this
accurately in App Store Connect's privacy nutrition labels.
What is the UIKit equivalent of this SwiftUI approach?
In UIKit you call the same ATTrackingManager API, but trigger it from
applicationDidBecomeActive(_:) in your AppDelegate, or from
sceneDidBecomeActive(_:) in a UIWindowSceneDelegate.
The framework and status enum are identical; only the call site differs.
// UIKit – SceneDelegate.swift
import AppTrackingTransparency
func sceneDidBecomeActive(_ scene: UIScene) {
Task {
let status = await ATTrackingManager
.requestTrackingAuthorization()
print("ATT status:", status)
}
}
Last reviewed: 2026-05-11 by the Soarias team.