Permissions are the moments of mobile apps. Camera, location, photos, microphone, notifications your app can’t just grab them. The good news is that permission handling becomes simple when you treat it like a user experience problem, not just a code problem.
Understand The Two Layers of Permissions
Build time permissions are the “paperwork”: Build-time permissions are what you declare in native config so the OS knows what your app might ask for. On Android this lives in AndroidManifest.xml, on iOS it’s Info.plist, and in Expo you control a lot of it through app.json/app.config.
Runtime permissions are the “ask nicely” moment: Runtime permissions are the popups users actually see. Android’s “dangerous” permissions require runtime prompts, and react native permissions Android API even supports a “rationale” message when the OS thinks you should explain yourself.
Pick Your Approach (React Native CLI vs Expo)
If you use React Native Permissions CLI, you manage native files directly:
With the CLI, you’ll edit iOS and Android native config yourself, and you’ll typically use react-native-permissions to keep your JavaScript code consistent across platforms.
If you use Expo, permissions are mostly config-driven (but still real):
Expo lets you configure permissions using app config, and it even supports blocking permissions you don’t want shipped in your app. That’s huge for privacy and store reviews.
Install the right tool
The simplest cross-platform choice is react-native-permissions:
react-native-permissions gives you one API to check and request permissions on iOS and Android, instead of writing totally different code paths for each. Bugsee and GetStream both rely on it for a reason: it’s straightforward.
CLI install (typical setup):
If you’re using react native permissions CLI, you install the library and run CocoaPods for iOS.
yarn add react-native-permissions
cd ios && pod install && cd ..
Expo note (important if you’ve seen old tutorials):
Older Expo permission packages were deprecated in favor of asking permissions through the specific module you use (like Camera, Location, etc.). If you’ve ever wondered “why does this old snippet not work”, that’s why.
Configure Permissions the Right Way
iOS requires human-readable reasons:
On iOS, Apple expects a clear message for why you need access. In Expo, you can set these messages in ios.infoPlist so you don’t forget them.
Expo example for iOS permission messages:
Here’s a clean Expo config example. Make it specific to your feature, not generic.
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "We use the camera so you can scan QR codes for quick sign-in."
}
}
}
}
Android configuration in Expo is also explicit:
Expo documents android.permissions and android.blockedPermissions. Blocking is useful when a library adds permissions you don’t actually use, because “extra permissions” can look suspicious to reviewers and users.
Expo example for Android allow + block:
This example shows adding one permission and blocking another.
{
"expo": {
"android": {
"permissions": ["android.permission.SCHEDULE_EXACT_ALARM"],
"blockedPermissions": ["android.permission.RECORD_AUDIO"]
}
}
}
Use a Reusable React Coding Base
The goal is one permission brain for your whole app:
Instead of sprinkling permission code across random screens, you’ll centralize it. Your future self will thank you, and your teammates will stop sending “where do we request location again” messages.
A permission helper file you can reuse everywhere:
This example uses react-native-permissions and returns a small, friendly state object you can render in the UI.
// src/permissions/useAppPermission.js
import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import {
check,
request,
openSettings,
RESULTS,
PERMISSIONS,
} from "react-native-permissions";
const getPermissionConst = (key) => {
if (key === "camera") {
return Platform.select({
ios: PERMISSIONS.IOS.CAMERA,
android: PERMISSIONS.ANDROID.CAMERA,
});
}
if (key === "microphone") {
return Platform.select({
ios: PERMISSIONS.IOS.MICROPHONE,
android: PERMISSIONS.ANDROID.RECORD_AUDIO,
});
}
if (key === "location") {
return Platform.select({
ios: PERMISSIONS.IOS.LOCATION_WHEN_IN_USE,
android: PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION,
});
}
throw new Error(`Unknown permission key: ${key}`);
};
export function useAppPermission(key) {
const perm = useMemo(() => getPermissionConst(key), [key]);
const [status, setStatus] = useState("checking");
const refresh = useCallback(async () => {
const s = await check(perm);
setStatus(s);
return s;
}, [perm]);
const ask = useCallback(async () => {
const s = await request(perm);
setStatus(s);
return s;
}, [perm]);
const goToSettings = useCallback(async () => {
await openSettings();
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const model = useMemo(() => {
const granted = status === RESULTS.GRANTED;
const blocked = status === RESULTS.BLOCKED;
const denied = status === RESULTS.DENIED;
const limited = status === RESULTS.LIMITED;
return {
status,
granted,
denied,
blocked,
limited,
refresh,
ask,
goToSettings,
};
}, [status, refresh, ask, goToSettings]);
return model;
}
A PermissionGate component that keeps screens clean:
This component shows your “why we need it” UI when permission isn’t ready, and renders children when it is.
// src/permissions/PermissionGate.js
import React from "react";
import { View, Text, Pressable } from "react-native";
import { useAppPermission } from "./useAppPermission";
export function PermissionGate({ permissionKey, title, why, children }) {
const p = useAppPermission(permissionKey);
if (p.granted) return children;
if (p.status === "checking") {
return (
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: "600" }}>{title}</Text>
<Text style={{ marginTop: 8 }}>Checking permission…</Text>
</View>
);
}
if (p.blocked) {
return (
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: "600" }}>{title}</Text>
<Text style={{ marginTop: 8 }}>
You’ve turned this off in settings. No worries. You can switch it back on.
</Text>
<Text style={{ marginTop: 8 }}>{why}</Text>
<Pressable
onPress={p.goToSettings}
style={{ marginTop: 12, padding: 12, backgroundColor: "#eee" }}
>
<Text>Open Settings</Text>
</Pressable>
</View>
);
}
return (
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: "600" }}>{title}</Text>
<Text style={{ marginTop: 8 }}>{why}</Text>
<Pressable
onPress={p.ask}
style={{ marginTop: 12, padding: 12, backgroundColor: "#eee" }}
>
<Text>Allow</Text>
</Pressable>
<Pressable
onPress={p.refresh}
style={{ marginTop: 10, padding: 12, backgroundColor: "#f5f5f5" }}
>
<Text>Not now</Text>
</Pressable>
</View>
);
}
Example usage in a real screen:
This is how you keep your feature code clean and readable.
// src/screens/ScanScreen.js
import React from "react";
import { Text, View } from "react-native";
import { PermissionGate } from "../permissions/PermissionGate";
export default function ScanScreen() {
return (
<PermissionGate
permissionKey="camera"
title="Camera access"
why="We need the camera to scan QR codes. We don’t store your camera feed."
>
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: "600" }}>Scanner ready</Text>
<Text style={{ marginTop: 8 }}>
Put the QR code in the frame and pretend you’re in a spy movie.
</Text>
</View>
</PermissionGate>
);
}
Handle Denial, Blocked States, and “Limited” Access
Denied usually means “ask later”:
If the user denies, treat it as “not now.” Keep the UI functional where possible, and offer an alternate path. For example, let users upload a photo instead of forcing camera access.
Blocked means “you need settings”:
When a permission is blocked, repeatedly requesting it won’t help. Your job is to show a calm message and provide a settings shortcut. This is one reason the Gate component above includes an “Open Settings” button.
Limited access is real on iOS:
Some iOS permissions can be “limited,” meaning the user gave partial access. Don’t treat that like failure. Detect it and adjust your feature so it works with what you’ve got.

