I have been running ad-supported indie iOS apps since 2014, mostly through AdMob. So when I built a new ad-monetized app with Rork and ran it on a real device, I expected the App Tracking Transparency prompt to pop up immediately. It never did. Not on first launch, not on any subsequent launch, not even after a full reinstall.
The console showed no errors. The build succeeded. The AdMob banner even rendered fine. But the IDFA stayed blank, my Firebase Analytics revenue dashboard was empty, and that familiar "Allow [App Name] to track your activity..." dialog was nowhere to be seen.
This is a surprisingly common failure mode for Rork-generated iOS apps. The AppTrackingTransparency framework is one of those pieces that fails silently when something is off — Apple deliberately designed it not to crash or warn, because the tracking flow is supposed to be invisible to users who say "no." That same design choice makes it brutal to debug. Below are the three causes I have actually run into, in the order I would check them.
Cause 1: Missing or empty NSUserTrackingUsageDescription in Info.plist
This is the most frequent culprit. Rork does not always add the ATT description to app.json automatically, so when EAS Build generates Info.plist, the NSUserTrackingUsageDescription key is simply absent.
Since iOS 14.5, calling requestTrackingAuthorization() on an app without this key does not show the dialog and silently returns .denied. No error is thrown, which is why so many developers assume the call "worked" and look elsewhere. There is no NSAssert, no exception, no warning in Xcode's console — just a quietly returned denial.
// app.json — add to ios.infoPlist
{
"expo": {
"ios": {
"infoPlist": {
"NSUserTrackingUsageDescription": "We use this identifier to show ads that match your interests."
}
}
}
}Be specific in the message. Apple sometimes rejects vague phrases like "for advertising"; phrases such as "show ads that match your interests" or "deliver more relevant content" pass review more reliably. The reviewer reads this string verbatim during App Review, so write it as if you were explaining it to a non-technical user.
After editing, run eas build or expo prebuild --clean. I lost two hours skipping the --clean flag once. If a generated ios/ directory already exists, Expo prefers it over app.json, so your changes are silently ignored. The same trap catches people when they switch from a managed workflow to a bare workflow halfway through a project — the bare ios/ folder takes precedence forever after.
To verify the change actually landed, open ios/[YourAppName]/Info.plist in Xcode after prebuild and search for NSUserTrackingUsageDescription. If it isn't there, the change didn't propagate.
Cause 2: Calling requestTrackingAuthorization() too early
The ATT dialog only shows when the app is fully foregrounded and active. If you call it inside useEffect on app start, the call often fires before the app reaches the active state, the dialog never appears, and the API simply returns .notDetermined.
This is particularly nasty because it works fine in the simulator and fails silently on real devices. The simulator transitions to the active state much faster than a physical device, especially on older hardware, so a request that races the lifecycle in the simulator wins and shows the dialog. On a real device with a slower cold start, that same race loses, and the developer never sees the failure during local testing. I only discovered this after a TestFlight tester told me the ad opt-in dialog was missing.
// ✅ The reliable pattern
import { useEffect, useRef } from "react";
import { AppState, Platform } from "react-native";
import {
requestTrackingPermissionsAsync,
getTrackingPermissionsAsync,
} from "expo-tracking-transparency";
export default function App() {
const requestedRef = useRef(false);
useEffect(() => {
if (Platform.OS !== "ios") return;
const subscription = AppState.addEventListener("change", async (state) => {
if (state !== "active" || requestedRef.current) return;
requestedRef.current = true;
const current = await getTrackingPermissionsAsync();
if (current.status === "undetermined") {
const result = await requestTrackingPermissionsAsync();
console.log("ATT result:", result.status);
// expected output: "granted" or "denied"
}
});
return () => subscription.remove();
}, []);
return /* ... */;
}Three things matter here. First, wait for AppState === "active" so the dialog has a chance to render. Second, gate the request behind a requestedRef so accidental remounts (hot reload, navigation pushes that re-mount the root) don't keep firing the request. Third, never re-request when the user has already decided — checking getTrackingPermissionsAsync() first avoids no-op API calls that pollute the logs.
If you ask Rork to "implement ATT," it sometimes generates a version that calls the API directly inside useEffect — replace it with the pattern above and the dialog becomes reliable across devices.
A subtle variant of this bug occurs when developers wrap the request in a screen component instead of the app root. If the user happens to background the app before reaching that screen on first launch, the request can fire while the app is inactive on resume. Putting the listener at the root component avoids that whole class of problems.
Cause 3: AdMob SDK initialized before the ATT result is decided
This is the trap that fooled me longest. Google Mobile Ads tries to read the IDFA during SDK initialization. If the SDK initializes before ATT is resolved, even a later "Allow" gives you non-personalized ads for that session, because the SDK has already cached the absence of an IDFA. Every subsequent ad request in that session uses the non-personalized signal, so your eCPM stays low even though the user agreed to tracking.
The correct order is:
- Launch the app
- Show the ATT dialog and wait for the user's choice
- Initialize AdMob only after the result is known
import mobileAds from "react-native-google-mobile-ads";
import { requestTrackingPermissionsAsync } from "expo-tracking-transparency";
async function initAdsWithConsent() {
if (Platform.OS === "ios") {
const { status } = await requestTrackingPermissionsAsync();
console.log("ATT decided:", status);
}
// Initializing AFTER the ATT result lets personalized ads work
await mobileAds().initialize();
console.log("AdMob initialized");
}When you ask Rork to wire up an ad SDK, it often places mobileAds().initialize() at the very top of App.tsx. That runs before ATT, so you'll need to reorder it manually. The same caveat applies to other ad networks — AppLovin, Unity Ads, IronSource — each of them caches the IDFA at init time, and each will give you non-personalized inventory if you initialize first.
For apps using consent management platforms like Google's UMP (User Messaging Platform) for GDPR, the correct order extends to: GDPR consent → ATT → SDK initialization. Reversing any of those three creates the same cached-state bug.
A testing trap: once decided, the dialog never shows again
Once the user taps "Allow" or "Don't Allow," the dialog is permanently dismissed for that app. Forgetting this during development leads to the maddening "I fixed it, but the dialog still doesn't appear — my fix is broken" loop.
To reset on a real device:
- Same app, retest: Settings → Privacy & Security → Tracking → toggle the app off and back on. This restores the
.notDeterminedstate and lets the next launch re-prompt. - Full reset: Uninstall and reinstall the app. Note that IDFA is not regenerated by reinstall alone — that requires a device-wide reset of advertising identifiers in Settings → Privacy & Security → Apple Advertising → Reset Advertising Identifier.
- Simulator: Device → Erase All Content and Settings. The simulator's ATT decision is tied to the simulator instance, so erasing it gives a clean slate.
I burned a full day on this before realizing the issue. The fastest sanity check is to open the iOS Settings app, tap your app, and confirm the tracking toggle's actual state. If it's off and you can't toggle it on, the user has denied with canAskAgain: false — the only path forward is to ask them to flip it manually in Settings.
A debug snippet I keep handy
When troubleshooting, I always log the current ATT state on every launch.
import { getTrackingPermissionsAsync } from "expo-tracking-transparency";
useEffect(() => {
(async () => {
const status = await getTrackingPermissionsAsync();
console.log("=== ATT status ===");
console.log("status:", status.status); // "granted" | "denied" | "undetermined"
console.log("canAskAgain:", status.canAskAgain);
console.log("expires:", status.expires);
// example outputs:
// status: "undetermined" → not yet asked (still requestable)
// status: "denied" canAskAgain: false → user denied; you cannot reprompt
})();
}, []);When canAskAgain: false comes back, no amount of code will resurface the dialog. The pragmatic move is to add a UI hint that points the user to the Settings app — a small banner saying "Personalized ads are disabled. Tap to enable in Settings" with a Linking.openSettings() action.
App Store review considerations
Your NSUserTrackingUsageDescription string is read aloud to App Review. Generic strings like "Required for advertising" or "App functionality" get rejected under Guideline 5.1.2. Strings that explain a concrete user benefit — "Show ads about products you've shown interest in" — typically pass without issue. If you've been rejected for ATT-related reasons, rewriting this string to be more specific is usually faster than appealing.
App Review also tests on real devices, so if your timing bug means the dialog never appears, the reviewer will see exactly what your TestFlight users saw — and reject under Guideline 5.1.1. Confirming the dialog appears on a physical device before submission is the single most reliable App Review check for monetized apps.
A practical takeaway
Don't trust Rork's generated ATT code blindly. Make a habit of checking three places yourself: app.json, the request timing, and the SDK init order. These days I spin up a tiny standalone sample with just those three pieces, verify the dialog appears on a physical device, and only then start adding business logic.
It's an unglamorous part of the iOS lifecycle, but getting it right has noticeably moved my eCPM by 1.5x to 2x in real apps — well worth the careful setup. The difference between a $0.50 eCPM and a $1.50 eCPM at 100,000 daily impressions is roughly $36,000 a year. That math makes ATT correctness one of the highest-leverage things an indie developer can fix.
If you're working through related iOS gotchas, I would also recommend Why Your Rork App's Push Notifications Don't Fire and Fixing Permission Failures in Rork iOS Apps — together they cover most of the iOS privacy minefield.
Thank you for reading. If this saves you the day I lost on it, that makes it worth writing.