AOY
TR EN
← Writing

Three Apple rejections: ATT, the GDPR form, and an ordering bug I did not expect

31 August 2026· 6 min read· Stores

Every fix uncovered the next layer. The last one was not in the code at all — it was in when iOS agrees to show the ATT prompt.

An iOS app that shows ads deals with two separate permissions, and they do not substitute for each other:

  • ATT (App Tracking Transparency) — Apple’s device permission, asked through a system dialog before you may touch the advertising identifier.
  • The UMP / TCF form — GDPR consent. Legally required in the EEA, the UK and Switzerland, presented by Google’s certified consent platform.

Apple’s rule looks simple: the system prompt has to come first. It took a month and three rejections to satisfy it.

First rejection — 5.1.2(i): the culprit was not our code

The rejection said the app showed a “custom prompt” instead of using ATT. This was the screen:

Our app wants to stay free for you. Ads help support our business. Tap “Allow” on the next screen to give permission to show ads that are more relevant to you.

That is precisely the pattern Apple forbids: telling the user to tap Allow. But I searched for the string and could not find it in the code — not in the source, not in the compiled binary. It was being pulled at runtime by Google’s UMP SDK from the AdMob console.

Decision · console over build

The fix went into the console, not the code: under AdMob → Privacy & messaging → App Tracking Transparency, the “IDFA explainer” message was removed from the app’s targeting. UMP fetches that message on every launch instead of bundling it, so no new build was needed — the moment the message stopped being served, the existing build stopped showing that screen.

⚠️ The other two cards (European regulations / US state regulations) were left alone: the first is a legal requirement in the EEA, the second is a privacy notice. Only the IDFA explainer was the violation.

The fix was verified in a US-region simulator: straight from the location permission to the system ATT dialog, nothing in between. Clean.

Second rejection — same clause, and this time it really was the order

Apple cited the same clause again. But the screenshot attached to the rejection no longer showed the IDFA explainer — it showed Google’s TCF form. So the console fix had worked, and the problem had simply moved.

Apple’s review device was in the EEA. The order there was:

_ensureConsent()        → GDPR/TCF form   ← Apple counts this as a "custom prompt"
_ensureAttPermission()  → system ATT dialog
MobileAds.initialize()
The verification ran in the wrong region

The first fix was verified in a US-region simulator, and there the consent flow returns immediately without showing anything — the GDPR form never appeared in that run, so the ordering problem was invisible.

Testing region-dependent behaviour in the wrong region is the same as not testing it. A clean result is not, by itself, evidence.

The order was flipped to ATT → UMP → SDK. Removing the UMP form was never an option — it is legally required in the EEA and our published privacy policy explicitly promises it; removing it would have been a false declaration. The order was the only variable we had.

And the order was right in the code but not at runtime

Measured in an EEA-simulated run:

[ads] ATT result: notDetermined      ← with the user answering NOTHING
...20s later: the consent flow timed out

The mechanism: iOS only shows the ATT prompt while the app is resumed. At launch, with our own location permission dialog in front, the app is inactive, and requestTrackingAuthorization() returns notDetermined instantly, without showing anything.

The code took that for “asked” and moved on, opening the UMP form; the ATT dialog then landed on top of it. What the user — and the reviewer — saw was UMP → ATT again, the exact order that had been rejected.

An await that waited for the wrong thing

There was an await, but it was not awaiting what we thought. The call was saying “I cannot ask right now”, not “I asked”, and both came back as the same value.

The fix: a helper that waits until the app is in the foreground, and up to three retries when the prompt returns without being shown. 🔴 The wait budget and the retry counter were kept separate — in the first attempt they were shared, and a long-lived location dialog burned all three retries on waiting.

Third rejection — 5.1.1(iv): right order, wrong second question

This time Apple cited a different clause, which meant the progress was real:

…the app shows a GDPR prompt that asks permission to track after the user has already selected “Ask App Not to Track”…

Google’s TCF form asks, on its first line, for consent to “Personalised advertising and content”. Opening that form after the user has said “do not track” is, in Apple’s reading, asking for a denied permission a second time.

The fix branches on the ATT answer:

ATT answerUMP formAd request
Allowshown in the EEApersonalised
Ask App Not to Tracknot shownnon-personalised; in the EEA, not requested at all without consent
Android / pre-iOS 14unchangedunchanged

Every undecided state — denied, restricted, an unanswered notDetermined, an exception — is treated as “no tracking”. When in doubt, the restrictive branch wins.

The real bug found along the way

Something far worse turned up while chasing this: the canRequestAds value that carries the consent state was being read and only written to the log. The ad SDK and three separate ad requests ran independently of it.

Which means ads were being requested for EEA users who had refused consent — against Google’s own integration requirement and against GDPR, and precisely what Apple’s “there should be no tracking activity until the user grants permission” is aimed at. All four load paths now gate on that flag.

Decision · deliberately fail-closed

If the consent state cannot be resolved (first launch with no network, or the flow throws), no ads are shown at all for that session. Losing a session of revenue was chosen over requesting an ad without consent.

⚠️ This needs a monitoring point: if the consent configuration ever becomes “required everywhere”, revenue goes to zero silently and it is indistinguishable from a code bug. The diagnosis reads off one log line.


What to take from it

  • Test region-dependent behaviour in the right region. The flow that was clean in the US was the flow that got rejected in the EEA.
  • The screen may not be produced by your code. The rejected text came from the AdMob console — and so did the fix, with no build.
  • Fixing the order in the code does not fix the order the user sees. iOS only shows the prompt while the app is in front, and “I could not ask” returns the same value as “I asked and got nothing”.
  • Computing a flag and not using it is worse than not computing it. The correct value was right there in the log, and nothing read it.

Related project

More writing

all of it