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:
| Status | Meaning |
|---|---|
granted | Approved by the user or OS. |
denied | Not granted — never requested or previously refused. |
permanentlyDenied | Never ask again; only Settings can fix it (Android). |
restricted | OS policy blocks it (parental controls, MDM). |
limited | Partial access — e.g. specific photos selected. |
provisional | Quietly 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
permanentlyDeniedby routing to Settings. The system dialog will never appear again — whenawait Permission.speech.request().isPermanentlyDenied, callopenAppSettings(). - 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
mainmanifest variant. - Keep
compileSdkVersioncurrent (35 per the package docs) and use AndroidX (android.useAndroidX=trueingradle.properties). - Android 13+ (API 33) granular permissions:
READ_EXTERNAL_STORAGEis effectively gone. UsePermission.photos,Permission.videos, andPermission.audio(mapped toREAD_MEDIA_IMAGES/READ_MEDIA_VIDEO/READ_MEDIA_AUDIO), withcompileSdkVersion33+. - Notifications on Android 13+ require
POST_NOTIFICATIONS, requested viaPermission.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.locationAlwayscannot be requested directly on Android 10+: requestlocationWhenInUsefirst, 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
statuscheck 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, andrestrictedwith fake statuses. - Watch
compileSdkVersionvstargetSdkVersiondrift — a mismatch can stoponRequestPermissionsResultfrom firing.
Common Pitfalls
- Assuming granted. Checking once at startup and never again is the biggest source of runtime crashes. Check before every sensitive operation.
- Missing manifest / Info.plist entries. Android silently returns
denied; iOS crashes on the missing usage description. - Expecting
statusto returnpermanentlyDeniedon Android. It will not — branch on the result ofrequest()instead. - Requesting
Permission.storageon Android 13+. It maps to permissions removed in API 33; usephotos/videos/audio. - Asking on first launch. A cold, out-of-context prompt trains users to tap “Deny.” Ask when the feature is invoked.
- Ignoring
restrictedandlimited. 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.