Privacy and Store Compliance
Tracking permission, the iOS privacy manifest, data disclosures on both stores, and in-app account deletion.
const { granted } = await requestTrackingPermissionsAsync();Both stores make you declare in writing what data your app collects and what you do with it. The declaration is reviewed, and an answer that does not match what your SDKs actually do gets the build rejected or the app removed.
App Tracking Transparency
On iOS, linking a user's data with data from other companies' apps or sites needs permission first. requestTrackingPermissionsAsync() from expo-tracking-transparency shows the system prompt and resolves to a PermissionResponse. Writing const { granted } = await requestTrackingPermissionsAsync() is the same as keeping the response and reading response.granted off it. Until it is granted, getAdvertisingId() returns null. Analytics about your own app is not tracking; an advertising SDK that identifies the same person elsewhere is.
Privacy manifest
Apple requires a PrivacyInfo.xcprivacy file listing required-reason API use. In Expo you do not write that file by hand: set ios.privacyManifests in the app config and it is generated at build time. Each entry pairs a category with a reason code, for example NSPrivacyAccessedAPICategoryUserDefaults with CA92.1. Third-party packages ship their own manifest under their ios folder, so copy the categories they declare.
Deletion is a feature you build
App Store guideline 5.1.1(v): if the app can create an account, the user must be able to start deleting it from inside the app. Google Play's user data policy also wants a web URL that does the same, declared in the Data safety form. Deactivating or freezing an account does not count. If you offer Sign in with Apple, revoke the token server-side as part of deletion.
Example
// app/consent.tsx
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import {
getAdvertisingId,
isAvailable,
requestTrackingPermissionsAsync,
} from "expo-tracking-transparency";
export default function Consent() {
const [granted, setGranted] = useState<boolean | null>(null);
useEffect(() => {
(async () => {
// false on Android and on iOS versions without ATT
if (!isAvailable()) {
setGranted(false);
return;
}
// Destructured; identical to reading response.granted off the result.
const { granted: allowed } = await requestTrackingPermissionsAsync();
setGranted(allowed);
if (allowed) {
const idfa = getAdvertisingId(); // string | null
console.log("advertising id available:", idfa !== null);
}
})();
}, []);
return (
<View style={styles.center}>
<Text style={styles.label}>
{granted === null
? "Asking for permission"
: granted
? "Personalised ads enabled"
: "Personalised ads off"}
</Text>
</View>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center" },
label: { fontSize: 16 },
});When to use it
- Pass review for an app that uses an advertising SDK on iOS.
- Fill the App Store privacy answers and the Play Data safety form to match what the app really sends.
- Add the account deletion path a reviewer rejected the last build for.
- Work out which privacy manifest entries a new dependency forces on you.
More examples
Declaring the prompt text and the privacy manifest
Two separate obligations in one file. The plugin option writes the sentence shown inside the system tracking dialog, and reviewers reject vague text, so name the actual purpose. The privacyManifests block becomes PrivacyInfo.xcprivacy. To find which categories you need, check each dependency for a PrivacyInfo.xcprivacy under node_modules/<package>/ios and copy the entries it declares.
{
"expo": {
"name": "My App",
"slug": "my-app",
"ios": {
"privacyManifests": {
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
},
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
"NSPrivacyAccessedAPITypeReasons": ["C617.1"]
}
]
}
},
"plugins": [
[
"expo-tracking-transparency",
{
"userTrackingPermission": "We use this to measure which ads bring people to the app."
}
]
]
}
}An in-app account deletion screen
A confirmation step is allowed and sensible; a path that only hides the account is not. Keep the screen reachable in two or three taps from settings, because reviewers look for it, and mention the web route in the same place to satisfy the Play requirement. Tell App Review in the submission notes where the button is and give them a test account.
// app/settings/delete-account.tsx
import { Alert, Button, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
export default function DeleteAccount() {
const router = useRouter();
// The server deletes the records AND revokes any Sign in with Apple token.
async function run() {
await fetch("https://api.example.com/v1/account", { method: "DELETE" });
router.replace("/");
}
function confirm() {
Alert.alert(
"Delete account",
"This removes your account and your data. It cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{ text: "Delete", style: "destructive", onPress: run },
],
);
}
return (
<View style={styles.page}>
<Text style={styles.body}>
Deleting is permanent. You can also delete your account at
example.com/delete without installing the app.
</Text>
<Button title="Delete account" color="#b00020" onPress={confirm} />
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1, padding: 16, gap: 16 },
body: { fontSize: 15, lineHeight: 22 },
});Keeping personal data out of analytics events
Run every event through one function like this before it leaves the device. Disclosures are per data type, so one careless property that sends an email address turns "we collect usage data" into a false declaration on both stores, and it is far easier to block at the single exit point than to audit every call site.
// lib/scrub.ts
const BLOCKED = ["email", "phone", "address", "password", "token"];
export function scrub(
properties: Record<string, string | number>,
): Record<string, string | number> {
const out: Record<string, string | number> = {};
// Keys sorted so the result is stable and testable.
for (const key of Object.keys(properties).sort()) {
out[key] = BLOCKED.includes(key) ? "[redacted]" : properties[key];
}
return out;
}
console.log(JSON.stringify(scrub({ plan: "pro", email: "[email protected]", items: 3 })));
// Output: {"email":"[redacted]","items":3,"plan":"pro"}
Discussion