The morning after I submitted a wallpaper app update, App Store Connect had a red badge on it. Guideline 5.1.1, three lines of text. No key name. No file to open.
The build was fine. TestFlight had it. The previous update had gone through with nearly the same configuration.
The first hour of that day went entirely into figuring out what I had been told. If you are stuck in the same place, here is the path I took.
An automated analysis of the submission indicates the app includes placeholder or otherwise insufficient purpose strings
The message reads exactly like this:
An automated analysis of the submission indicates the app includes placeholder or otherwise insufficient purpose strings. Purpose strings must clearly and completely describe your use of data.
Two phrases carry the meaning.
The first is automated analysis. No reviewer opened your app. A machine read the Info.plist inside the binary you uploaded. Rejections with this wording started showing up in volume around July 20, 2026.
The second is placeholder or otherwise insufficient. Nothing here says the strings are missing. They are present, and their contents fall short.
That is what separates this from ITMS-90683: Missing Purpose String in Info.plist, which arrives during upload processing. One is absence, the other is inadequacy, and the fixes have nothing in common. If you are looking at the absence case instead, start with When your Rork iOS build gets rejected with ITMS-90683.
The awkward part is that the automated pass names no key. Which NS...UsageDescription tripped it is yours to work out.
Those default strings are not something you forgot to write
In an Expo project generated by Rork, adding a library that touches photos or the camera also adds the matching usage description for you. A mechanism called a config plugin writes it into the Info.plist at build time.
The default for expo-image-picker looks like this:
{
"expo": {
"plugins": [
["expo-image-picker", { "photosPermission": "Allow $(PRODUCT_NAME) to access your photos" }]
]
}
}Leave photosPermission unset and that sentence ships. $(PRODUCT_NAME) is substituted with your app name during the build, so the binary ends up carrying something like Allow Wallpaper Studio to access your photos.
It reads like English, which is precisely the problem. The field is not blank, so it feels handled. To an automated pass, it is a textbook placeholder.
What actually caught me was one step earlier than that.
The wallpaper app in question only saves generated images to Photos. It has no feature that reads the library at all, so NSPhotoLibraryAddUsageDescription should have been the only key present. The Info.plist also carried NSPhotoLibraryUsageDescription — the read permission — as a side effect of the library I had installed.
The automated pass also flags resources your app declares but never uses. However carefully you word a string, it cannot describe a use that does not exist. Before rewriting anything, I had to decide whether the key belonged there at all.
Look at what is actually in the Info.plist
Reading app.json will not tell you what finally gets written. Generate the native side once and inspect the real file.
npx expo prebuild --platform ios
grep -A1 "UsageDescription" ios/*/Info.plistOne caution. From Expo SDK 57 onward, expo prebuild discards and regenerates ios/ and android/ by default. If you have hand-edited anything in those directories, write down what you added before you run it. For a project still in the shape Rork generated, go ahead.
The output comes back looking like this:
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow Wallpaper Studio to access your photos</string>
--
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Used to save the wallpaper you selected into your Photos album…</string>
--
<key>NSCameraUsageDescription</key>
<string>Allow Wallpaper Studio to access your camera</string>
Knowing which library brings which key along makes the search much faster. These are the pairings I run into most often.
| Library | Keys it may inject |
|---|---|
| expo-image-picker | NSPhotoLibraryUsageDescription / NSPhotoLibraryAddUsageDescription / NSCameraUsageDescription / NSMicrophoneUsageDescription |
| expo-media-library | NSPhotoLibraryUsageDescription / NSPhotoLibraryAddUsageDescription |
| expo-camera | NSCameraUsageDescription / NSMicrophoneUsageDescription |
| expo-location | NSLocationWhenInUseUsageDescription / NSLocationAlwaysAndWhenInUseUsageDescription |
| expo-tracking-transparency | NSUserTrackingUsageDescription |
| expo-local-authentication | NSFaceIDUsageDescription |
Adding what you thought was only a photo picker can bring camera and microphone strings with it, because the library exposes both entry points. That is not a misconfiguration on your side. It still reads to the automated pass as a resource you declared and never use.
Some of these are obvious on sight. Others are a judgment call, and once the list grows past a handful, reading with your eyes starts missing things. I handed the judgment to a script.
Flag the defaults automatically
No dependencies, short enough to read in one sitting. It pulls every NS...UsageDescription out of the Info.plist and compares each value against the known default patterns.
import { readFileSync } from "node:fs";
// Strings Expo config plugins write by default, and that tend to survive to submission
const PLUGIN_DEFAULTS = [
/^Allow .+ to access your photos$/i,
/^Allow .+ to access your camera$/i,
/^Allow .+ to access your microphone$/i,
/^Allow .+ to use your location$/i,
/^Allow .+ to access your (contacts|calendar|reminders)$/i,
/^Allow .+ to save photos$/i,
];
// Sentences that state access without stating a reason
const NO_REASON = [
/^(this app|the app) (needs|requires|uses) access to your [^.]+\.?$/i,
/^(for|to) (better|improve) (user )?experience\.?$/i,
];
const plistPath = process.argv[2] ?? "ios/Info.plist";
const xml = readFileSync(plistPath, "utf8");
const pairs = [];
const re = /<key>(NS[A-Za-z]*UsageDescription)<\/key>\s*<string>([\s\S]*?)<\/string>/g;
let m;
while ((m = re.exec(xml)) !== null) {
pairs.push({ key: m[1], value: m[2].trim() });
}
let flagged = 0;
for (const { key, value } of pairs) {
const reasons = [];
if (PLUGIN_DEFAULTS.some((r) => r.test(value))) reasons.push("plugin default, unchanged");
if (NO_REASON.some((r) => r.test(value))) reasons.push("no stated purpose");
if (value.length < 25) reasons.push("too short (under 25 characters)");
if (reasons.length > 0) {
flagged++;
console.log(`FLAG ${key}`);
console.log(` "${value}"`);
console.log(` -> ${reasons.join(" / ")}`);
} else {
console.log(`OK ${key}`);
}
}
console.log(`\n${flagged} of ${pairs.length} strings need rewriting.`);
process.exit(flagged > 0 ? 1 : 0);Run it with node audit-purpose-strings.mjs ios/YourApp/Info.plist. Against the Info.plist on my machine it printed this:
FLAG NSPhotoLibraryUsageDescription
"Allow Wallpaper Studio to access your photos"
-> plugin default, unchanged
OK NSPhotoLibraryAddUsageDescription
FLAG NSCameraUsageDescription
"Allow Wallpaper Studio to access your camera"
-> plugin default, unchanged
FLAG NSMicrophoneUsageDescription
"This app needs access to your microphone."
-> no stated purpose
FLAG NSLocationWhenInUseUsageDescription
"Allow Wallpaper Studio to use your location"
-> plugin default, unchanged
4 of 6 strings need rewriting.
It exits 1 while anything is still flagged and 0 when the list is clean, so you can drop it straight into CI.
The 25-character floor is not a rule from Apple. It is roughly the length at which a sentence has room to say what the data is for, and it exists to make a human look again at whatever it catches.
Rewrite around resource, purpose, and boundary
Apple asks you to clearly and completely describe your use of data. Split that into three parts and you have a template.
| Key | Before | After |
|---|---|---|
| NSPhotoLibraryAddUsageDescription | Allow $(PRODUCT_NAME) to access your photos | Used to save the wallpaper you selected into your Photos album. The app never reads photos already in your library. |
| NSCameraUsageDescription | Allow $(PRODUCT_NAME) to access your camera | Used to take a profile photo without leaving the app. Photos you take are stored on this device only. |
| NSUserTrackingUsageDescription | This app needs access to your data. | Used only to limit how often the same ad is shown on this device. Browsing history is not collected or sold to third parties. |
The shape is the same every time: which resource, for what purpose, and where it stops. That third clause does more work than it looks. Never reads, never uploads, never shares — there is usually something true and specific you can promise.
In app.json, the change looks like this:
{
"expo": {
"plugins": [
["expo-image-picker", {
"photosPermission": false,
"savePhotosPermission": "Used to save the wallpaper you selected into your Photos album. The app never reads photos already in your library.",
"cameraPermission": false
}]
]
}
}Passing false keeps that string out of the Info.plist entirely. For a resource your app does not use, the right answer is not a better sentence — it is removing the declaration.
There is one more trap if you ship in more than one language. app.json holds a single string per key; translations live in InfoPlist.strings files per locale. Rewrite the Japanese carefully, leave the English default in place, and the same rejection comes back the moment the app is checked in an English locale. That cost me an extra round trip once.
What needs rewriting is not the language App Review happens to use. It is every language you ship.
Send one line back with the resubmission
Once the corrected build is uploaded, I add a short note in the reply box in App Store Connect. There is no need to argue the case.
"Removed the unused photo library read declaration and replaced the save permission string with a specific description of its use."
Name what you changed and why, in one sentence, against what the automated pass reported. It saves the reviewer a lookup. Since I started writing it this way, the back-and-forth has settled in a single round.
Before your next submission, run audit-purpose-strings.mjs once. It finishes instantly, which is not a comparison you can make with the time spent hunting for a cause after a rejection lands.
If you want a wider set of pre-submission checks, The four acceptance checks I still run after the build turns green covers finding defects in the artifact itself. For rejections organized by guideline, A field manual for App Store rejections on Rork-built apps is the closer match.
For a long time I treated purpose strings as boxes to fill so a submission would pass. I think of them differently now: one line, in the user's hands, saying what the app does and where it stops.