Skip to content
Blog

Runtime Permissions in Flutter: A Practical Guide to permission_handler

Learn how to request, check, and handle runtime permissions in Flutter with permission_handler, including statuses, platform configuration, Android 13+ granular permissions, and testing tips.

Published on • October 4, 2026

AI Assistant

Runtime Permissions in Flutter: A Practical Guide to permission_handler

Users do not hand over the camera, microphone, or location when they install your app — on modern mobile platforms they hand them over one prompt at a time, while the app is running. Android has required runtime permission requests since 6.0 (Marshmallow), and iOS follows the same model, where grants can be limited, provisional, or revoked at any moment. Getting this wrong is one of the fastest ways to ship a broken feature: the API call succeeds, the dialog never appears, and your code silently assumes access it does not have.

The permission_handler package is the de-facto standard Flutter plugin for this problem. It gives you one cross-platform API to check status, request permissions, observe callbacks, and deep-link the user into system settings when they have permanently denied you.

Key technologies: permission_handler 13.0.2, Permission / PermissionStatus, openAppSettings(), AndroidManifest.xml, iOS Info.plist, Android 13+ POST_NOTIFICATIONS and READ_MEDIA_*.

Why Runtime Permissions Matter

Install-time permission grants are largely history. On Android 6.0+, dangerous permissions (camera, location, contacts, storage, microphone) are requested at runtime; the user can deny, deny-without-asking-again, or revoke them later from Settings. On iOS the model is similar, with extra nuance: some permissions can be restricted by policy (parental controls, MDM profiles), granted limited (photo library access to a subset), or provisional (notifications quietly allowed until the user acts).

Two consequences follow: your code must handle every outcome, not just success — a denied request is a normal, expected result, not an exception — and you cannot assume state persists, since a permission granted yesterday may be revoked tomorrow, so check before use rather than only at first launch.

As the package README puts it, permissions “aren’t just granted to apps at install time. Rather, developers have to ask the user for permission while the app is running.”

Installing permission_handler

Add the dependency:

dependencies:
  flutter:
    sdk: flutter
  permission_handler: ^13.0.2

Then run flutter pub get. The plugin is federated (permission_handler_android, permission_handler_apple, permission_handler_windows), so the platform configuration below is required before anything works.

Understanding Permission Statuses

Every Permission exposes a status that resolves to one of these values:

StatusMeaning
grantedApproved by the user or OS.
deniedNot granted — never requested or previously refused.
permanentlyDeniedNever ask again; only Settings can fix it (Android).
restrictedOS policy blocks it (parental controls, MDM).
limitedPartial access — e.g. specific photos selected.
provisionalQuietly allowed pending user action (iOS notifications).

Statuses expose convenience getters, so the code reads well:

var status = await Permission.camera.status;
if (status.isDenied) {
  // Never asked, or asked and refused.
}

if (await Permission.location.isRestricted) {
  // Blocked by parental controls / device policy.
}

There is also a fluent callback style (onGrantedCallback, onDeniedCallback, onPermanentlyDeniedCallback, … chained before .request()) if you prefer branching that way.

The Android permanentlyDenied subtlety

This trips up almost everyone. On Android, Permission.status does not return permanentlyDenied — it reports denied. Android does not expose whether a denial is permanent: never-requested, “ask every time,” and permanently denied all look identical to the app. Only the result of request() can be permanentlyDenied, and calling request() on an already-permanently-denied permission is cheap — the OS resolves it immediately without showing a dialog.

So the correct Android pattern is request-driven: call request() and branch on its returned status, rather than trying to predict permanentlyDenied from a status check.

The Request Flow with Best Practices

Requesting is a one-liner, but the flow around it matters:

Future<void> ensureCameraAccess() async {
  var status = await Permission.camera.status;

  if (status.isGranted) {
    openCamera();
    return;
  }

  if (status.isPermanentlyDenied || status.isRestricted) {
    await promptSettingsSheet(); // explains why you need it
    return;
  }

  status = await Permission.camera.request();
  if (status.isGranted) {
    openCamera();
  } else if (status.isPermanentlyDenied) {
    openAppSettings();
  } else {
    showFallback(); // denied — degrade gracefully
  }
}

Best practices:

  • Ask in context. Do not request camera on first launch “just in case.” Trigger the prompt when the user taps “Take photo” — they understand why you are asking.
  • Explain before you ask. A short pre-prompt (“We need camera access to scan documents”) reduces reflexive denials. On Android you can check Permission.contacts.shouldShowRequestRationale.
  • Handle permanentlyDenied by routing to Settings. The system dialog will never appear again — when await Permission.speech.request().isPermanentlyDenied, call openAppSettings().
  • Degrade, do not crash. A denied permission should lead to a feature-limited experience, not a dead end.
  • Batch requests carefully. await [Permission.location, Permission.storage].request() asks for several at once, but a combined prompt often produces a combined deny — one at a time is friendlier.
  • Check service state too. Location also depends on the OS toggle: await Permission.locationWhenInUse.serviceStatus.isEnabled.

Platform Setup: Android

Runtime requests only work if the permission is declared in android/app/src/main/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
    ...
</manifest>

Points to remember:

  • Declare permissions in the main manifest variant.
  • Keep compileSdkVersion current (35 per the package docs) and use AndroidX (android.useAndroidX=true in gradle.properties).
  • Android 13+ (API 33) granular permissions: READ_EXTERNAL_STORAGE is effectively gone. Use Permission.photos, Permission.videos, and Permission.audio (mapped to READ_MEDIA_IMAGES / READ_MEDIA_VIDEO / READ_MEDIA_AUDIO), with compileSdkVersion 33+.
  • Notifications on Android 13+ require POST_NOTIFICATIONS, requested via Permission.notification.
  • Full file-system access on Android 11+ is Permission.manageExternalStorage (MANAGE_EXTERNAL_STORAGE), a high-risk permission that must be declared for Google Play review.
  • Permission.locationAlways cannot be requested directly on Android 10+: request locationWhenInUse first, then escalate.

Platform Setup: iOS

On iOS, permissions are driven by Info.plist usage descriptions. Missing key = crash the moment the permission is checked or requested:

<key>NSCameraUsageDescription</key>
<string>We use the camera to scan documents.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Show nearby sites on the map.</string>

Under CocoaPods (the traditional path), you also enable each permission via GCC_PREPROCESSOR_DEFINITIONS in your Podfile’s post_install block — PERMISSION_CAMERA=1 for what you use, 0 for the rest — then delete the unused Info.plist keys. Since version 8.0.0 all permissions are excluded by default, so you only compile in what you need.

With Swift Package Manager (Flutter 3.24+ / Xcode 15+), detection is automatic: a permission is compiled in when its usage-description key is present in Info.plist. Notifications are enabled by default (opt out with PERMISSION_NOTIFICATIONS=0), while criticalAlerts requires a special Apple entitlement and stays off unless you opt in. If no Info.plist can be found during the build, every permission is compiled out and all checks report denied.

Testing Tips

  • Use fresh profiles. Once denied twice on Android you are permanently denied; reinstall (or clear app data) to reset and test the happy path.
  • Test the revoke path. Grant permission, revoke it from Settings while the app is backgrounded, then resume — your status check should catch it.
  • Automate with grant flags. Android: adb shell pm grant com.example.app android.permission.CAMERA. iOS: xcrun simctl privacy booted grant camera <bundle-id>.
  • Unit-test your decision logic. Extract the status-branching into a pure function and cover denied, permanentlyDenied, and restricted with fake statuses.
  • Watch compileSdkVersion vs targetSdkVersion drift — a mismatch can stop onRequestPermissionsResult from firing.

Common Pitfalls

  1. Assuming granted. Checking once at startup and never again is the biggest source of runtime crashes. Check before every sensitive operation.
  2. Missing manifest / Info.plist entries. Android silently returns denied; iOS crashes on the missing usage description.
  3. Expecting status to return permanentlyDenied on Android. It will not — branch on the result of request() instead.
  4. Requesting Permission.storage on Android 13+. It maps to permissions removed in API 33; use photos / videos / audio.
  5. Asking on first launch. A cold, out-of-context prompt trains users to tap “Deny.” Ask when the feature is invoked.
  6. Ignoring restricted and limited. Parental controls and the iOS photo picker’s limited selection are legitimate states needing their own UI.

Wrapping Up

Runtime permissions are a contract with your user, not a formality. permission_handler gives you the primitives — status, request(), openAppSettings(), and per-status callbacks — but the quality comes from how you sequence them: ask in context, explain the why, branch on every status, and degrade gracefully when the answer is no. Get the platform configuration right, handle permanentlyDenied deliberately, and test with revoked state.

Sources