Handling Mobile Photo Permissions to Meet App Store Compliance (React Native & Expo)

A practical guide to scoping photo/media access in React Native and Expo apps so Google Play and the App Store don’t reject your release.


1. Overview / Introduction

Almost every production mobile app touches the user’s photo library at some point — a profile picture, a KYC document, a proof-of-delivery snap, a support-ticket attachment. On the surface it is a solved problem: install a picker library, add a permission, ship.

That assumption is what gets releases rejected today.

Both stores have moved from “ask for permission and explain why” to “prove you cannot do this with a system picker.” On Android, READ_MEDIA_IMAGES and READ_MEDIA_VIDEO are restricted permissions under Google Play’s Photo and Video Permissions policy — full enforcement has been in effect since 28 May 2025. On iOS, a full-library NSPhotoLibraryUsageDescription invites Guideline 5.1.1 scrutiny when a system picker would have sufficed.

The trap specific to React Native and Expo is that you frequently do not write the offending permission yourself. The Android manifest merger unions permissions from every native dependency in your tree, and Google reviews the merged manifest inside the AAB you upload — not the file in your repo. A picker library, a chat SDK, or a file-system helper can add a restricted permission on your behalf, and the first you hear of it is a policy rejection email.

This post covers where these permissions come from in an RN/Expo codebase, which packages are the usual sources, how to scope access down to what your feature actually needs, and how to verify the result before you upload.

Applies to: any React Native (bare or CLI) or Expo (managed or prebuild) app that targets Android API 33+ and lets users pick, capture, or save images.


2. Problem Statement

The concrete failure mode looks like this:

Issue found: Your app uses the READ_MEDIA_IMAGES / READ_MEDIA_VIDEO permission, which is restricted to apps whose core functionality requires broad access to photos or videos. Permission use is not directly related to your app’s core purpose.

The underlying problems are four:

  1. Broad-by-default access. Legacy pickers read the entire media library to render their own grid. If the user only ever needs to attach one ID card, you have still requested access to every photo on the device.
  2. Permissions you never declared. Your AndroidManifest.xml may be clean while the merged manifest is not. Dependencies inject permissions silently.
  3. Copy-paste from READMEs. Several popular libraries declare nothing themselves but instruct you to paste a restricted permission block into your app manifest. The permission is yours on paper, and the review treats it that way.
  4. Stale releases across tracks. Play evaluates all active version codes in all tracks — internal, closed, open, production. A dormant internal-testing build with the old permission keeps you non-compliant even after you fix production.

There is no runtime symptom. Everything works on-device. The failure is entirely at review time, and it typically lands at the worst possible moment in a release cycle.


3. Initial Approaches Tried (What Didn’t Work)

3.1 “Just declare the permission and write a good rationale”

The first instinct is to keep the existing picker and justify the permission — a clear in-app pre-prompt, a well-written purpose string, a Play Console declaration form.

Why it failed: the policy is a core-purpose test, not a disclosure test. Google’s guidance is explicit that apps with custom pickers are not automatically qualified to use the permission. A photo-management or gallery product qualifies. An app that attaches a document to a form does not, however good the rationale. Submissions were rejected with “not directly related to your app’s core purpose.”

3.2 Requesting the permission only at the moment of use

Deferring the runtime request until the user actually taps Attach is good UX and made no difference whatsoever. Play reviews the manifest declaration, not the call site. A permission you declare but never request is still a declared restricted permission.

3.3 Blanket-removing every media permission from the app manifest

Stripping READ_MEDIA_* with tools:node="remove" and shipping, without changing the picker.

Why it failed: it half-worked and hid the real issue. On Android 13+ the modern pickers do not need the permission at all, so most flows kept working — but any code path still calling a library-enumeration API (getPhotos()-style) silently returned empty or threw on older devices. Worse, the removal masked the fact that a dependency was still the source, so the next dependency upgrade reintroduced it.

3.4 Auditing the repo manifest instead of the build output

grep READ_MEDIA android/app/src/main/AndroidManifest.xml came back clean, so the team assumed the rejection was a review error and resubmitted. Twice.

Why it failed: the merged manifest is a different artifact. Only the merger report names the contributing library.

3.5 Pinning old library versions to “avoid churn”

Several of the offending packages had already fixed themselves in a later major. Staying pinned kept the violation in place while the fix sat one upgrade away.


4. Why This Final Approach

The approach that actually clears review inverts the question. Instead of “how do we justify broad access?”, ask “what is the narrowest capability this feature needs?” — and then pick the API that grants exactly that.

For the overwhelming majority of real use cases (profile photo, document upload, attachment, scan-and-capture), the narrowest capability is “the user hands me one file” — which needs no permission at all on either platform.

The chosen model:

Capability needed Android iOS Permission
User picks image(s) Android Photo Picker (PickVisualMedia) PHPickerViewController none
User picks a non-media file Storage Access Framework (ACTION_OPEN_DOCUMENT) UIDocumentPickerViewController none
Capture with camera ACTION_IMAGE_CAPTURE / CameraX UIImagePickerController CAMERA / NSCameraUsageDescription only
Save a file we produced MediaStore insert Photos add-only none on API 29+ / NSPhotoLibraryAddUsageDescription
Enumerate the whole library READ_MEDIA_IMAGES + declaration form NSPhotoLibraryUsageDescription restricted — avoid unless it is your product

Why this addresses the earlier failures:

  • It is out-of-process. Both system pickers run in a separate process and hand your app URIs for the selected items only. There is nothing to justify, because there is nothing broad being granted.
  • It removes the declaration entirely rather than defending it — the only reliably reviewable outcome.
  • It is not a downgrade in reach. The Android Photo Picker is present on Android 11+ via system module updates and is backported through Google Play services down to API 19, with automatic fallback to ACTION_OPEN_DOCUMENT where unavailable.
  • UX improves. Users see a familiar system sheet with no permission dialog. The best permission prompt is the one that never appears.

Broad access is kept as a deliberate, documented exception for the one case that genuinely requires it — a media-library product — not as the default.


5. Implementation Details

5.1 Step one: find out what is actually in your build

Never start from your source manifest. Build the release artifact and read the merger report.

# React Native / bare
cd android && ./gradlew :app:processReleaseManifest

# The merged result
cat app/build/intermediates/merged_manifests/release/AndroidManifest.xml | grep uses-permission

# The report that names the guilty library
grep -n -E "READ_MEDIA|EXTERNAL_STORAGE" \
  app/build/outputs/logs/manifest-merger-release-report.txt

Lines read ADDED from [:some-library] .../AndroidManifest.xml:12:5-79. That bracket is your answer.

For Expo, generate the native project first, then do the same:

npx expo prebuild --clean --platform android
cd android && ./gradlew :app:processReleaseManifest

Verify the artifact you upload — this is the ground truth Play scans:

bundletool dump manifest --bundle=app-release.aab \
  --xpath=/manifest/uses-permission/@android:name

Android Studio’s Merged Manifest tab (bottom of the manifest editor) gives the same attribution interactively.

5.2 Step two: choose permission-free pickers

React Native (bare / CLI)

react-native-image-picker v8.x is the reference implementation: it declares zero permissions, uses PickVisualMedia on Android and PHPickerViewController on iOS, and ships a PrivacyInfo.xcprivacy.

npm i react-native-image-picker

import { launchImageLibrary, launchCamera } from 'react-native-image-picker';

// No permission request needed — the system picker is out-of-process.
const pickProfilePhoto = async () => {
  const result = await launchImageLibrary({
    mediaType: 'photo',
    selectionLimit: 1,
    quality: 0.8,
    includeBase64: false,
  });
  if (result.didCancel || result.errorCode) return null;
  return result.assets?.[0] ?? null;
};

// Camera capture — this one DOES need CAMERA / NSCameraUsageDescription.
const captureDocument = () =>
  launchCamera({ mediaType: 'photo', saveToPhotos: false, quality: 0.8 });

Two configuration notes:

  • If minSdkVersion < 30, add implementation("androidx.activity:activity:1.9.+") to android/app/build.gradle so the backported picker is available.
  • saveToPhotos: true requires WRITE_EXTERNAL_STORAGE on API ≤ 28 only — declare it capped (see 5.4). Prefer false and write via MediaStore.

For non-media files, use @react-native-documents/picker (the maintained successor to react-native-document-picker). It is pure Storage Access Framework and declares no permissions.

Expo

Use expo-image-picker at ≥ 15.1.0. Versions 14.3.2 – 15.0.7 hardcoded READ_MEDIA_IMAGES and READ_MEDIA_VIDEO into the module manifest; they were removed in expo/expo#31902 precisely because the permissions became restricted.

import * as ImagePicker from 'expo-image-picker';

const pickProfilePhoto = async () => {
  // No requestMediaLibraryPermissionsAsync() call — the picker needs none.
  const result = await ImagePicker.launchImageLibraryAsync({
    mediaTypes: ['images'],
    allowsMultipleSelection: false,
    allowsEditing: true,
    aspect: [1, 1],
    quality: 0.8,
  });
  return result.canceled ? null : result.assets[0];
};

Configure the plugin to emit only what you use. Passing false blocks the permission on Android and omits the iOS usage string:

{
  "expo": {
    "plugins": [
      ["expo-image-picker", {
        "photosPermission": "Photos you select are attached to your submission.",
        "cameraPermission": "Used to photograph your document.",
        "microphonePermission": false
      }]
    ]
  }
}

expo-image-picker defaults to adding RECORD_AUDIO; set microphonePermission: false unless you record video.

5.3 Step three: deal with expo-media-library

This is the highest-impact single fix in most Expo codebases. Through SDK 52 (expo-media-library ≤ 17.1.x) the module manifest hardcodes READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO and READ_MEDIA_VISUAL_USER_SELECTED, and unconditionally sets android:requestLegacyExternalStorage="true". Plugin options cannot remove them.

If you only pick or save images, remove the dependency entirely. expo-image-picker covers picking; MediaLibrary.saveToLibraryAsync is the only thing most apps keep it for.

If you need it, upgrade to ≥ 18.0.0 (SDK 53), where the permissions moved into the config plugin behind an option:

["expo-media-library", {
  "photosPermission": "Save exported images to your library.",
  "savePhotosPermission": "Save exported images to your library.",
  "isAccessMediaLocationEnabled": false,
  "granularPermissions": []
}]

granularPermissions defaults to ["photo", "video", "audio"]. Set it to [] for save-only usage, or ["photo"] if you genuinely enumerate images.

On SDK ≤ 52, or as a belt-and-braces measure anywhere, block them at the app level:

{
  "expo": {
    "android": {
      "blockedPermissions": [
        "android.permission.READ_MEDIA_IMAGES",
        "android.permission.READ_MEDIA_VIDEO",
        "android.permission.READ_MEDIA_AUDIO"
      ]
    }
  }
}

blockedPermissions emits tools:node="remove" into the generated manifest. Verify the prebuild output — merge order can defeat it against a hardcoded module node.

5.4 Step four: strip and cap what remains

In android/app/src/main/AndroidManifest.xml (bare RN, or an Expo config plugin / dangerous mod for managed):

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">

  <!-- Remove restricted permissions injected by dependencies -->
  <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove" />
  <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"  tools:node="remove" />
  <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"  tools:node="remove" />
  <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:node="remove" />
  <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:node="remove" />

  <!-- Cap legacy storage rather than removing it (older devices still need it) -->
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
      android:maxSdkVersion="32" tools:replace="android:maxSdkVersion" />
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
      android:maxSdkVersion="28" tools:replace="android:maxSdkVersion" />

  <application tools:remove="android:requestLegacyExternalStorage">

    <!-- Backported Photo Picker for API 19–29 and Android Go 11/12 -->
    <service android:name="com.google.android.gms.metadata.ModuleDependencies"
             android:enabled="false" android:exported="false"
             tools:ignore="MissingClass">
      <intent-filter>
        <action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
      </intent-filter>
      <meta-data android:name="photopicker_activity:0:required" android:value="" />
    </service>
  </application>
</manifest>

Three things people get wrong here:

  • xmlns:tools on <manifest> is mandatory. Without it the build fails or the markers silently no-op.
  • tools:replace="android:maxSdkVersion" is required to cap. The merger otherwise takes the most permissive value, so one uncapped library declaration wins over your capped one.
  • Do not remove READ_MEDIA_VISUAL_USER_SELECTED. It is the Android 14 partial access permission — the compliant one, not a restricted one. Remove it only if you have removed READ_MEDIA_IMAGES too.

tools: markers are stripped after merging in an app module. In a library module they are not, and they propagate downstream — relevant if you ship an internal RN library.

5.5 Step five: keep iOS narrow

The rule is the same, expressed differently.

Key Grants Use when
(none) — Default PHPickerViewController. Preferred.
NSPhotoLibraryAddUsageDescription Add-only: save without read You only write to the library
NSPhotoLibraryUsageDescription Full read/write Only for real library enumeration

Ship no NSPhotoLibraryUsageDescription at all if PHPicker covers your flow — its mere presence invites 5.1.1 questions. A default PHPickerConfiguration() (constructed without a photoLibrary: argument) requires neither authorization nor a usage string.

Purpose strings must be specific. Generic text is a documented rejection cause. “This app requires photo library access to function properly” gets rejected; “Photos you select are attached to your support ticket” does not.

react-native-permissions is the best control surface here, because its Podfile setup physically excludes uncompiled handler source — unused framework code and its usage strings never ship:

# ios/Podfile
setup_permissions([
  'Camera',
  'PhotoLibraryAddOnly',   # prefer add-only over 'PhotoLibrary'
])

Finally, audit the built .app/Info.plist, not your source — a pod or config plugin can merge in a photo key you never wrote. Aggregate PrivacyInfo.xcprivacy declarations at the app level too, since Apple does not reliably parse manifests inside static CocoaPods dependencies (this surfaces as ITMS-91053 / ITMS-91055 emails after upload).

5.6 The declaration form — for the genuine exception

If you really are a gallery or photo-management product:

  1. Play Console → your app → Policy and programs → App content.
  2. Open the Photo and video permissions declaration.
  3. Describe the core purpose, name the user-facing feature, and explain technically why the Android Photo Picker, ACTION_GET_CONTENT and SAF are insufficient.
  4. Attach an unlisted YouTube demo of the in-app flow.
  5. Deactivate or supersede stale releases in every track before submitting — Play checks them all.

6. Outcome / Benefits

  • Zero restricted permissions in the merged manifest. The rejection class disappears rather than being argued against each release.
  • No permission dialog for the primary flow. The pre-prompt screen, the rationale modal, the settings-deep-link fallback for “Don’t allow” — all of that UI and its state machine can be deleted.
  • Fewer failure paths. Denied, permanently-denied, limited-access, and Android 14 partial-access states no longer exist for picker flows, which removes a long tail of support tickets and edge-case bugs.
  • Smaller surface across platforms. Add-only on iOS, MediaStore writes on Android, CAMERA only where the camera is actually used.
  • Faster review cycles. No sensitive-permission declaration means no manual review queue for that item.
  • Better trust signals. The Play listing’s permission list shortens, and the App Store privacy card no longer claims library access the app doesn’t use.

The measurable effect worth tracking is completion rate on the attach/upload step — it typically rises, because a system sheet with no permission gate has fewer places to drop out.


7. Lessons Learned / Pitfalls

  1. Audit the merged manifest, not your repo. Every widely-repeated attribution in blog posts should be verified against your own manifest-merger-release-report.txt. Most of them are wrong for your tree.
  2. READMEs are a bigger source of restricted permissions than library manifests. Several popular packages declare nothing and simply tell you to paste the block. Do not paste blocks you haven’t read.
  3. A permission declared but never requested still fails review. The declaration is the violation.
  4. Runtime behaviour is a bad signal. Everything works on your device with or without the permission on Android 13+. Only the review catches it.
  5. Capping needs tools:replace. A bare maxSdkVersion in your manifest loses to an uncapped library declaration.
  6. Check every track. A forgotten internal-testing build blocks compliance for the whole app.
  7. Multi-select on iOS is where it gets genuinely hard. Libraries that render their own multi-select grid use full-library authorization by construction; no config flag fixes that. Either accept the review exposure or move to PHPicker’s native multi-select.
  8. Unmaintained packages are compliance debt. Several offenders here are abandoned, so the fix is migration, not an upgrade. Budget for it.
  9. READ_MEDIA_VISUAL_USER_SELECTED is a friend, not a foe. Without it on Android 14+, READ_MEDIA_IMAGES is granted only temporarily and revoked when your app is backgrounded — which produces confusing “why did it forget?” bug reports.
  10. Add a CI gate. This regresses on any dependency bump. Fail the build instead of finding out at review.
# ci/check-permissions.sh — fail the build on restricted permissions
set -euo pipefail
REPORT=android/app/build/outputs/logs/manifest-merger-release-report.txt
if grep -qE "^(ADDED|IMPLIED).*(READ_MEDIA_IMAGES|READ_MEDIA_VIDEO|MANAGE_EXTERNAL_STORAGE)" "$REPORT"; then
  echo "::error::Restricted media permission present in merged manifest"
  grep -nE "READ_MEDIA_IMAGES|READ_MEDIA_VIDEO|MANAGE_EXTERNAL_STORAGE" "$REPORT"
  exit 1
fi
echo "Permission audit passed."


8. Future Improvements

  • Adopt Android 14 partial access properly wherever broad access is genuinely core: declare READ_MEDIA_VISUAL_USER_SELECTED, request ACCESS_MEDIA_LOCATION in the same call to avoid a second dialog, re-check grants in onResume, and handle selected-URI expiry instead of caching permission state.
  • Move all remaining picker entry points behind one internal module with a single typed API (pickImage, pickDocument, captureImage, saveImage). One place to audit, one place to swap implementations.
  • Publish an internal Expo config plugin that applies the strip-and-cap block and the photo-picker service, so new apps in the org start compliant.
  • Track the permission diff in PRs. Post the merged-manifest permission list as a bot comment so a new dependency’s permissions are visible at review time, not release time.
  • Revisit iOS multi-select on PHPicker natively to retire the last full-library authorization.
  • Re-audit each store policy cycle. The direction of travel is consistently narrower — Android’s photo picker, iOS limited access, per-app storage. Designing for the narrowest capability now is the cheapest way to absorb the next change.

Appendix A — React Native & Expo packages commonly behind media-permission rejections

Verified against shipped package contents. Always confirm against your own merger report.

Confirmed offenders (they put permissions in your build)

Package What it contributes Fix
expo-media-library ≤ 17.1.x READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO, READ_MEDIA_VISUAL_USER_SELECTED hardcoded in the module manifest, plus requestLegacyExternalStorage="true" Upgrade to ≥ 18.0.0 (SDK 53) and set granularPermissions: []; or remove the dependency; or blockedPermissions on older SDKs
expo-image-picker 14.3.2 – 15.0.7 READ_MEDIA_IMAGES, READ_MEDIA_VIDEO Upgrade to ≥ 15.1.0 (removed in expo/expo#31902)
rn-fetch-blob 0.12.0 (abandoned) WRITE_EXTERNAL_STORAGE uncapped, plus com.android.vending.CHECK_LICENSE — an independent review flag Migrate to react-native-blob-util, then cap storage perms
react-native-blob-util WRITE_EXTERNAL_STORAGE + READ_EXTERNAL_STORAGE, both uncapped Cap both with maxSdkVersion + tools:replace
react-native-fs 2.20.0 (unmaintained) WRITE_EXTERNAL_STORAGE uncapped — the maintained @dr.pogodin fork still does Cap it, or migrate to expo-file-system
react-native-image-crop-picker < 0.42 WRITE_EXTERNAL_STORAGE uncapped (≤ 0.40.x) Upgrade to ≥ 0.42 (0.51.x); note the iOS caveat below
Zendesk Android SDK 5.1.1 (via Belvedere) READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO — vendor-confirmed Upgrade to ≥ 5.3.0
DJI Mobile SDK Android V5 READ_MEDIA_IMAGES, READ_MEDIA_VIDEO No vendor fix as of writing — tools:node="remove"

README traps (the package is clean; the docs tell you to add it)

Package What the docs tell you to paste Reality
@react-native-camera-roll/camera-roll READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE Its own manifest is empty, but the library is library enumeration — it cannot be made compliant by config. A photo-picker migration request was closed as not planned. Replace it with react-native-image-picker unless you are a real gallery app.
react-native-permissions A full list of every supported permission Declares nothing itself. Keep only what you use — its README says so in bold.

Clean — safe to use

Package Notes
react-native-image-picker v8.x Zero permissions; PickVisualMedia + PHPickerViewController; ships a privacy manifest. The recommended RN picker.
@react-native-documents/picker Zero permissions; pure SAF. Preferred over the deprecated react-native-document-picker (also clean).
expo-image-picker ≥ 15.1.0 CAMERA, plus capped READ/WRITE_EXTERNAL_STORAGE. Not a policy trigger.
expo-file-system INTERNET + capped legacy storage. Fine.
expo-camera CAMERA only.
react-native-share, expo-sharing, expo-image-manipulator No permissions.
react-native-permissions No permissions of its own; the best tool for scoping both platforms.

Notes on partial cases

  • react-native-image-crop-picker: Android is fine from 0.42 (it uses PickVisualMedia and never declared READ_MEDIA_* itself). iOS is the exposure — it renders a custom multi-select grid over the full library via PHPhotoLibrary.requestAuthorization, which requires broad NSPhotoLibraryUsageDescription and cannot be configured away.
  • MANAGE_EXTERNAL_STORAGE is almost never library-injected. The scoped-storage helper packages all require you to add it yourself. If it is in your merged manifest, remove it and use SAF.
  • Glide, Firebase and the mainstream ad SDKs do not declare READ_MEDIA_*. Don’t chase them — read the merger report.

Appendix B — Decision checklist

Does the user hand you specific files?
  └─ YES → Android Photo Picker / SAF + PHPicker.  ZERO permissions.  ✅ Done.
  └─ NO, we enumerate the whole library
       └─ Is media management the app's core purpose?
            └─ NO  → redesign the feature around a system picker.
            └─ YES → declare READ_MEDIA_IMAGES/VIDEO
                     + READ_MEDIA_VISUAL_USER_SELECTED (partial access)
                     + Play Console declaration + demo video
                     + iOS NSPhotoLibraryUsageDescription with a specific string.

Then, before every release: build the AAB, dump its manifest, and diff the permission list.


Sources