For an Expo project running the managed workflow, the iOS privacy manifest does not get hand-edited in Xcode. It is generated from app.json by the Expo prebuild step, and EAS Build then ships it inside the compiled IPA. The trick is wiring app.json correctly, letting Continuous Native Generation handle the file, and resisting the temptation to commit the ios directory just because Apple emailed about ITMS-91056.
Short answer
In a managed Expo project, the privacy manifest lives in app.json under expo.ios.privacyManifests. On SDK 50 and later, @expo/config-plugins writes PrivacyInfo.xcprivacy into the iOS target during npx expo prebuild. EAS Build runs prebuild for you when ios/ is not committed, so your config stays the source of truth. Declare the required reason API categories your code and dependencies touch, set NSPrivacyTracking, and let the build pipeline handle the file generation.
What you should know
- The managed workflow keeps PrivacyInfo.xcprivacy out of source control. It is regenerated each prebuild from
expo.ios.privacyManifestsin app.json. - EAS Build skips prebuild when
ios/is in git. Addios/andandroid/to.gitignoreto keep the managed workflow honest. - SDK 50 ships native support; SDK 49 needs
expo-privacy-manifest-polyfill-plugin. Same schema, same generated file. - Most Expo apps need four or five required reason categories. UserDefaults, FileTimestamp, SystemBootTime, DiskSpace, and sometimes ActiveKeyboard.
- The "Multiple commands produce PrivacyInfo.xcprivacy" error means the file is being written twice. Delete the manual file under
ios/and keep only the app.json config.
Why does the managed Expo workflow handle PrivacyInfo.xcprivacy differently than bare?
The short answer is that managed Expo projects regenerate the iOS target on every build, so any file dropped into ios/ by hand gets overwritten when prebuild runs. The privacy manifest is no different. Bare projects own their ios directory; managed projects do not.
The mechanism that does the writing is @expo/config-plugins, which Expo SDK 50 added support for. When you run npx expo prebuild, the plugin reads expo.ios.privacyManifests from your app config, converts it into the plist-shaped XML that Apple expects, and writes the result into the iOS target at ios/<YourAppName>/PrivacyInfo.xcprivacy. EAS Build does the same thing remotely when ios/ is absent from your repo.
That separation matters because of how EAS Build decides what to do with native folders. Per Expo's EAS Build configuration guide, if the ios directory is present in your repository at build time, EAS treats the project as bare and runs the build straight from the committed files. Prebuild does not run, app.json changes do not sync, and your manifest stops tracking your config. For managed projects the rule is firm: keep ios/ and android/ in .gitignore.
What goes in the privacyManifests block in app.json?
In a managed Expo project, the relevant section looks like this:
{
"expo": {
"ios": {
"privacyManifests": {
"NSPrivacyTracking": false,
"NSPrivacyTrackingDomains": [],
"NSPrivacyCollectedDataTypes": [],
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": ["C617.1"]
}
]
}
}
}
}
The schema mirrors the four top-level keys in Apple's privacy manifest file documentation. NSPrivacyTracking is a boolean; set it to true only when the app actively performs tracking under App Tracking Transparency. NSPrivacyTrackingDomains holds the list of endpoints contacted for tracking, and it must remain empty when tracking is false. NSPrivacyCollectedDataTypes carries entries shaped like the App Privacy survey in App Store Connect. NSPrivacyAccessedAPITypes is the array Apple actually enforces at processing time.
The keys are case-sensitive. A typo in NSPrivacyAccessedAPIType produces a manifest that builds, uploads, and still triggers the ITMS-91056 warning because Apple's parser does not recognize the entry.
Which reason codes does a typical Expo app need?
The honest answer is that most Expo apps need at least four. The React Native runtime alone touches UserDefaults via AsyncStorage, FileTimestamp via image caching, and SystemBootTime via certain network libraries. The full list and acceptable codes appear in Apple's required reason API documentation.
| API category | Common Expo trigger | Typical reason code |
|---|---|---|
| NSPrivacyAccessedAPICategoryUserDefaults | AsyncStorage, expo-secure-store fallback | CA92.1 |
| NSPrivacyAccessedAPICategoryFileTimestamp | expo-image, expo-file-system caching | C617.1 |
| NSPrivacyAccessedAPICategorySystemBootTime | expo-modules-core internals | 35F9.1 |
| NSPrivacyAccessedAPICategoryDiskSpace | expo-file-system, photo uploads | E174.1 |
| NSPrivacyAccessedAPICategoryActiveKeyboard | expo-keyboard, custom keyboard apps | 3EC4.1 |
The reason codes are tied to specific call sites. Picking a code that does not match how the API is actually used is a paper trail Apple's reviewers can pull on; the reason language in Apple's docs is narrow, and a mismatched code does come up during a Guideline 5.1 escalation.
How do third-party SDKs fit into this?
For SDKs on Apple's commonly used third-party SDKs list, the SDK vendor is responsible for shipping a signed PrivacyInfo.xcprivacy inside the distribution. Firebase, Google Mobile Ads, Sentry, RevenueCat, and most of the larger React Native libraries already do this.
Your responsibility in app.json is the app-target manifest, which covers the union of every required reason API your code and dependencies hit. That means if you depend on a library that calls into UserDefaults, you still need NSPrivacyAccessedAPICategoryUserDefaults in your top-level config, even if the library declares it in its own pod manifest. Apple reads the app-target manifest first, and a missing category there is enough to trigger the ITMS-91056 warning at upload time.
For the SDKs that have not caught up with the signed-manifest rule (some smaller analytics and crash reporting libraries still fall here), the rejection comes back as ITMS-91061, which is a separate but related warning specifically about the SDK side. Replacing or updating the offending SDK is the only fix; you cannot patch another vendor's privacy declaration in your own config.
Why does the "Multiple commands produce" error happen and how do you fix it?
The error reads Multiple commands produce '...<AppName>.app/PrivacyInfo.xcprivacy' and shows up during the Xcode build step on EAS Build. It almost always means the manifest is being generated twice: once by the Expo config plugin from app.json, and once because a manual PrivacyInfo.xcprivacy file is already committed at ios/<YourApp>/.
The fix in a managed project is to delete the manual file and keep only the app.json config. Run rm ios/<YourApp>/PrivacyInfo.xcprivacy, then either run npx expo prebuild --clean locally to regenerate the iOS folder cleanly, or commit the deletion and let EAS Build regenerate ios/ on its end when ios/ is gitignored. Per the Expo SDK 50 release notes, the privacy manifest config plugin runs unconditionally when the field is present in app.json, so leaving both sources in place is the most common cause of this collision.
To verify the file made it into the IPA after a clean build, unzip the artifact and look for Payload/<YourApp>.app/PrivacyInfo.xcprivacy. The contents should match the plist version of your app.json config. For builders who want an external automated read of the build before submission, PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission scanning aligned with OWASP MASVS for no-code and vibe-coded apps, and it inspects the bundled PrivacyInfo.xcprivacy as part of the report.
What to watch out for
A few patterns that catch out managed Expo projects:
- Committing the ios folder "just to debug a build." Once that folder is in git, EAS stops running prebuild, and your app.json privacy config silently stops syncing.
- Adding tracking domains while NSPrivacyTracking is false. The manifest parser accepts the inconsistent state, but the App Privacy survey in App Store Connect flips into a confused mixed-tracking display.
- Declaring reason codes you do not actually qualify for. The build uploads, but the inaccurate declaration can surface during a later Guideline 5.1 review pass.
- Ignoring the warning email and waiting for outright rejection. Apple emails the missing-reason warning within minutes of processing per the May 2024 third-party SDK announcement. The email is the warning shot, not the rejection.
- Leaving SDK 49 without the polyfill plugin. Without
expo-privacy-manifest-polyfill-pluginfrom the Expo GitHub repository,expo.ios.privacyManifestsis silently ignored on SDK 49 and the manifest never gets written.
Key takeaways
- For an Expo managed project, the privacy manifest is config, not source. Keep it in app.json and let prebuild generate the file.
- The ios folder belongs in .gitignore. Committing it breaks EAS Build's ability to sync config changes.
- Five required reason categories cover most Expo apps. Match the codes to how the API is actually called.
- Apple sends ITMS-91056 warnings within minutes of upload when a category is missing. Treat the email as a warning shot, not the rejection.
- Some teams running fintech, health, or AI-coded apps outsource pre-submission verification to PTKD.com (https://ptkd.com) so the privacy manifest and the rest of the OWASP MASVS surface are checked before each App Store Connect upload, not after.



