●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Auditing Privacy Manifests for Rork-Generated Expo Apps — A One-Day Pre-Submission Workflow for Indie Developers
A pre-submission workflow for indie developers shipping Rork-generated Expo apps: enumerate every dependency, tell ITMS-91053 apart from ITMS-91061, and catch the Pods that npm names never show you — hermes included.
A few hours after I hit submit, an email arrived from App Store Connect that said only "ITMS-91053: Missing API declarations." It was a small update to a wallpaper app, and the previous build had gone through untouched. I run wallpaper and mindfulness apps on my own, and since Privacy Manifests became mandatory in 2024, this kind of bounce-back has become far more frequent.
The hardest part was that the rejections almost never came from code I had written. A native library deep inside the Rork-generated Expo project was calling UserDefaults, and one missing declaration was enough to halt the whole submission. Apple's automated checks are stricter than human reviewers, and the reason text can take hours to surface. If you only react after the fact, your monthly release plan starts to slip.
This guide lays out how to move from "post-rejection mode" to "pre-submission audit mode" for Rork-generated Expo and React Native apps. The goal is to walk you through a half-day to one-day workflow that exhaustively reviews the SDK chain, so that you can leave with a working npm ls + find-based audit script in your repo by the end of reading.
Why "react after rejection" is too slow
Reacting after rejection is heavier than it looks. The shortest realistic loop after a single rejection looks like this. Two to six hours for the rejection email to arrive after upload. One to three hours to pinpoint which SDK is causing it. Thirty minutes to two hours to edit PrivacyInfo.xcprivacy and rebuild via EAS Build. Another hour for re-upload and processing. Even on a good day you are losing half a day, and on a bad day a day and a half.
In spring 2024 I went through this loop three times in a row on a new wallpaper app. Builds that passed before AdMob was added started failing with UserDefaults-related Required Reason violations after adding AdMob. Adding RevenueCat then surfaced an NSPrivacyAccessedAPICategoryDiskSpace requirement. Every SDK addition cost one rejection, and the release date slipped a full week.
Switching to a pre-submission audit eliminated almost all ITMS-91053 rejections from my release flow. The audit shifted the work from "gambling on review luck" to a process I can reproduce.
Understanding the structure of the SDK chain
Privacy Manifests are hierarchical. It is rare for a single root PrivacyInfo.xcprivacy to cover everything; each native module needs its own. Apple's automated review runs against the built .ipa as a whole, so a single missing manifest anywhere in the dependency tree fails the entire submission.
A typical Rork-generated Expo app has three layers of dependencies.
The app itself (iOS project generated from app/ and app.json)
The Expo SDK family (expo-* modules, roughly 20–40 packages)
Third-party React Native libraries (ads, billing, analytics, storage, notifications — five to fifteen packages)
You only need to author the manifest for the first layer yourself. The other two depend on whether the SDK author has shipped one. As of May 2026, Expo SDK 53 and 54 cover essentially every module, but third-party libraries are still uneven.
A particularly tricky case is Expo Config Plugins that inject native code dynamically. Because Plugin-driven files are generated at EAS Build time, grepping the local ios/ folder will miss them. I once spent a full day discovering that the output of expo-notifications' plugin did not include PrivacyInfo.xcprivacy. Once you know that vector exists, the fix is mechanical, but if you do not know to look there, it can vaporize a release window.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A reproducible 30-minute script that lists every native dependency in a Rork-generated app and flags which ones lack PrivacyInfo.xcprivacy
✦A May 2026 cheat sheet for AdMob, Firebase, RevenueCat, MMKV, Sentry, and other SDKs that personal developers actually ship
✦A pre-submission workflow that turns post-rejection scramble into a calm, repeatable process — backed by real ITMS-91053 case studies
✦How ITMS-91061 differs from ITMS-91053, and how to match Podfile.lock against Apple's SDK list to catch dependencies npm never names — hermes among them
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The half-day track (about three hours) is for releases that add one or two SDKs. The full-day track (seven to eight hours) is for major Expo SDK upgrades or any release where Rork's AI suggestions reshaped the dependency tree significantly. I use the half-day track for wallpaper app updates and the full-day track when I move a mindfulness app onto the new annual iOS major version.
The half-day track looks like this.
0:00–0:30 — Capture the dependency tree with npm ls --production --depth=5 and run the PrivacyInfo.xcprivacy discovery script
0:30–1:30 — List the missing-manifest packages and check whether upgrading to the latest version resolves them
1:30–2:30 — Merge or write the additional manifests by hand and run a local EAS Build pass
2:30–3:00 — Validate the whole .ipa with the same automated check xcrun altool --validate-app runs
The full-day track adds tracking of files emitted by Expo Config Plugins, a diff check against Apple Developer's "Required Reason API documentation," and a reconciliation between the Privacy Nutrition Label and the manifest.
Step 1: Extract the dependency tree accurately
The first task is to enumerate every SDK that ends up in the build. You could ask Rork's chat to do it, but the most reproducible path is local.
# Run from the project root of the Rork-generated appnpm ls --production --depth=5 --json > /tmp/deps-tree.json# Keep only packages that are likely to ship native codejq -r ' [.. | objects | select(.dependencies != null) | .dependencies | keys[]] | unique[]' /tmp/deps-tree.json \ | grep -E "^(react-native-|expo-|@react-native-|@expo/|@stripe/|@sentry/|firebase|@react-native-firebase|react-native-google-mobile-ads|react-native-mmkv|react-native-purchases)" \ > /tmp/native-deps.txtwc -l /tmp/native-deps.txt
I set --depth=5 because Expo SDK's inner dependencies (expo-modules-core and so on) typically live two or three levels deep. --depth=Infinity produces files large enough to slow the subsequent processing.
For my main wallpaper app, which has about twenty-two top-level dependencies, this script returns forty to fifty-five lines. The number shifts each time Rork rewrites code with the AI, so re-running it just before release gives you a clean diff. That diff is the starting point for the audit.
Step 2: Check PrivacyInfo.xcprivacy coverage in bulk
For each dependency, check whether PrivacyInfo.xcprivacy ships with it. Doing this one library at a time turns into a find slog, so I keep this shell script as _tools/audit-privacy-manifest.sh.
#!/usr/bin/env bash# audit-privacy-manifest.sh — Check PrivacyInfo.xcprivacy coverage across native depsset -euo pipefailNATIVE_DEPS="${1:-/tmp/native-deps.txt}"REPORT="/tmp/privacy-audit-$(date +%Y%m%d-%H%M).tsv"printf "package\tstatus\tpath\n" > "$REPORT"while IFS= read -r pkg; do base="node_modules/$pkg" if [ ! -d "$base" ]; then printf "%s\tmissing-dir\t-\n" "$pkg" >> "$REPORT" continue fi found=$(find "$base" -name "PrivacyInfo.xcprivacy" 2>/dev/null | head -1 || true) if [ -n "$found" ]; then printf "%s\thas-manifest\t%s\n" "$pkg" "$found" >> "$REPORT" else printf "%s\tNO-MANIFEST\t-\n" "$pkg" >> "$REPORT" fidone < "$NATIVE_DEPS"echo "report: $REPORT"column -t -s $'\t' "$REPORT" | head -30
When the script finishes, the NO-MANIFEST rows are the SDKs that need immediate work, and has-manifest rows can be left alone. On my latest wallpaper app, out of forty-eight dependencies, six were NO-MANIFEST. Three of those were resolved by npm update because the latest releases shipped manifests, and the remaining three needed manual handling.
Step 3: A decision flow for SDKs missing a manifest
NO-MANIFEST rows are not a panic moment. Most clear up within thirty minutes if you walk through the following stages.
The first stage is "does the latest version ship one?" Run npm view <package> versions to scan history and npm view <package@latest> files to inspect what gets bundled. If a manifest is in there, npm install <package>@latest is the entire fix. In my experience, roughly eighty percent of SDKs that had a major release before mid-2024 shipped Privacy Manifest support during 2025.
The second stage is "is there a tracking issue?" The query https://github.com/<org>/<repo>/issues?q=PrivacyInfo almost always surfaces a relevant thread. If a fix is merged, you can wait for the next release or apply patch-package immediately.
The third stage is "ship one yourself." This is the last resort when the library author is unresponsive or you are using a fork. You write a postinstall script that drops a node_modules/<package>/ios/PrivacyInfo.xcprivacy file into place. Here is the snippet I use for one older React Native animation library.
// scripts/inject-privacy-manifest.js// Last-resort injection when the upstream package has no PrivacyInfo.xcprivacyconst fs = require("fs");const path = require("path");const targets = [ { pkg: "react-native-some-old-lib", iosDir: "ios", manifest: "manifests/some-old-lib.xcprivacy", },];for (const t of targets) { const dest = path.join("node_modules", t.pkg, t.iosDir, "PrivacyInfo.xcprivacy"); if (fs.existsSync(dest)) continue; fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(t.manifest, dest); console.log(`injected: ${dest}`);}
Adding node scripts/inject-privacy-manifest.js to package.jsonscripts.postinstall ensures the file is restored on every npm install. patch-package works for the same goal, but I find the file-based approach easier to track in code review.
Step 4: A cheat sheet of common SDKs (May 2026 snapshot)
This is the current status of the SDKs that I actively use across my wallpaper, mindfulness, and law-of-attraction apps. Versions are as of writing; always confirm with npm view <pkg>@latest immediately before release.
react-native-google-mobile-ads (AdMob) — Manifest shipped from 13.x. Requires UserDefaults: CA92.1 and SystemBootTime: 35F9.1. AdMob is one of the most painful to handle by hand, and 12.x or older needs manual work.
react-native-purchases (RevenueCat) — Manifest shipped from 9.x. Requires DiskSpace: E174.1 and UserDefaults: CA92.1. Be sure to keep the Privacy Nutrition Label in sync.
@sentry/react-native — Manifest shipped from 6.x with FileTimestamp: C617.1 and UserDefaults: CA92.1.
@react-native-firebase/app and friends — Manifest support starts at 22.x. Auth, Analytics, Messaging, and Crashlytics each have their own subpackage manifest.
react-native-mmkv — Manifest shipped from 3.x, declaring UserDefaults: CA92.1.
@react-native-async-storage/async-storage — Manifest shipped from 2.x, declaring UserDefaults: CA92.1.
expo-notifications, expo-file-system, expo-secure-store — Covered by Expo SDK 53 and later. No extra declarations required.
react-native-mixpanel — Quality varies between forks; if the latest does not ship a manifest, integrating the native Mixpanel iOS SDK directly is faster than waiting.
Anything not in this list, I check with npm view <pkg> files | grep -i privacy. If a manifest is bundled, adopt it. Otherwise, drop straight to the third stage of the decision flow above.
Step 5: Tighten the app-level manifest
After the SDK side is squared away, do the final pass on your own PrivacyInfo.xcprivacy. Rork-generated Expo apps store this in a slightly unusual place.
Stock Expo: app/PrivacyInfo.xcprivacy, with app.json declaring ios.privacyManifests
Bare workflow: ios/<AppName>/PrivacyInfo.xcprivacy
Rork-customized layouts: read expo.ios.privacyManifests in app.json
Here is the app.json template I have been using on wallpaper apps. It passes review with AdMob, RevenueCat, and Sentry installed, without further declarations on top.
Note that NSPrivacyTracking is false. ATT-free apps (most of my wallpaper apps fall into this) are fine that way, but when you enable AdMob's personalized ads, flip it to true and populate NSPrivacyTrackingDomains. My mindfulness apps do not show ATT prompts, so false; web-linked apps stay on true.
Step 6: Validate the build locally before the long EAS Build loop
Once the manifest set looks right, run a local build and inspect the .ipa before kicking off an EAS Build. Cloud builds have a long feedback loop, and you want to catch mistakes here.
# Local build (requires a Mac)npx expo prebuild --platform ios --cleancd ios && pod install && cd ..# Archive in Xcode and extract the ipaxcodebuild archive \ -workspace ios/MyApp.xcworkspace \ -scheme MyApp \ -configuration Release \ -archivePath /tmp/MyApp.xcarchive \ -destination "generic/platform=iOS"xcodebuild -exportArchive \ -archivePath /tmp/MyApp.xcarchive \ -exportPath /tmp/MyApp \ -exportOptionsPlist ios/export-options.plist# List every PrivacyInfo.xcprivacy inside the ipacd /tmp/MyAppunzip -o MyApp.ipa -d MyApp-unpacked > /dev/nullfind MyApp-unpacked -name "PrivacyInfo.xcprivacy"
The number of files listed here should be roughly the has-manifest count from Step 2 plus one for the app itself. If the count is off, a Pod is overwriting an old Privacy Manifest, or an Expo Config Plugin is stripping one in a later step.
Finally, run xcrun altool --validate-app to invoke the same automated check App Store Connect uses.
A No errors response means the submission almost always sails through. The calm that comes from passing validate-app is worth the few minutes it costs, so I never skip it now.
One clarification, because I skipped this step for a while over a misunderstanding. People hear that altool is deprecated and stop using it. What was deprecated is the notarization path, which now points to notarytool. The --validate-app and --upload-app subcommands talk to App Store Connect instead, and Apple's upload guide still lists them alongside Transporter. Don't let the notarization news talk you out of the validation step.
Reconciling the Privacy Manifest with the Privacy Nutrition Label
It is worth pausing here to disentangle two things that are easy to confuse. The Privacy Manifest (PrivacyInfo.xcprivacy) is a machine-readable declaration of what your code touches. The Privacy Nutrition Label (App Privacy in App Store Connect) is a human-readable disclosure shown in the store. They should agree, and when they do not, reviewers will reach out individually.
A wallpaper app of mine ran into this last year. The manifest had NSPrivacyTracking: false, but in App Store Connect, "Identifiers — Device ID" was still marked as "Used for Tracking" from an old draft. The reviewer asked me to confirm the consistency. The reply itself took five minutes, but the round-trip ate twenty-four hours.
A reasonable pre-submission checklist is just these three lines. For each data type in App Store Connect's App Privacy section, verify that the "Used for Tracking," "Linked to User," and "Linked to Identity" flags do not contradict NSPrivacyTracking and NSPrivacyCollectedDataTypes in app.json. If you ship AdMob, confirm whether the design actually requests IDFA (request → true, no request → false). If you ship RevenueCat, decide whether the user identifier is anonymous (Linked to User: false) or email-linked (true).
I keep this mapping in _documents/privacy-mapping.md as a single table. Writing it once turns the next release's review into a five-minute confirmation.
Picking Required Reason API codes by intent, not by SDK
The reason codes in PrivacyInfo.xcprivacy (CA92.1, etc.) are defined per API category. Copying what an SDK ships works for that SDK, but if your own app code uses UserDefaults for settings storage, you must declare your own intent (CA92.1), separate from what the SDKs declare for theirs.
Here are the codes I reach for most often. The canonical list is Apple's Required Reason API documentation; entries get added and reorganized, so confirm there before every release.
NSPrivacyAccessedAPICategoryUserDefaults — CA92.1 for in-app settings storage, 1C8F.1 for shared defaults, AC6B.1 for CloudKit-linked usage
NSPrivacyAccessedAPICategoryFileTimestamp — C617.1 for app-managed caches and sync, 3B52.1 for displaying creation dates of user-selected files
NSPrivacyAccessedAPICategorySystemBootTime — 35F9.1 for elapsed-time UI updates, 8FFB.1 for measurement
NSPrivacyAccessedAPICategoryDiskSpace — E174.1 for in-app storage decisions, 85F4.1 for "do I have room to download" checks
I once forgot 35F9.1 on a wallpaper app and got a rejection routed through AdMob. AdMob's bundled manifest declared 35F9.1, but my own app also read SystemBootTime for a separate reason, and the app-level declaration was missing. Per Apple's rule that "the app declares intent independently of any SDK," keeping a small _documents/required-reason-mapping.md table reduces the chance of that mistake when adding a new SDK.
ITMS-91053 and ITMS-91061 are different warnings with different fixes
Everything so far has been about ITMS-91053 — a missing Required Reason API declaration. But submission emails sometimes carry a second, confusingly similar code: ITMS-91061. I filed both under the same mental heading for a while, and spent half a day adding entries to PrivacyInfo.xcprivacy while a warning that had nothing to do with my declarations refused to clear.
They have different causes and, more importantly, different people who can fix them.
Warning
What's missing
Who can fix it
ITMS-91053
A reason code for a Required Reason API your code touches
You. Add the code to your app's own manifest and it's done
ITMS-91061
An SDK Apple names explicitly ships without a privacy manifest at all
The SDK vendor. Your options are to wait or to switch
The SDKs in scope for ITMS-91061 are the ones Apple lists on its Third-party SDK requirements page. If you submit a new app containing one of them, or an update that adds one, that SDK must include a privacy manifest — and a signature too, when it arrives as a binary dependency. The requirement covers any version of a listed SDK, and any library that repackages one.
That distinction is what makes the timing matter. ITMS-91053 is something you can clear the same evening you notice it. ITMS-91061 depends on somebody else's release schedule, and you cannot estimate that. 91053 is homework; 91061 is a purchase order. Half the reason to run this audit a week before submission is to turn that unknown wait into something you can place early.
npm names won't get you there — match Podfile.lock against Apple's list
There is a second gap here that took me longer than it should have to see. The audit script in Step 2 reads node_modules. Apple's list, on the other hand, names Pods and frameworks. The two vocabularies simply don't overlap.
hermes is the clearest example. It's React Native's JavaScript engine, present in Expo's default configuration. You can stare at your npm dependency tree all day and never see a line called hermes — but it's on Apple's list. Which means a freshly generated Rork Expo app already ships one listed SDK before you add anything at all.
Here are the entries that actually turn up in Expo and React Native projects, along with how they get in.
Name on Apple's list
How it arrives
hermes
React Native's JS engine, bundled in Expo's default configuration
Once you know you're matching against the wrong vocabulary, the fix is small. Pull the Pod names out of the PODS: section of ios/Podfile.lock and intersect them with the list copied from Apple's page. One npx expo prebuild run is enough to produce a Podfile.lock even for a Rork-generated project.
#!/usr/bin/env bash# audit-apple-sdk-list.sh# Match Podfile.lock against the SDKs Apple requires manifests and signatures forset -euo pipefailLOCK="${1:-ios/Podfile.lock}"LIST="${2:-_tools/apple-sdk-list.txt}" # one name per line, copied from Apple's page# Pull Pod names out of the PODS: section (subspecs collapse to the parent name)awk '/^PODS:/{f=1;next} /^[A-Z]/{f=0} f && /^ - /{print $2}' "$LOCK" \ | tr -d '"' | sed 's|/.*||' | sort -u > /tmp/pods.txthit=0while IFS= read -r name; do [ -z "$name" ] && continue # substring, case-insensitive, so hermes-engine still matches hermes found=$(grep -i -- "$name" /tmp/pods.txt || true) if [ -n "$found" ]; then hit=$((hit + 1)) printf "%-28s <= %s\n" "$name" "$(echo "$found" | paste -sd, -)" fidone < "$LIST"echo "----"echo "listed SDKs present: ${hit}"
For each Pod that comes back, check two things. First, whether a PrivacyInfo.xcprivacy is actually inside it.
# manifest presence, per matched Podfor pod in hermes-engine FirebaseCore GoogleUtilities nanopb; do found=$(find "ios/Pods/$pod" -name "PrivacyInfo.xcprivacy" 2>/dev/null | head -1 || true) printf "%-20s %s\n" "$pod" "${found:-NO-MANIFEST}"done
Second, the signature. Signatures are required where a listed SDK arrives as a binary dependency, so Pods compiled from source are out of scope. Only the prebuilt .xcframework and .framework bundles need checking.
# signature check, prebuilt frameworks onlyfind ios/Pods -name "*.xcframework" -maxdepth 3 | while read -r fw; do name=$(basename "$fw") if codesign -dv "$fw" >/dev/null 2>&1; then printf "%-40s signed\n" "$name" else printf "%-40s UNSIGNED (ask the vendor)\n" "$name" fidone
I run audit-apple-sdk-list.sh immediately after audit-privacy-manifest.sh, because the gap you see from the npm side and the gap you see from the Pod side don't overlap. The list to match against is Podfile.lock, not package.json. Seeing that late cost me a handful of rejections whose cause I couldn't explain at the time.
Apple updates the list on their side, of course. I keep _tools/apple-sdk-list.txt in the repo and diff it against the official page once a quarter, which has been often enough.
How far indie developers should automate this
These steps are achievable in half a day, but running them by hand every release does not scale. I have folded the following into a release-prep GitHub Actions workflow for both my wallpaper and mindfulness apps.
Before cutting the release tag, run audit-privacy-manifest.sh automatically
If any NO-MANIFEST row appears, post a Slack notification and pause for a human decision
If everything is has-manifest, proceed straight to EAS Build
When NO-MANIFEST rows appear, I set aside thirty to sixty minutes and walk the Step 3 flow. Since folding this into the workflow, I have not had an ITMS-91053 bounce that I can remember.
Having said all that, I have not handed this process over entirely. Apple's policies get more detailed every year, and what a machine can do is match one list against another. Which reason code you claim is a question about why your app touches that API, and I may be missing something, but that seems like the part only the person who wrote the code can answer.
Let the matching be mechanical; write the reasons yourself. That is the line I try hardest to hold on the days the deadline is closest.
For your next release, pick just one of audit-privacy-manifest.sh or audit-apple-sdk-list.sh and keep it in the repo. Either takes about as long to write as a single rejection cycle costs, and the releases after it feel different. Thank you for reading this far.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.