When an Expo iOS build clears archive in EAS, the next failure that catches teams is the ITMS-91053 email. App Store Connect lists NSPrivacyAccessedAPICategoryUserDefaults under "Missing API declaration" and asks for an approved reason. The Required Reason API rule is not new, but the wording of the email and the location of the fix in an Expo project trip up developers who never wrote a line of Objective-C. The fix is small, but the reason code matters.
Short answer
The Required Reason API for UserDefaults is configured in Expo through the privacyManifests block in app.json. Apple has enforced this for every iOS submission since May 1, 2024. Three reason codes are valid: CA92.1 for data that stays inside the app, 1C8F.1 for App Group sharing with widgets or App Clips, and C56D.1 for SDK wrapper code. Picking the wrong code triggers ITMS-91053 again on the next upload. Apple documents the rule under TN3183 on adding required reason API entries.
What you should know
- NSUserDefaults sits on Apple's Required Reason API list. Enforcement began on May 1, 2024 for new submissions and updates.
- The category name in the manifest is NSPrivacyAccessedAPICategoryUserDefaults. It is one of several categories Apple flags during App Store Connect ingestion.
- Three reason codes exist for UserDefaults: CA92.1, 1C8F.1, and C56D.1. Pick the one that matches how the app actually uses the API, or list more than one when both apply.
- Expo configures the manifest in app.json, under
expo.ios.privacyManifests. The Expo prebuild pipeline writes the resulting PrivacyInfo.xcprivacy into the native iOS project. - Static CocoaPods can ship their own manifest, but Apple does not always parse them. That is why your own manifest may still need an entry that already exists in a library.
- The ITMS-91053 email lists every flagged category in one block. The fix is one consolidated update to app.json plus a rebuild, not several round trips.
What does the Required Reason API actually require for UserDefaults?
The Required Reason API rule is a string-level declaration: Apple wants to see a reason code attached to every category of API your app touches. NSUserDefaults sits in that list alongside file timestamps, disk space, system boot time, and the active keyboard. The category name inside the manifest is NSPrivacyAccessedAPICategoryUserDefaults. Apple documents the rule in TN3183 on adding required reason API entries and in the Privacy manifest files reference.
The scanner reads strings, not call sites. If your compiled Mach-O binary references the UserDefaults selectors, the ingestion service expects to find a matching entry in your bundled PrivacyInfo.xcprivacy. The check happens during App Store Connect ingestion, the same layer that emits the ITMS-90338 and ITMS-91022 codes for other policies. You see the warning within minutes of an Xcode or EAS upload.
Apple has enforced the rule for every submission since May 1, 2024. Before that date, builds without a manifest entry were accepted with a warning email. After that date, the email blocks the build from reaching TestFlight and the App Store. Builds you already published do not retroactively need the file, but the next update does.
NSUserDefaults is broad. Any call to [NSUserDefaults standardUserDefaults], UserDefaults.standard, or any registered suite triggers the rule. React Native code that calls AsyncStorage on iOS sits on top of NSUserDefaults in older releases, so even a project that never writes Objective-C is exposed.
Which UserDefaults reason code should you use in Expo?
Apple defines three reason codes for NSPrivacyAccessedAPICategoryUserDefaults. They are usually mutually exclusive in a single project, but they can be combined when the app legitimately falls into more than one category.
| Reason code | What it permits | What it disallows |
|---|---|---|
CA92.1 | Read or write UserDefaults values only the app itself reads | Sharing the same suite with widgets, App Clips, or extensions through an App Group |
1C8F.1 | Share UserDefaults across apps, widgets, App Clips, and extensions in the same App Group | Cross-vendor reads from apps outside the App Group |
C56D.1 | A third-party SDK wraps NSUserDefaults, and the app only reaches the API through the SDK wrapper | The SDK using UserDefaults for its own purposes or sending values off-device |
The default for a standalone Expo app with no widget is CA92.1. If the project ships an iOS widget through a custom Xcode target and shares preferences with the host app through an App Group, switch to 1C8F.1. If you ship an SDK that other developers embed and your SDK touches UserDefaults inside its own wrapper functions, list C56D.1.
Picking the wrong code is silent at first. The build passes ingestion and reaches TestFlight. Apple's review or a later enforcement pass can flag the bundle when the runtime behavior contradicts the declared code. Declaring CA92.1 and then writing to an App Group suite for a widget extension is technically a mismatch. The MszPro reference on ITMS-91053 documents both codes side by side with worked examples.
How do you configure the Required Reason API in Expo's app.json?
Expo exposes the manifest through the ios.privacyManifests field in app.json or app.config.ts. The Expo prebuild pipeline writes the resulting PrivacyInfo.xcprivacy into the native iOS project before EAS or expo run:ios compiles. The shape mirrors the plist format Apple expects.
{
"expo": {
"ios": {
"privacyManifests": {
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
}
]
}
}
}
}
The official Expo guide on Privacy manifests shows the same block. The array NSPrivacyAccessedAPITypeReasons can hold multiple codes when both apply, for example ["CA92.1", "1C8F.1"] for an app that mixes app-only and App Group storage.
Two extra steps tighten the configuration. Run npx expo install --fix before resubmitting; the command upgrades Expo SDK libraries to the version that already includes their own PrivacyInfo file, and Apple parses framework-level manifests too. Sync the manifest with any other Required Reason categories your app uses, because the same NSPrivacyAccessedAPITypes array carries entries for file timestamps (NSPrivacyAccessedAPICategoryFileTimestamp), disk space (NSPrivacyAccessedAPICategoryDiskSpace), and system boot time (NSPrivacyAccessedAPICategorySystemBootTime). The ITMS-91053 email lists every flagged category in one block, so the fix is one consolidated update, not several round trips.
After editing app.json, rebuild with EAS (eas build --platform ios) and confirm the resulting .ipa contains a PrivacyInfo.xcprivacy file at the bundle root. The Expo prebuild step writes the file as part of the native project, not at runtime.
What happens when a third-party library accesses UserDefaults?
The harder cases are libraries you did not write. Many React Native and Expo libraries call UserDefaults indirectly: @react-native-async-storage/async-storage historically used UserDefaults on iOS before switching to a sqlite-backed implementation, expo-secure-store falls back to UserDefaults for non-sensitive values, push-notification SDKs cache tokens in standardUserDefaults, and analytics SDKs cache opt-out flags there.
Apple's intent is that each library ships its own PrivacyInfo.xcprivacy. In practice, the Expo May 2024 privacy manifest tracking issue notes that Apple does not parse manifests bundled with static CocoaPods libraries reliably. The library is technically compliant, but the ingestion service does not always read the file. The workaround is to include the reason code at the app level too, even when the library already declares it.
To find which libraries in your project touch UserDefaults, grep node_modules for the symbol:
grep -R "NSUserDefaults\|UserDefaults.standard" \
node_modules/*/ios 2>/dev/null
Or check for shipped manifests:
find node_modules -name "PrivacyInfo.xcprivacy"
For libraries that ship a manifest but still trigger ITMS-91053, the safe path is to copy the reason code into the app-level privacyManifests block. For libraries that ship no manifest, open an issue against the library and add a C56D.1 reason at the app level as a stopgap. The React Native Privacy Manifest discussion #776 keeps the canonical list of which libraries have shipped fixes.
How do you confirm the Required Reason API entry is correct?
Three checks catch mistakes before the next App Store Connect upload.
First, dump the bundled manifest from the EAS artifact. After eas build, download the .ipa and unzip it. The PrivacyInfo.xcprivacy file sits at Payload/<App>.app/PrivacyInfo.xcprivacy. The file is a plist; convert it with plutil -convert xml1 -o - PrivacyInfo.xcprivacy and read the NSPrivacyAccessedAPITypes array. The category and reason code should match what you wrote in app.json.
Second, run the open-source check_ios_required_reason_api_for_privacy_manifest tool over the unzipped bundle. The script walks every framework and lists which ones reference UserDefaults selectors and whether they ship a manifest. The output is faster than reading every nested PrivacyInfo file by hand.
Third, upload a TestFlight build and read the email Apple sends. The full ITMS-91053 list arrives within minutes for builds with missing entries, and the email enumerates every API category that still lacks a declaration. The presence of the email is itself a check; a clean upload means the manifest was parsed and accepted.
Some teams add an external scan to the same pre-submission step. PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission analysis of compiled iOS binaries against the OWASP MASVS controls, which sit next to but do not replace the Required Reason API check.
What to watch out for
- Static CocoaPods manifest parsing is unreliable. Apple's own ingestion does not always read a manifest bundled with a static library. Include the reason code at the app level even when the library already declares it.
CA92.1and App Group access do not mix. If your app reads or writes a suite shared with a widget, switch to1C8F.1. A mismatch is a documentation issue, not a runtime crash, and Apple can flag it on review.- The Required Reason API check is not a security audit. It does not measure whether the values written to UserDefaults are sensitive, encrypted at rest, or excluded from iCloud Backup.
- The list of Required Reason API categories can grow. Apple has added categories after the May 2024 cutoff, so a build that passed last quarter can fail this quarter on the same code.
- AsyncStorage on iOS historically sat on top of UserDefaults. Even a project that never writes Objective-C is exposed in older releases.
Key takeaways
- The Required Reason API for UserDefaults is declared at the app level in Expo, inside
expo.ios.privacyManifests, with one of three reason codes:CA92.1,1C8F.1, orC56D.1. CA92.1covers a standalone app with no widget or App Group.1C8F.1covers App Group sharing with widgets, App Clips, or extensions.C56D.1covers third-party SDK wrappers.- Apple's parsing of static CocoaPods manifests is unreliable, so the safe practice is to include the reason at the app level even when a library declares it.
- The ITMS-91053 email lists every flagged category in one block, including UserDefaults. The fastest fix is one consolidated update to app.json plus
npx expo install --fixbefore the next EAS build. - For teams who want an external read of the IPA against OWASP MASVS controls in the same pre-submission step, PTKD.com (https://ptkd.com) runs storage, network, and platform checks that the Required Reason API gate does not look at.




