Auditing the permissions your config plugins added
Your permission surface is a function of your dependency list, and most teams have never read it.
Config plugins add permission strings during prebuild. Install a package that needs the camera and NSCameraUsageDescription appears in the generated Info.plist without anyone typing it. That is a genuinely good feature, right up until the feature is removed and the package is not.
Now your app declares a purpose string for a capability it never exercises. App Review reads the shipped Info.plist, not your intent — and a permission with no visible feature invites both a Guideline 5.1.1 data-minimisation question and, for background modes, a Guideline 2.5.4 one.
The audit is fifteen minutes and it should happen before every submission, because the surface changes when your lockfile changes rather than when your code does.
Step 1: read what actually ships
Not the repository. The generated output.
npx expo prebuild --clean --no-install
# iOS: every purpose string in the built Info.plist
/usr/libexec/PlistBuddy -c "Print" ios/*/Info.plist \
| grep -B0 -A1 -i 'UsageDescription'
# iOS: background modes
/usr/libexec/PlistBuddy -c "Print :UIBackgroundModes" ios/*/Info.plist 2>/dev/null
# Android: the MERGED manifest, which is what ships
find android -path '*intermediates*' -name 'AndroidManifest.xml' | head -1 \
| xargs grep -E 'uses-permission|foregroundServiceType'The Android merged manifest is the important one and the one people skip. It is your manifest combined with every library's, and it is where permissions you never declared appear.
Step 2: one sentence per permission
For each entry, answer this in a single sentence:
Which user-facing feature stops working if this is removed?
Write the answers down. You need them twice anyway: once for the decision below, and once for your App Review notes.
| Answer | What it means |
|---|---|
| A specific screen and action | Keep it. Put the sentence in your review notes. |
| "A library needs it" | Not an answer. Find out which library and why. |
| "We used to have that" | Remove it. |
| "I do not know" | Remove it and see what breaks. That is cheaper than a rejection cycle. |
An unused permission is not a neutral leftover. It is a question you have invited a reviewer to ask, and one you cannot answer well.
Step 3: which plugin put it there
# what is installed
grep -oE '"(expo-[a-z-]+|react-native-[a-z-]+|@[a-z-]+/[a-z-]+)":' package.json | tr -d '":'
# which packages ship a config plugin at all
ls node_modules/*/app.plugin.js node_modules/@*/*/app.plugin.js 2>/dev/null \
| sed 's|node_modules/||;s|/app.plugin.js||'
# your own plugin list
grep -A40 '"plugins"' app.json app.config.js 2>/dev/nullThe usual culprits: expo-camera, expo-location, expo-image-picker, expo-media-library, expo-notifications, expo-contacts, expo-av, expo-tracking-transparency, plus community packages wrapping the same capabilities.
Step 4: fix it at the source
Three legitimate routes, in order of preference.
Remove the package. If the feature is gone, so should the dependency be. This also removes it from your privacy-manifest surface, which is a second benefit. Which dependencies need a privacy manifest.
Configure the plugin. Most permission-adding plugins accept options, including for the purpose string itself:
{
"expo": {
"plugins": [
["expo-location", {
"locationWhenInUsePermission": "MileTruth uses your location to detect drive sessions automatically."
}]
]
}
}Writing your own purpose strings is worth doing regardless of the audit. The default strings that plugins ship are generic, and a generic purpose string is a worse user prompt and a weaker answer to a reviewer than one naming the actual feature.
Write a small config plugin to strip a key you genuinely cannot remove at source. Last resort, but it is the only version of "delete the key" that survives prebuild.
What is not a route: editing ios/YourApp/Info.plist or android/app/src/main/AndroidManifest.xml. Those are build artefacts. The fix works locally, vanishes when EAS runs prebuild, and produces an identical second rejection.
Step 5: write the sentences into review notes
You already produced them in step 2.
PERMISSIONS
Location "Always": the app detects drive sessions automatically while
closed. Without it the core feature does not function. The app remains
usable if declined; sessions can be started manually from the home screen.
Camera: used only in Profile > Add photo.A permission with a stated user-facing reason and a stated graceful degradation is a permission that does not generate a question. Review notes that work.
The Android half
Every dangerous permission in the merged manifest needs either a Data safety row or a defensible reason it collects nothing. Sensitive ones go further: ACCESS_BACKGROUND_LOCATION requires a Permissions Declaration in Play Console with a written justification and a demonstration video, and Google's expectation is the least invasive option that satisfies the use case.
Foreground service types must match actual behaviour and be declared. A library adding a foreground service you did not know about is a live policy problem rather than a curiosity. Google Play policy for RN apps.
Do it on the lockfile, not the calendar
The trigger for this audit is a dependency change, not a release date. Adding one package can add a permission, a privacy-manifest requirement and a Data safety row simultaneously, and none of those appear in your diff.
shipcheck runs the whole audit statically: reads app config, config plugins, the generated Info.plist, the merged Android manifest and node_modules, and reports permissions with no corresponding code path, background modes with no matching capability, and fixes applied in generated directories that prebuild will overwrite. Locally, before you build, inside the Claude Code session you already have open. What it checks.
Questions and answers
Why does my Expo app request permissions I never added?
Config plugins add them during prebuild. Installing a package that needs the camera typically injects NSCameraUsageDescription into the generated Info.plist and the corresponding permission into the Android manifest. If you later remove the feature but keep the package, the declaration stays.
How do I remove a permission from an Expo app?
At the source. Uninstall the package that adds it, or configure the plugin's options in app.json or app.config.js, or write a small config plugin that strips the key. Editing ios/YourApp/Info.plist directly does not survive the next prebuild.
Where do I see the permissions my Expo app actually ships with?
Run prebuild and read the generated ios Info.plist and the merged AndroidManifest under android build intermediates. Those are what ship. The files in your repository before prebuild are not the whole picture.
Does an unused permission cause an App Store rejection?
It can. Apple's data-minimisation expectations under Guideline 5.1.1 make requesting a permission the app does not need a live rejection reason, and Guideline 2.5.4 specifically addresses declaring the location background mode without a legitimate persistent-location feature.
Do unused permissions affect Google Play?
Yes. Dangerous permissions in the merged manifest need to be reflected in your Data safety declaration or justified as not collecting data, and sensitive permissions such as background location require a separate Permissions Declaration with a demonstration video.
shipcheck. “Auditing the permissions your config plugins added.” Baker Ventures LLC, September 6, 2026. https://shipcheck.bakerventuresstudio.com/guides/expo-config-plugin-permission-audit/