- Published on
- 6 min read
> App Tracking Transparency in SwiftUI
If your app does any kind of cross-app or cross-website tracking, or even just reads the IDFA for an ad network, iOS requires you to go through App Tracking Transparency first. ATT is the system-level permission that gates access to the advertising identifier and any tracking that links a user's activity across apps and websites owned by different companies. Without it, ASIdentifierManager.shared().advertisingIdentifier returns a zeroed UUID and any SDK relying on it quietly stops working.
The framework itself, AppTrackingTransparency, is small. The tricky parts are the Info.plist copy, the one-shot nature of the system prompt, and picking the right moment to ask.
Info.plist: NSUserTrackingUsageDescription
Before you can call the tracking API, your app needs NSUserTrackingUsageDescription in Info.plist. This string is shown directly inside the system alert. If the key is missing, the request call crashes the app the first time you hit it.
<key>NSUserTrackingUsageDescription</key>
<string>We use this identifier to show you ads that match the topics you care about and to measure which ads actually work, so we can show you fewer and better ones.</string>
The copy here really matters. Apple's own guidance is to explain what the user gets out of it, not just what you want. Vague lines like "Allow tracking to improve your experience" tend to get hammered by the Deny button. A concrete value exchange ("more relevant recommendations", "keep the app free") tends to do much better.
Requesting Authorization
The ATTrackingManager class has two variants of the same method: a completion-handler version and an async one. In a modern SwiftUI codebase you almost always want the async call.
import AppTrackingTransparency
import AdSupport
func requestTrackingPermission() async -> ATTrackingManager.AuthorizationStatus {
let status = await ATTrackingManager.requestTrackingAuthorization()
return status
}
There's nothing to throw here. The system hands you back one of four cases from AuthorizationStatus:
.notDetermined: the user hasn't been asked yet. CallingrequestTrackingAuthorizationwill show the prompt..restricted: the device is under a restriction like parental controls or MDM. You cannot prompt and the user cannot change it in Settings from your app's side..denied: the user explicitly tapped "Ask App Not to Track", or tracking was disabled later in Settings..authorized: the user tapped "Allow". Only in this case does the IDFA return a real value.
Reading the IDFA
Once (and only once) you have .authorized, the advertising identifier is available:
import AdSupport
import AppTrackingTransparency
func currentIDFA() -> UUID? {
guard ATTrackingManager.trackingAuthorizationStatus == .authorized else {
return nil
}
return ASIdentifierManager.shared().advertisingIdentifier
}
In any other state, advertisingIdentifier returns 00000000-0000-0000-0000-000000000000. It's worth gating explicitly on the status rather than inspecting the UUID, because a zeroed value is not an error condition you want to pass to an ad SDK by accident.
When to Prompt
This is where most apps get ATT wrong. The rules are simple but strict:
The prompt is one-shot. If the user selects "Ask App Not to Track", .denied is sticky. You cannot call requestTrackingAuthorization again and get another chance. The user has to go to Settings > Privacy & Security > Tracking and toggle your app back on, which the overwhelming majority will never do.
Because of that, do not prompt on launch. A cold app that fires the system alert before the user has seen a single screen is basically guaranteed a denial. Prompt at a moment where the user has already seen value and the ask makes sense. An e-commerce app might wait until after the first add-to-cart. A news app might wait until after the third article. A game might wait until the first level ends.
The other common pattern is a pre-prompt: your own SwiftUI screen that explains, in friendly language, why tracking helps and what the user gets in return, with a single "Continue" button that then triggers the real system prompt. Because you control that UI, you can prompt for the pre-prompt as many times as you want. But once you call requestTrackingAuthorization, that's your shot.
SwiftUI Integration
The cleanest place to request authorization in SwiftUI is the .task modifier on the screen where the ask makes sense. .task fires once when the view appears and is async-native, which matches the ATT API perfectly.
import SwiftUI
import AppTrackingTransparency
import AdSupport
struct PersonalizedFeedView: View {
@State private var trackingStatus: ATTrackingManager.AuthorizationStatus = .notDetermined
@State private var showingPrePrompt = false
var body: some View {
VStack(spacing: 20) {
Text("Your Feed")
.font(.largeTitle)
// Rest of the feed content goes here.
}
.task {
trackingStatus = ATTrackingManager.trackingAuthorizationStatus
if trackingStatus == .notDetermined {
showingPrePrompt = true
}
}
.sheet(isPresented: $showingPrePrompt) {
TrackingPrePromptView {
Task {
trackingStatus = await ATTrackingManager.requestTrackingAuthorization()
showingPrePrompt = false
}
}
}
}
}
struct TrackingPrePromptView: View {
let onContinue: () -> Void
var body: some View {
VStack(spacing: 24) {
Image(systemName: "sparkles")
.font(.system(size: 56))
.foregroundStyle(.tint)
Text("Make your feed yours")
.font(.title.bold())
Text("Allowing tracking lets us tune recommendations to what you actually read, and keeps the app free. You can change this at any time in Settings.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Button("Continue", action: onContinue)
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(32)
}
}
Notice that we check trackingAuthorizationStatus first. Reading the current status does not trigger the system prompt and does not count as "the ask". If the user is already .authorized, .denied, or .restricted, there's no point showing your pre-prompt.
Handling Denial Gracefully
If the user denies, the right move is to not nag. You can detect denial and offer a settings link later, gated on some appropriate trigger like a toggle in your privacy preferences screen, but the system prompt itself is gone for good.
func openTrackingSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
openSettingsURLString opens your app's page in Settings, where the user can flip tracking back on if they feel like it. Treat it as a "just in case" escape hatch and not part of the main flow.
ATT is one of those APIs where the code is almost the easy part. The real work is being honest about what you'll do with the identifier, writing Info.plist copy that reflects that, and holding off on the prompt until the user actually has context for it. Get those three right and the opt-in rate usually takes care of itself.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
Deep Linking and Universal Links Setup in iOS
How to set up custom URL schemes and Universal Links in iOS, handle incoming URLs in SwiftUI, and route users to the right screen.
Scheduling Local Notifications with UNUserNotificationCenter
A practical guide to scheduling and handling local notifications in iOS using UNUserNotificationCenter, from permission requests to actionable notification categories.
The Difference Between Frame and Bounds in UIKit
Understand the key difference between a UIView's frame and bounds properties, and when to use each in your iOS apps.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.