ATT Is Where iOS Submissions Quietly Get Stuck
You finish your Rork app, push it to TestFlight, submit to the App Store, and a few days later get a rejection note that says your App Tracking Transparency disclosure is incomplete. I have lived through this loop three times. The code itself was fine — what tripped me up was the combination of three things: the wording in Info.plist, the moment the prompt appears, and how the app behaves when the user taps "Don't Allow."
ATT became mandatory in iOS 14.5. If your app reads the IDFA or shares any identifier that lets you track a user across other apps and websites, Apple now requires explicit, in-app consent. The moment you drop in AppsFlyer, Adjust, AdMob, Meta Audience Network, or any similar SDK, you are in scope — even if you never directly read the advertising identifier yourself.
This guide walks through implementing ATT in a Rork-based app (React Native + Expo) so it passes review on the first try. We will cover not just the code with expo-tracking-transparency, but the wording, timing, and fallback strategy that reviewers actually scrutinize. By the end you should be able to ship a build with ATT plumbing that survives both an automated pre-check and a human reviewer.
Three Common Cases Where You Need ATT
Before writing any code, confirm whether ATT applies to your app. You need it if any of the following is true.
- You use an ad SDK: AdMob, Meta Audience Network, Unity Ads, AppLovin, and basically every monetization network read or attempt to read the IDFA
- You use an attribution SDK: AppsFlyer, Adjust, Branch, Singular, or anything that tracks where installs came from across other apps and the web
- You use analytics that reads IDFA: Firebase Analytics with the advertising ID enabled, certain Mixpanel configurations, or any setup where you ship the IDFA to a remote analytics service
If your app only uses an internally generated UUID stored on your own server and never touches IDFA or shares identifiers with third parties, you do not need to show the ATT prompt. That said, Apple inspects edge cases carefully, so always check each SDK's documentation for whether it accesses IDFA. A good test: scan your Podfile.lock (or the iOS build output) for any SDK whose name appears in your privacy nutrition label as collecting "identifiers." If something is listed there, ATT almost certainly applies.
Step 1: Add the Disclosure String to Info.plist
In a Rork project, edit app.json (or app.config.js) and set ios.infoPlist.NSUserTrackingUsageDescription.
{
"expo": {
"ios": {
"bundleIdentifier": "com.example.myapp",
"infoPlist": {
"NSUserTrackingUsageDescription": "Allow this app to use activity data so we can show ads tailored to you and keep the app free. You can keep using every feature even if you decline."
}
}
}
}The wording matters more than most developers think. Reviewers look for three things.
- A specific reason: "For ads" or "for analytics" alone is too vague. State why the user benefits — ideally that the app stays free
- A clear statement that decline does not break the app: Wording that implies tracking is required gets rejected, because Apple's guideline explicitly forbids gating features behind ATT consent
- No misleading framing: "To improve our service" alone is too soft when the SDK actually reads the advertising ID. Reviewers will compare your disclosure string against the privacy nutrition label
The pattern that has consistently passed for me always names ads, free usage, and full functionality on decline. Keep the string under about 150 characters so it fits comfortably in the system dialog without truncation on smaller devices.
Step 2: Trigger the Prompt with expo-tracking-transparency
Rork projects can pull in Expo modules, so expo-tracking-transparency is the safest path. Add a small helper near your app's entry point.
// app/_layout.tsx or similar
import {
getTrackingPermissionsAsync,
requestTrackingPermissionsAsync,
} from 'expo-tracking-transparency';
import { Platform } from 'react-native';
export async function ensureTrackingPermission(): Promise<boolean> {
// iOS only — Android is not in scope
if (Platform.OS !== 'ios') return true;
// On iOS < 14.5 the API is unavailable, so treat as granted
const current = await getTrackingPermissionsAsync();
if (current.status === 'granted') return true;
if (current.status === 'denied') return false;
// Only prompt when the user has not yet decided
const result = await requestTrackingPermissionsAsync();
return result.status === 'granted';
}The key detail is calling requestTrackingPermissionsAsync only when the status is undetermined. Once a user has answered "Don't Allow," calling this method again will not show the prompt — it just returns denied. To re-prompt, the user has to flip the toggle in iOS Settings under "Privacy & Security → Tracking." A common mistake is wrapping the call in a useEffect that fires on every mount, which leads to silent denials and inflates your "rejected" metric for no real reason. Cache the answer in module state and treat the first determination as final until the user explicitly asks to change it.
Step 3: Time the Prompt Right
Apple recommends prompting "at the appropriate time," and reviewers will reject apps that fire it the moment the app launches. The pattern that has worked best for me looks like this.
- On first launch, show onboarding screens — no prompt yet, just introduce what the app does
- At the end of onboarding, add a single screen that explains: "We keep this app free by showing ads. The next dialog asks if you'd like ads tailored to your interests. Choose 'Allow Tracking' to opt in."
- The "Continue" button on that screen calls
requestTrackingPermissionsAsync()
This pre-prompt pattern roughly doubles the opt-in rate in my experience, going from somewhere around 25–30% to 50% or higher depending on the audience. Apple endorses it in their official sample code, which also means reviewers tend to recognize it as a good-faith pattern. Just be careful that your pre-prompt does not visually mimic the system dialog — that crosses into "deceptive UI" territory and will get rejected. Use your own brand styling, your own button labels, and a clear continue/skip option that does not pre-bias the user.
Step 4: Handle "Don't Allow" Gracefully
If a user declines, you cannot lock them out of features or refuse to show ads (App Review Guideline 3.2.2 prohibits this). The right move is to switch to non-personalized advertising and disable any IDFA-based logic on the attribution side.
import mobileAds, { MaxAdContentRating } from 'react-native-google-mobile-ads';
export async function configureAds(): Promise<void> {
const trackingAllowed = await ensureTrackingPermission();
await mobileAds().setRequestConfiguration({
maxAdContentRating: MaxAdContentRating.T,
tagForChildDirectedTreatment: false,
});
// Pass the flag to each ad request, e.g.:
// const request = { requestNonPersonalizedAdsOnly: !trackingAllowed };
await mobileAds().initialize();
}For AdMob, every ad request should carry requestNonPersonalizedAdsOnly: !trackingAllowed. Attribution SDKs like AppsFlyer and Adjust expose options such as disableIDFA that you can set based on the user's choice. Some SDKs also expose a setEnabled(false) style toggle that you can flip on the fly — call this immediately after ensureTrackingPermission() resolves so the SDK never sends an IDFA on the first request.
iOS also offers SKAdNetwork, Apple's own framework for measuring installs without IDFA. You lose 1:1 user-level attribution, but campaign-level performance still works. Any app spending real money on user acquisition should plan to support SKAdNetwork alongside the IDFA flow. Most of the major attribution SDKs now wrap SKAdNetwork (and its successor AdAttributionKit) for you, but you still need to register your campaigns in the SDK dashboard and add the network IDs to your Info.plist under SKAdNetworkItems.
Step 5: The Specific Things That Got Me Rejected
Beyond the textbook setup, here are the rejection notes I actually received. Knowing the patterns up front can save you a submission cycle.
- The disclosure string did not mention "ads": Saying "to improve our service" alone got flagged because the SDK list clearly included ad networks. The string must match what is actually happening, and Apple cross-references it against your privacy nutrition label
- The prompt fired before onboarding: Reviewers called this "user is presented with the prompt without context." Adding a single explanatory screen fixed it on the next submission
- No path back to Settings: Apps without a way to deep-link into Settings (
Linking.openURL('app-settings:')) get noted in internal review. Add a row in your Settings screen for users who want to change their decision later
None of these are written explicitly in Apple's developer documentation — they are reviewer judgment calls. Reading enough rejection feedback, though, you start to see the same three issues over and over. If you keep a small Notion page with rejection notes from your past submissions, you build up a personal checklist faster than any official doc can teach you.
Start by Re-reading Your Info.plist Copy
ATT compliance is decided more by your wording than by your code. Before you ship anything, open app.json and check that NSUserTrackingUsageDescription mentions ads, free access, and the fact that declining keeps every feature working. Just doing that meaningfully drops your rejection probability.
If you are working through the broader attribution stack, the Rork attribution guide for AppsFlyer, Adjust, and SKAdNetwork covers how the choice flows downstream. For permission UX patterns more generally, the Rork Face ID / Touch ID biometric auth guide walks through similar prompt timing decisions. And before submitting, the Rork App Store review checklist is worth a final pass to catch anything else reviewers tend to flag.