The plugins array in app.json was empty. I had not written a single line of code that shows a location prompt. Still, I generated the iOS project and opened Info.plist to see what was in it.
It listed strings for the camera, contacts, microphone, motion and the photo library. Every one was a default English sentence, something like "Allow $(PRODUCT_NAME) to access your camera".
When an AI builds your app, packages pile up with each feature. I wondered whether permission strings were piling up quietly alongside them, so I counted.
What I measured
I made an empty probe project and installed the packages below, using the versions npx expo install chose for Expo SDK 58.
| Package | Version |
|---|---|
| expo | 58.0.14 |
| react-native | 0.88.0 |
| expo-location | 58.0.17 |
| expo-camera | 58.0.10 |
| expo-contacts | 58.0.7 |
| expo-media-library | 58.0.10 |
| expo-image-picker | 58.0.10 |
app.json held only a name and a bundle identifier, with no plugins. I ran prebuild on Linux, so I added --no-install to skip pod install.
CI=1 npx expo prebuild --platform ios --no-install
grep -oE "<key>NS[A-Za-z]+UsageDescription</key>" ios/probe/Info.plistResult: nine strings, all default English
| Key | Where it came from (what removed it) |
|---|---|
| NSCameraUsageDescription | expo-camera |
| NSContactsUsageDescription | expo-contacts (gone with contactsPermission: false) |
| NSLocationWhenInUseUsageDescription | expo-location |
| NSLocationAlwaysUsageDescription | expo-location |
| NSLocationAlwaysAndWhenInUseUsageDescription | expo-location |
| NSMotionUsageDescription | expo-location (it appears in the plugin source) |
| NSMicrophoneUsageDescription | expo-camera and expo-image-picker (gone once both set microphonePermission: false) |
| NSPhotoLibraryUsageDescription | expo-image-picker and expo-media-library |
| NSPhotoLibraryAddUsageDescription | expo-media-library (gone with savePhotosPermission: false) |
What surprised me: an app that only needs location still gets four strings from that one package, including "always" and motion. Leaving expo-location out of plugins made no difference. The defaults of an installed package apply whether or not you list it.
I did not trace the mechanism. What I can say is that an empty plugins array and a list of five plugin names produced the same output. When I reduced the project to expo-location alone, the result shrank to four strings plus the reduced-accuracy dictionary key (NSLocationTemporaryUsageDescriptionDictionary), five keys in all.
Turning them off is package-specific
For location, this is what I used. It keeps only the "while using the app" permission and gives it a sentence in my own words.
{
"expo": {
"plugins": [
[
"expo-location",
{
"locationWhenInUsePermission": "We use your location to show stores near you.",
"locationAlwaysPermission": false,
"locationAlwaysAndWhenInUsePermission": false,
"motionUsagePermission": false
}
]
]
}
}After prebuilding again, the location keys were down to NSLocationWhenInUseUsageDescription. The "always" and motion strings were gone. The dictionary key for temporary full accuracy stayed. In the plugin source, locationFullAccuracyPermission: false appears to skip it, but I did not run that one.
If several packages are installed, turning one off may not be enough, because another package can add the same key. In my test, I switched off the camera in expo-image-picker, yet NSCameraUsageDescription stayed. The default from expo-camera was still applying.
The other route is to uninstall packages you do not use. A package installed for a feature that never shipped is the surest permission to remove, because it takes its strings with it.
Android has its own switch
On the same empty project, Android prebuild wrote 15 uses-permission lines. They included contacts read and write, audio recording, external storage and drawing over other apps.
To remove one, list it under android.blockedPermissions in app.json. I blocked the two contacts permissions and audio recording, then prebuilt again. All three stayed in the manifest, now marked tools:node="remove", which strips them when manifests are merged.
{
"expo": {
"android": {
"blockedPermissions": [
"android.permission.READ_CONTACTS",
"android.permission.WRITE_CONTACTS",
"android.permission.RECORD_AUDIO"
]
}
}
}A script that does the counting
Counting by eye does not last. So I wrote a small script that reads the generated Info.plist and compares it with a list of the permissions I actually use. It needs nothing but Node.
// plist-audit.mjs
// Usage: node plist-audit.mjs ios/<app-name>/Info.plist used-permissions.json
import { readFileSync } from "node:fs";
const [plistPath, usedPath] = process.argv.slice(2);
if (!plistPath || !usedPath) {
console.error("usage: node plist-audit.mjs <Info.plist> <used-permissions.json>");
process.exit(2);
}
const xml = readFileSync(plistPath, "utf8");
// Pick up only the <string> right after <key>NSxxxUsageDescription</key> (dictionaries are skipped)
const re = /<key>(NS\w+UsageDescription)<\/key>\s*<string>([^<]*)<\/string>/g;
const found = [...xml.matchAll(re)].map((m) => ({ key: m[1], text: m[2] }));
// A file listing only the permissions you use. Example: ["NSLocationWhenInUseUsageDescription"]
const used = new Set(JSON.parse(readFileSync(usedPath, "utf8")));
const isDefault = (t) => /^Allow \$\(PRODUCT_NAME\)/.test(t);
let bad = 0;
for (const { key, text } of found) {
const flags = [];
if (!used.has(key)) flags.push("possibly unused");
if (isDefault(text)) flags.push("still the default English text");
if (flags.length) bad++;
console.log(`${flags.length ? "NG" : "OK"} ${key}${flags.length ? " <- " + flags.join(" / ") : ""}`);
}
for (const k of used) {
if (!found.some((f) => f.key === k)) {
console.log(`?? ${k} <- you said you use it, but it is not in Info.plist (may crash at runtime)`);
}
}
console.log(`\n${bad} of ${found.length} need a look`);
process.exit(bad ? 1 : 0);used-permissions.json holds only the keys the app really needs. If the app uses location while in use and nothing else, it is a single line:
["NSLocationWhenInUseUsageDescription"]Run against the starting state (empty plugins, five packages), it printed this:
NG NSCameraUsageDescription <- possibly unused / still the default English text
NG NSContactsUsageDescription <- possibly unused / still the default English text
NG NSLocationAlwaysAndWhenInUseUsageDescription <- possibly unused / still the default English text
NG NSLocationAlwaysUsageDescription <- possibly unused / still the default English text
NG NSLocationWhenInUseUsageDescription <- still the default English text
NG NSMicrophoneUsageDescription <- possibly unused / still the default English text
NG NSMotionUsageDescription <- possibly unused / still the default English text
NG NSPhotoLibraryAddUsageDescription <- possibly unused / still the default English text
NG NSPhotoLibraryUsageDescription <- possibly unused / still the default English text
9 of 9 need a lookWith the setup narrowed to location and the sentence rewritten, it ended with a single OK. The exit code is 0 in that case, so you can put it in a pre-release routine.
One check before you delete anything
The audit cannot tell a permission you can drop from one you cannot. That is what the ?? line is for.
On iOS, calling a protected feature such as the camera or location without its purpose string ends the app. A feature you believe is unused may still be reached from some other screen.
Here is the order I follow.
- Search the code for calls to the camera, contacts, location and similar APIs.
- Take permissions the code never calls out of
used-permissions.jsonand run the script. - For each
NGkey, decide whether to uninstall the package or switch it off withfalse. - Rewrite the strings you keep so they say what that screen uses the permission for.
App Store Review Guideline 5.1.1 asks apps to state clearly why they request a permission. Shipping a pile of strings you cannot explain leaves you unable to explain them later. How review treats such cases, I do not know. What I do now is treat these strings as a place for permissions I have decided to keep, not a list that fills itself.
Note that this is a result for an Expo iOS project. A SwiftUI project from Rork Max handles permissions differently, and other package versions may change the default keys. Please read the numbers here as values from the version set above.
For what a regenerated native project does to your own edits, see Counting what prebuild --clean will erase before you upgrade to Expo SDK 57. For inspecting the app after it is built, Before You Ship: Go Looking for Your Own Keys Inside the Exported Expo Bundle covers it. If Apple has already flagged your Privacy Manifest, When Your Rork App Gets ITMS-91053 — A Practical Guide to Privacy Manifests and Required Reason APIs may help.
Before your next release, I would suggest running prebuild once and opening Info.plist. The count may be higher than you expect.