Privacy

    How do you add PrivacyInfo.xcprivacy to an Expo managed project?

    Expo app.json open in a code editor with the expo.ios.privacyManifests block visible next to an Xcode window showing the generated PrivacyInfo.xcprivacy file

    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.privacyManifests in app.json.
    • EAS Build skips prebuild when ios/ is in git. Add ios/ and android/ to .gitignore to 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 categoryCommon Expo triggerTypical reason code
    NSPrivacyAccessedAPICategoryUserDefaultsAsyncStorage, expo-secure-store fallbackCA92.1
    NSPrivacyAccessedAPICategoryFileTimestampexpo-image, expo-file-system cachingC617.1
    NSPrivacyAccessedAPICategorySystemBootTimeexpo-modules-core internals35F9.1
    NSPrivacyAccessedAPICategoryDiskSpaceexpo-file-system, photo uploadsE174.1
    NSPrivacyAccessedAPICategoryActiveKeyboardexpo-keyboard, custom keyboard apps3EC4.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-plugin from the Expo GitHub repository, expo.ios.privacyManifests is 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.
    • #expo
    • #privacy manifest
    • #ios
    • #app store
    • #eas build
    • #react native
    • #managed workflow
    • #apple

    Frequently asked questions

    Do I need to commit the ios folder if I want to debug a native crash?
    Not for the privacy manifest config. You can run npx expo run:ios locally to get a one-off iOS build for native debugging without committing the directory. The ios folder gets regenerated from app.json on each prebuild, so any local debugging changes are temporary. If a permanent native change is needed, the right path is a custom config plugin, not a committed ios directory.
    What happens if my Expo SDK is 49 or older?
    SDK 49 and earlier do not have privacy manifest support in @expo/config-plugins. The fix is the expo-privacy-manifest-polyfill-plugin from Expo's GitHub, which reads the same expo.ios.privacyManifests schema and writes PrivacyInfo.xcprivacy at prebuild. Install it with npx expo install expo-privacy-manifest-polyfill-plugin and add the name to the plugins array in app.json. Upgrading to SDK 50 later does not require config changes.
    Does Apple actually check the privacy manifest at processing time?
    Yes, at processing time, not at human review. After an IPA finishes processing in App Store Connect, Apple's automated layer parses PrivacyInfo.xcprivacy and matches reason categories against the static analysis of API calls in the binary. Missing reasons trigger an email warning quoting ITMS-91056 within a few minutes. That warning becomes a hard rejection on the next upload if it is not addressed.
    Can one config support both bare and managed Expo apps in the same repo?
    Technically yes, but it costs you the automation. A managed project keeps the manifest in app.json and ignores ios/. A bare project keeps a hand-edited PrivacyInfo.xcprivacy in ios/. Trying to support both means choosing one as the source of truth and ignoring the other, which usually drifts. Most teams pick the workflow per repo and accept that the choice locks in for the project's life.
    Why does prebuild keep regenerating my manifest with extra entries?
    Because @expo/config-plugins merges entries from your app.json with whatever installed config plugins request. Some Expo modules register their own privacy manifest contributions during prebuild. If you see entries you did not declare, check the plugin list in your app.json and the changelogs for any module that bumped versions recently. The merge logic is additive, so removing the entries by hand will not persist across prebuilds.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free