◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Getting Started
◈ Getting Started/2026-10-19Intermediate

My plugins Array Was Empty, and Info.plist Still Had Nine Permission Strings

Even with an empty plugins array, every Expo package you install can add permission strings to Info.plist. I ran prebuild on SDK 58, counted them, and wrote the settings and a small audit script that remove them.

Expo221Info.plist7prebuild4iOS permissionsAndroid permissionspre-release checksRork591

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.

PackageVersion
expo58.0.14
react-native0.88.0
expo-location58.0.17
expo-camera58.0.10
expo-contacts58.0.7
expo-media-library58.0.10
expo-image-picker58.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.plist

Result: nine strings, all default English

KeyWhere it came from (what removed it)
NSCameraUsageDescriptionexpo-camera
NSContactsUsageDescriptionexpo-contacts (gone with contactsPermission: false)
NSLocationWhenInUseUsageDescriptionexpo-location
NSLocationAlwaysUsageDescriptionexpo-location
NSLocationAlwaysAndWhenInUseUsageDescriptionexpo-location
NSMotionUsageDescriptionexpo-location (it appears in the plugin source)
NSMicrophoneUsageDescriptionexpo-camera and expo-image-picker (gone once both set microphonePermission: false)
NSPhotoLibraryUsageDescriptionexpo-image-picker and expo-media-library
NSPhotoLibraryAddUsageDescriptionexpo-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 look

With 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.

  1. Search the code for calls to the camera, contacts, location and similar APIs.
  2. Take permissions the code never calls out of used-permissions.json and run the script.
  3. For each NG key, decide whether to uninstall the package or switch it off with false.
  4. 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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◈ Getting Started2026-10-15
Should You Ship a Web Version While the Store Reviews Your App? Choosing a Release Order for a Rork App With Three Questions
Web first or store first for an app built in Rork? I checked Rork's FAQ and Expo's docs: Rork Pro and Rork Max give you different options, and three questions help you decide the release order.
◈ Getting Started2026-09-14
Rork No Longer Creates New Expo Projects: Keep Yours, or Rebuild It in SwiftUI?
You can no longer start an Expo project in Rork, but existing ones keep building and shipping. Here is how to decide whether to stay on Expo or rebuild in SwiftUI, and what quietly goes missing when you rebuild.
◈ Getting Started2026-09-08
Whether You Need Rork Max Is Answered by Your Permissions, Not the Price Page
When I cannot decide between standard Rork and Rork Max, I stop reading the price page. Instead I pull every OS capability my app actually asks for out of app.json and Info.plist, then sort them into what Expo covers, what a config plugin covers, and what truly needs native Swift.