Welcome to the sixth article in our OWASP Mobile Top 10 2024 series! In previous articles we covered M1 through M5. Today we reach the most unusual item on the list: the only risk with a LOW technical impact and a SEVERE business impact.
Introduction
M6 is the most unusual item in the OWASP Mobile Top 10. The other nine describe technical flaws: a query that wasn't parameterized, a certificate that wasn't validated, a token stored insecurely. M6 asks a different question:
"Why are you collecting this data at all?"
And if the answer is "because we might need it," you already have an M6 violation.
The interesting part is that M6's technical impact is rated LOW while its business impact is SEVERE. It's the only inversion on the list. Your code can work perfectly, nothing crashes, no system goes down — and your company can still face millions in fines.
A specific situation for React Native developers
React Native apps carry extra M6 risk because:
-
The dependency chain runs deep. One
npm installcan pull in three native SDKs, each sending its own telemetry. -
The JS side is very "talkative." The
console.loghabit, crash reporting integrations, and dev tooling make PII leakage easy. - Platform requirements keep changing. Apple's Privacy Manifest requirement and Google Play's Data Safety form are declarations that must be filled in correctly — and they must match your app's actual behaviour.
OWASP Assessment
| Metric | Value | Meaning |
|---|---|---|
| Exploitability | AVERAGE | Requires breaching another layer first |
| Prevalence | COMMON | In nearly every application |
| Detectability | EASY | Watching traffic/logs is enough |
| Technical Impact | LOW | The system keeps running |
| Business Impact | SEVERE | Legal penalties, lawsuits, reputational loss |
⚠️ Read that table twice. Technical impact LOW, business impact SEVERE. This explains why engineering teams keep deferring M6: nothing breaks, no alarm goes off. The problem is that the bill arrives much later and much larger.
Why is the technical impact low? Privacy violations usually have little technical impact on the system as a whole. Only if the PII includes information like authentication data can it affect certain global security properties, such as traceability.
What PII Is and Why It's Valuable
Privacy controls are concerned with protecting Personally Identifiable Information (PII): names and addresses, credit card information, e-mail and IP addresses, information about health, religion, sexuality and political opinions.
Why do attackers want this data?
An attacker could:
- Impersonate the victim to commit fraud
- Misuse the victim's payment data
- Blackmail the victim with sensitive information
- Harm the victim by destroying or manipulating their critical data
In general, PII could either be leaked (a violation of confidentiality), manipulated (violation of integrity), or destroyed/blocked (violation of availability).
The "our app doesn't collect PII" fallacy
An app can only be vulnerable to Inadequate Privacy Controls if it processes some form of personally identifiable information. And this is almost always the case: client apps' IP addresses visible to a server, logs of the app's usage, and metadata sent with crash reports or analytics are PII that apply to most apps.
Here's the PII of an app that "doesn't collect PII":
- 🌐 IP address — in server logs, on every request
- 📱 Device identifiers — IDFA, Android ID, install ID
- 📊 Usage logs — which screen, when, how long
- 💥 Crash metadata — device model, OS, app state
- 🕐 Timestamps — a behavioural profile can be built
- 🌍 Language and locale — location inference
- 📶 Network carrier — geographic and socioeconomic signal
Combined, these are usually enough to identify one person — which is, by definition, PII.
Almost all apps process some kind of PII. Many even collect and process more than they need to fulfil their purpose, which makes them more attractive as a target without business needs.
How M6 Differs from the Other Items
This is the key to understanding M6: it isn't a separate bug class, it's a lens applied to the other items.
Given an app that uses PII, it might expose it like any other sensitive data. This most notably happens through:
- Insecure data storage and communication (M5, M9)
- Data access with insecure authentication and authorization (M3, M1)
- Insider attacks on the app's sandbox (M2, M4, M8)
In other words, M6 = the other items × personal data:
| Combination | Result |
|---|---|
| M5 (Insecure communication) + PII | Personal data leaks in transit |
| M9 (Insecure storage) + PII | Personal data plain on device |
| M3 (Insecure auth) + PII | Access to someone else's data |
| M4 (Missing validation) + PII | Leak to logs/clipboard/URL |
| M2 (Supply chain) + PII | An SDK's hidden telemetry |
⚠️ But M6 has a question of its own: "Could we have avoided collecting this data entirely?"
That last line is critical. M5 says "encrypt the data," M9 says "store it securely." M6 proposes something more radical: something that does not exist cannot be attacked.
Attack Vectors
Typical sources for PII are well protected — for example the sandbox of the app, the network communication with the server, the app's logs and backups. Some have less protection but are still hard to access, like URL query parameters and clipboard content.
Obtaining PII thus requires the attacker to first breach security on another level. Attackers could eavesdrop on the network communication, access the file system, clipboard, or logs with a trojan, or get their hands on the mobile device and create a backup to analyze.
Well protected (another layer must be breached first):
- App sandbox
- Network communication with the server
- App logs
- Device backups
Less protected (but still hard to access):
- URL query parameters
- Clipboard content
Since PII is just data that can be stored, processed, and transmitted by all means available on mobile devices, the possibilities to extract or manipulate it are manifold.
OWASP Example Attack Scenarios
OWASP gives three concrete scenarios for M6 — and all three are extremely common in React Native projects.
Scenario #1 — Inadequate sanitization of logs and error messages
Reporting of logs and exceptions is essential for quality assurance of a productive app. Crash reports and other usage data help developers fix bugs and learn how their app is used. However, logs and error messages might contain PII if the developers chose to include this data in log or error messages. Also, third-party libraries might include PII in their error messages and logs as well.
An example of a frequent issue are database exceptions that reveal part of the query or result. This will most likely be visible to any platform provider used for collecting and evaluating crash reports. It might also become visible to the user if the error is displayed on screen, or to attackers who can read device logs.
💡 The React Native equivalent: every place you wrote
console.log(user)and every exception you send to Sentry/Crashlytics. Details below.
Scenario #2 — Using PII in URL query parameters
URL query parameters are often used to transmit request arguments to a server. However, URL query parameters are visible at least in the server logs, but often also in website analytics and possibly in the local browser history. So sensitive information should never be transmitted as query parameters. Instead, they should be sent as a header or part of the body.
// ❌ Visible in server logs, CDN logs, analytics
GET /api/users?email=jane@example.com&phone=5551234567
// ✅ In the body or a header
POST /api/users/lookup
{ "email": "jane@example.com" }
Scenario #3 — Exclusion of personal data in backups / not setting hasFragileUserData
Most PII processed by an app is stored in its sandbox. The app should explicitly configure what data to include in device backups. An attacker might obtain a device and create a backup, or get a backup from another source, from which the sandbox content could be extracted.
Alternatively, by setting hasFragileUserData to true on Android, an app may preserve its data upon uninstallation. An attacker who manages to install a malicious app with the same package id later can access this data.
Hence, both settings should be explicitly set for apps to make the developers' intent transparent and to control the information flow through backups or between subsequent installations of an app.
Technical and Business Impacts
Technical Impacts — LOW
Privacy violations usually have little technical impact on the system as a whole. Only if the PII includes information like authentication data can it affect certain global security properties, e.g., traceability.
If user data is manipulated it might render the system unusable for that user. Through ill-formed data, the backend may also be disturbed if it is missing proper sanitization and exception handling.
Business Impacts — SEVERE
The extent and severity of the business impact strongly depends on the number of affected users, the criticality of the affected data, and the data protection regulations that apply where the violation happened.
⚖️ Violation of legal regulations
Regulations are the biggest issue regarding privacy controls. Relevant regulations with known sanctions: GDPR (Europe), CCPA (California, US), PDPA (Singapore), PIPEDA (Canada), LGPD (Brazil), Data Protection Act 2018 (UK), POPIA (South Africa), PDPL (China).
💰 Financial damage due to victims' lawsuits
Whoever is personally affected by a privacy violation might sue the app provider that let the violation happen. These lawsuits might be successful, depending on the legal regulations that apply and the ability of the provider to show that they had adequate and up to date protection mechanisms in place.
📉 Reputational damage
If a privacy violation affects users on a large scale, it is likely published in media, generating negative publicity for the provider. As a consequence, sales and usage for the app and even other, unrelated products of the same provider might drop.
🔓 Loss or theft of PII
Actual information stolen might be misused, even for attacks on the provider of the app. For example, specific user data could be used to employ a social engineering attack on the provider by impersonating a victim.
⚠️ Note the phrasing about lawsuits: "depending on... the ability of the provider to show that they had adequate and up to date protection mechanisms in place." Applying protection isn't enough; you have to be able to document it. That makes M6 a documentation problem too.
Prevention Strategies: Data Minimization
OWASP's proposed solution for M6 is fundamentally different from the other items:
Something that does not exist cannot be attacked, so the safest approach to prevent privacy violations is to minimize the amount and variety of PII that is processed.
This requires full awareness of all PII assets in a given app. With that awareness, the following six questions should be assessed:
- Is all PII processed really necessary? (name and address, gender, age...)
- Can some of the PII be replaced by less critical information? (fine-grained location → coarse-grained)
- Can some of the PII be reduced? (location updates every hour instead of every minute)
- Can some of the PII be anonymized or blurred? (by hashing, bucketing, or adding noise)
- Can some of the PII be deleted after an expiration period? (only keep health data of the last week)
- Can users consent to optional PII usage? (receive a better service but be aware of the added risk)
The remaining PII should not be stored or transferred unless absolutely necessary. If it must be stored or transferred, access must be protected with proper authentication and possibly authorization.
Defense in depth should also be considered for particularly critical data. For example, health data may be encrypted with a key sealed in the device's TPM in addition to its storage in the app's sandbox. So if an attacker manages to circumvent the sandbox restrictions, the data is still not readable.
Threat modeling can be used to determine the most likely ways that privacy violations may occur in a given app. The effort of securing PII could then be focused on these.
Static and dynamic security checking tools might reveal common pitfalls, like logging of sensitive data or leakage to clipboard or URL query parameters.
React Native Specific Security
1. Build a PII inventory
Minimization starts with awareness. If you don't know which personal data enters your app, from where, and where it goes, you have nothing to minimize.
// privacy/dataInventory.js
// This file isn't code, it's documentation. But sitting next to the code keeps it alive.
export const PII_INVENTORY = {
email: {
purpose: 'Account identity and communication',
collectedAt: 'Registration screen',
storedIn: ['Keychain (inside token)', 'Backend'],
sharedWith: ['Backend API'],
retention: 'Until account deletion',
legalBasis: 'Performance of a contract',
required: true,
},
preciseLocation: {
purpose: 'Show nearby stores',
collectedAt: 'Store finder screen',
storedIn: ['In memory, not persisted'],
sharedWith: ['Maps SDK'],
retention: 'Session only',
legalBasis: 'Explicit consent',
required: false,
// 🔍 QUESTION 2: Would coarse location be enough?
minimizationNote: 'City-level may suffice — evaluate',
},
deviceId: {
purpose: 'Grouping crash reports',
collectedAt: 'App launch',
storedIn: ['Sentry'],
sharedWith: ['Sentry (third party)'],
retention: '90 days',
legalBasis: 'Legitimate interest',
required: false,
// 🔍 QUESTION 4: Can it be hashed?
minimizationNote: 'Use a hash instead of the raw ID',
},
};
This inventory does three jobs: (1) it gives you a list to apply the minimization questions to, (2) it's the source when filling in the Google Play Data Safety form and Apple Privacy Manifest, (3) it lets you document the claim that you "had adequate protection mechanisms" in an audit.
2. Permission minimization and just-in-time requests
The biggest privacy mistake is requesting every permission at app launch.
// ❌ BAD — ask for everything at launch, user has no idea why
useEffect(() => {
requestCameraPermission();
requestLocationPermission();
requestContactsPermission();
requestNotificationPermission();
}, []);
// ✅ GOOD — ask at the moment of need, with context
import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions';
import { Platform } from 'react-native';
const LOCATION_PERMISSION = Platform.select({
ios: PERMISSIONS.IOS.LOCATION_WHEN_IN_USE,
android: PERMISSIONS.ANDROID.ACCESS_COARSE_LOCATION, // ✅ COARSE, not FINE
});
export async function requestLocationWithContext() {
const status = await check(LOCATION_PERMISSION);
if (status === RESULTS.GRANTED) return true;
if (status === RESULTS.DENIED) {
// Explain why BEFORE the system prompt
const userAgreed = await showRationale({
title: "Let's find nearby stores",
message:
'We use your location only to show the stores closest to you. ' +
'It never leaves your device and is not stored.',
confirmText: 'Continue',
cancelText: "I'll pick a city",
});
if (!userAgreed) {
// ✅ Accept the refusal and offer an alternative
return navigateToManualCitySelection();
}
const result = await request(LOCATION_PERMISSION);
return result === RESULTS.GRANTED;
}
if (status === RESULTS.BLOCKED) {
// ✅ Don't push the user to Settings, offer an alternative
return navigateToManualCitySelection();
}
return false;
}
Minimization in permission choice
| Need | ❌ Excessive | ✅ Minimum |
|---|---|---|
| Nearby stores | ACCESS_FINE_LOCATION |
ACCESS_COARSE_LOCATION |
| Profile photo | READ_EXTERNAL_STORAGE |
Photo Picker (no permission) |
| Continuous location | ACCESS_BACKGROUND_LOCATION |
WHEN_IN_USE |
| Finding friends | READ_CONTACTS |
Manual invite by the user |
| QR scanning | Camera + gallery | Camera only |
The Android 13+ and iOS 14+ photo pickers require no permission — the user shares only the photo they selected. That's far less data access than requesting READ_EXTERNAL_STORAGE.
3. Log and crash report hygiene (OWASP Scenario #1)
This is the most common M6 violation in React Native projects.
// ❌ All of these leak PII
console.log('User logged in:', user); // the whole user object
console.log('Response:', JSON.stringify(res)); // API response
console.log('Token:', accessToken); // credentials
console.error('Query failed:', sqlQuery); // query + parameters
console.log output is readable via adb logcat on Android and Console.app on iOS. Anyone with device access — and in some cases another app — can see it.
Step 1: Strip console in production
// babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
env: {
production: {
plugins: [
['transform-remove-console', { exclude: ['error', 'warn'] }],
],
},
},
};
But since you're excluding error and warn, make sure you don't write PII there either.
Step 2: Write a redaction layer
// utils/redact.js
const PII_KEYS = [
'email', 'phone', 'phoneNumber', 'password', 'token', 'accessToken',
'refreshToken', 'ssn', 'iban', 'cardNumber', 'cvv',
'address', 'latitude', 'longitude', 'birthDate', 'fullName',
];
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.]+/g;
// Phone numbers need either a + country code or separators. A bare run of
// digits is deliberately NOT matched: Date.now() is 13 digits, and so are
// plenty of order IDs and counters — matching those would silently corrupt
// your logs while looking like it worked.
const PHONE_RE = /\+\d{1,3}[\s.-]?\d{2,4}[\s.-]?\d{3}[\s.-]?\d{2,4}\b|\b\d{3}[\s.-]\d{3}[\s.-]\d{4}\b/g;
const IBAN_RE = /\b[A-Z]{2}\d{2}[\sA-Z0-9]{10,30}\b/g;
export function redact(value, depth = 0) {
if (depth > 6) return '[deep]';
if (typeof value === 'string') {
return value
.replace(EMAIL_RE, '[EMAIL]')
.replace(PHONE_RE, '[PHONE]')
.replace(IBAN_RE, '[IBAN]');
}
if (Array.isArray(value)) {
return value.map((v) => redact(v, depth + 1));
}
if (value && typeof value === 'object') {
const out = {};
for (const [key, val] of Object.entries(value)) {
const isPii = PII_KEYS.some((k) =>
key.toLowerCase().includes(k.toLowerCase())
);
out[key] = isPii ? '[REDACTED]' : redact(val, depth + 1);
}
return out;
}
return value;
}
Step 3: Filter crash reporting
// services/sentry.js
import * as Sentry from '@sentry/react-native';
import { redact } from '../utils/redact';
Sentry.init({
dsn: 'https://...',
// ✅ Don't send the user's IP
sendDefaultPii: false,
beforeSend(event) {
// Scrub exception messages
if (event.exception?.values) {
event.exception.values = event.exception.values.map((ex) => ({
...ex,
value: typeof ex.value === 'string' ? redact(ex.value) : ex.value,
}));
}
// Scrub extra and contexts
if (event.extra) event.extra = redact(event.extra);
if (event.contexts) event.contexts = redact(event.contexts);
// Minimize the user object — no email/IP
if (event.user) {
event.user = { id: event.user.id }; // opaque ID only
}
// Scrub request data
if (event.request) {
delete event.request.cookies;
delete event.request.headers?.Authorization;
if (event.request.data) event.request.data = redact(event.request.data);
}
return event;
},
beforeBreadcrumb(breadcrumb) {
// Strip query strings from network breadcrumb URLs
if (breadcrumb.category === 'xhr' || breadcrumb.category === 'fetch') {
if (breadcrumb.data?.url) {
breadcrumb.data.url = breadcrumb.data.url.split('?')[0];
}
}
// Scrub console breadcrumbs
if (breadcrumb.category === 'console' && breadcrumb.message) {
breadcrumb.message = redact(breadcrumb.message);
}
return breadcrumb;
},
});
⚠️ Third-party libraries log PII too. OWASP states this explicitly. Database exceptions, HTTP library errors, and SDKs' own logs are outside your control.
beforeSendcatches a lot of that — but not all of it. Sentry's own docs are clear that in the React Native SDK,beforeSendonly filters events generated from the JavaScript layer; native events from Android and iOS code are not passed through it. So a crash inside a native SDK can carry PII straight past your filter. TreatbeforeSendas a strong layer, not a complete one, and keep PII out of native-facing calls in the first place.
4. Keep PII out of URLs (OWASP Scenario #2)
// ❌ Visible in server logs, the CDN, analytics, proxies
await apiClient.get(`/users/search?email=${email}&phone=${phone}`);
// ✅ Send in the body
await apiClient.post('/users/search', { email, phone });
You can catch this with an interceptor:
// Warn during development if a URL carries PII
const PII_PARAM_NAMES = ['email', 'phone', 'token', 'ssn', 'iban', 'name'];
apiClient.interceptors.request.use((config) => {
if (__DEV__ && config.url?.includes('?')) {
const query = config.url.split('?')[1];
const params = new URLSearchParams(query);
for (const key of params.keys()) {
if (PII_PARAM_NAMES.some((p) => key.toLowerCase().includes(p))) {
console.warn(
`⚠️ Possible PII in URL query parameter: "${key}" — move it to the body`
);
}
}
}
return config;
});
The same rule applies to deep links. We covered deep link validation in M4; the additional question here is: what are you carrying in that link? Deep link URLs persist in OS logs and sometimes browser history.
5. Backup and uninstall behaviour (OWASP Scenario #3)
Android
<!-- android/app/src/main/AndroidManifest.xml -->
<application
android:allowBackup="false"
android:hasFragileUserData="false"
...>
OWASP recommends setting both explicitly — leaving them to defaults makes your intent unclear.
-
allowBackup="false"→ app data is not included in device backups -
hasFragileUserData="false"→ data is deleted when the app is uninstalled
If you set hasFragileUserData="true", an attacker who installs a malicious app with the same package id can access your old data.
If you do need backups, keep allowBackup="true" and be selective instead — the rules files only take effect when backup is on, so pointing at them while backup is disabled does nothing:
<application
android:allowBackup="true"
android:hasFragileUserData="false"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
...>
You need both attributes because they cover different versions: fullBackupContent applies up to API 30, and dataExtractionRules takes over on API 31+ (and also governs device-to-device transfer, which is separate from cloud backup).
<!-- android/app/src/main/res/xml/backup_rules.xml — API ≤ 30 -->
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<!-- Default: back up nothing -->
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="file" path="." />
<!-- Only non-sensitive preferences -->
<include domain="sharedpref" path="app_theme_prefs.xml" />
</full-backup-content>
<!-- android/app/src/main/res/xml/data_extraction_rules.xml — API 31+ -->
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="file" path="." />
<include domain="sharedpref" path="app_theme_prefs.xml" />
</cloud-backup>
<!-- Device-to-device transfer is a separate decision -->
<device-transfer>
<exclude domain="sharedpref" path="." />
<exclude domain="database" path="." />
<exclude domain="file" path="." />
</device-transfer>
</data-extraction-rules>
iOS
iOS backs up the Documents directory to iCloud by default. The underlying mechanism for opting out is the NSURLIsExcludedFromBackupKey resource value — but be aware that popular JS file-system libraries don't all expose it, so check your library's API rather than assuming a helper exists. If yours doesn't, you need a small native module.
Two approaches that don't require one:
import RNFS from 'react-native-fs';
// ✅ Option 1: Caches — never backed up, but the system may purge it.
// Good for regenerable data (thumbnails, downloaded media, parsed feeds).
const cachePath = `${RNFS.CachesDirectoryPath}/${filename}`;
await RNFS.writeFile(cachePath, content, 'utf8');
// ✅ Option 2: for secrets, don't put them in a file at all.
// Keychain with a ThisDeviceOnly accessibility class is excluded from
// backups by the OS — no extra configuration needed.
import * as Keychain from 'react-native-keychain';
await Keychain.setGenericPassword('auth', token, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
That second one connects back to M3: the ThisDeviceOnly variants we used for token storage were already solving a backup-exposure problem, not just an access-control one.
6. Clipboard leakage
The clipboard is one of the channels OWASP places in the "less protected" category. Anything you copy is accessible to other apps.
import Clipboard from '@react-native-clipboard/clipboard';
// ❌ Leaving sensitive data on the clipboard
Clipboard.setString(otpCode);
// ✅ Copy, then clear after a delay
async function copyTemporarily(value, ttlMs = 30000) {
Clipboard.setString(value);
setTimeout(async () => {
// hasString() checks for content without reading it — on iOS 14+ a
// read can surface the "pasted from" banner, so avoid getString() here.
const hasContent = await Clipboard.hasString();
if (hasContent) {
Clipboard.setString('');
}
}, ttlMs);
}
There's a real trade-off in that last block. You can't tell whether the clipboard still holds your value without reading it back, and reading it back is the thing you're trying to avoid. Clearing unconditionally means you might wipe something the user copied in the meantime. For a 30-second OTP that's usually the right call; for longer-lived values, consider not copying to the clipboard at all and offering an autofill or in-app paste instead.
You can disable copying entirely on sensitive fields:
<TextInput
value={cardNumber}
contextMenuHidden={true} // ✅ No copy/paste menu
selectTextOnFocus={false}
secureTextEntry
/>
iOS 14+ and Android 12+ show the user a notification on clipboard reads — that's how you can notice an SDK reading the clipboard without telling you.
7. Screenshot and app switcher masking
When the user leaves the app, the OS takes a screenshot and shows it in the app switcher. That image is written to disk.
import { useEffect } from 'react';
import ScreenGuardModule from 'react-native-screenguard';
function BankingScreen() {
useEffect(() => {
(async () => {
// ✅ Required from v2.0.0 onwards — register() alone will not work
await ScreenGuardModule.initSettings({
displayScreenGuardOverlay: true,
timeAfterResume: 2000,
});
await ScreenGuardModule.register({ backgroundColor: '#000000' });
})();
return () => {
ScreenGuardModule.unregister();
};
}, []);
return <View>{/* sensitive content */}</View>;
}
This also blocks screenshots and screen recording (via FLAG_SECURE on Android). Check the version you install: the v1.x API took a colour string directly and had no initSettings, so older snippets you find online won't work on v2 and vice versa.
8. App Tracking Transparency (iOS)
Since iOS 14.5, you must ask the user for permission before collecting tracking identifiers like the IDFA.
import {
getTrackingStatus,
requestTrackingPermission,
} from 'react-native-tracking-transparency';
export async function initTracking() {
const status = await getTrackingStatus();
if (status === 'not-determined') {
// ✅ Give context before the prompt
await showTrackingRationale();
const newStatus = await requestTrackingPermission();
if (newStatus === 'authorized') {
enableAdTracking();
} else {
// ✅ Actually honour the refusal — turn the SDKs off too
disableAdTracking();
}
return;
}
if (status === 'authorized' || status === 'unavailable') {
enableAdTracking();
} else {
disableAdTracking();
}
}
<!-- ios/YourApp/Info.plist -->
<key>NSUserTrackingUsageDescription</key>
<string>We'd like to use your advertising identifier to show you more relevant ads. If you decline, the app works exactly the same.</string>
⚠️ The most critical point: when the status is
denied, actually disable your ad SDKs. Asking for permission and ignoring the refusal is a heavier violation than never asking — both legally and under App Store policy.
9. Platform declarations: Privacy Manifest and Data Safety
These two aren't code, they're declarations. But filled in wrongly, your app gets rejected or creates a compliance problem later.
iOS — PrivacyInfo.xcprivacy
Apple stopped accepting app submissions without a privacy manifest as of May 1, 2024. The file declares why the app calls certain APIs Apple considers sensitive — currently these cover accessing UserDefaults, file timestamps, system boot time, disk space, and the active keyboard. Apple describes this as an open list that may expand.
React Native generates a template during the pod install step. You may still need to add the reasons for APIs used by your dependencies to your own PrivacyInfo.xcprivacy.
In Expo this is managed through the app config:
// app.config.js
export default {
expo: {
ios: {
privacyManifests: {
NSPrivacyAccessedAPITypes: [
{
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults',
NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
},
{
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp',
NSPrivacyAccessedAPITypeReasons: ['C617.1'],
},
],
NSPrivacyCollectedDataTypes: [
{
NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeEmailAddress',
NSPrivacyCollectedDataTypeLinked: true,
NSPrivacyCollectedDataTypeTracking: false,
NSPrivacyCollectedDataTypePurposes: [
'NSPrivacyCollectedDataTypePurposeAppFunctionality',
],
},
],
NSPrivacyTracking: false,
},
},
},
};
Android — Google Play Data Safety
The form you complete in the Play Console's App Privacy tab. The Data safety form must match your privacy policy and your app's actual behaviour. A mismatch can delay release or create a compliance problem later.
This is where your PII_INVENTORY file pays off: you fill the form from the inventory rather than from memory.
10. Third-party SDK data flows
We covered the supply chain in M2 and SDK traffic in M5. The M6 question is: which personal data do these SDKs collect, and who do they send it to?
Don't rely only on package.json. React Native apps pull native SDKs through CocoaPods, Gradle, Expo config plugins, and transitive dependencies.
Checklist:
- What data does the SDK collect? (check the traffic, not the docs)
- Does it ship its own privacy manifest? (Apple requires it)
- Does its behaviour change when ATT is
denied? - Is the collected data declared in your Data Safety form?
- Do you have a data processing agreement (DPA) in place?
11. User rights: access, export, deletion
GDPR and similar regulations give users the right to access their data and request deletion. That has to exist in the app.
// screens/PrivacySettingsScreen.jsx
export default function PrivacySettingsScreen() {
return (
<ScrollView>
<Section title="Your data">
<Row
label="Download my data"
description="Receive a copy of all data related to your account by email"
onPress={requestDataExport}
/>
<Row
label="Delete my account"
description="Your account and all your data are permanently deleted"
onPress={confirmAccountDeletion}
destructive
/>
</Section>
<Section title="Permissions">
<Toggle
label="Analytics"
description="Helps us improve the app"
value={consent.analytics}
onChange={(v) => updateConsent('analytics', v)}
/>
<Toggle
label="Personalized ads"
value={consent.advertising}
onChange={(v) => updateConsent('advertising', v)}
/>
</Section>
</ScrollView>
);
}
Centralize consent management — scattered code toggling SDKs one by one becomes a violation the moment one toggle is forgotten:
// services/consent.js
const consentState = { analytics: false, advertising: false };
export async function applyConsent(newState) {
Object.assign(consentState, newState);
// ✅ Every SDK managed from one place
analytics.setEnabled(consentState.analytics);
adSdk.setPersonalizationEnabled(consentState.advertising);
crashReporting.setEnabled(consentState.analytics);
await persistConsent(consentState);
await logConsentChange(consentState); // ✅ Keep a consent record
}
That last line matters: record the consent. In an audit, saying "the user consented" isn't enough — you need to be able to show it.
Testing Strategies
Privacy testing differs from the other items: you aren't looking for a flaw, you're looking for excess.
Scanning traffic for PII
# pii_scanner.py — a mitmproxy script
import re
PATTERNS = {
'EMAIL': re.compile(rb'[\w.+-]+@[\w-]+\.[\w.]+'),
'PHONE': re.compile(rb'\b\d{10,}\b'),
'IBAN': re.compile(rb'\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b'),
}
def request(flow):
scan(flow.request.pretty_url.encode(), 'URL', flow)
if flow.request.content:
scan(flow.request.content, 'BODY', flow)
def scan(data, where, flow):
for name, pattern in PATTERNS.items():
if pattern.search(data):
print(f"⚠️ {name} in {where} → {flow.request.host}{flow.request.path[:60]}")
Run this and navigate through the app. Every unexpected host and every match deserves investigation.
Log scanning
# Android — scan logs for PII while the app runs
adb logcat -c && adb logcat | grep -iE "email|token|password|@.*\.(com|net)|[0-9]{10,}"
# iOS — Console.app, or:
xcrun simctl spawn booted log stream --predicate 'processImagePath contains "YourApp"' \
| grep -iE "email|token|password"
Backup content check
# Android — take a backup and inspect its contents
adb backup -f backup.ab com.yourapp
dd if=backup.ab bs=1 skip=24 | zlib-flate -uncompress | tar -tvf -
# With allowBackup="false" this should come back empty
Automated tests
// __tests__/privacy.test.js
import { redact } from '../src/utils/redact';
describe('redaction', () => {
it('masks email addresses', () => {
const input = { note: 'Contact jane@example.com for details' };
expect(JSON.stringify(redact(input))).not.toContain('jane@example.com');
});
it('masks PII keys', () => {
const input = { email: 'a@b.com', phone: '5551234567', name: 'Jane' };
const out = redact(input);
expect(out.email).toBe('[REDACTED]');
expect(out.phone).toBe('[REDACTED]');
});
it('leaves non-PII fields alone', () => {
const input = { screenName: 'Home', itemCount: 5 };
expect(redact(input)).toEqual(input);
});
});
Manual test scenarios
| Test | Method | Expected |
|---|---|---|
| Permission rationale | Trigger every permission prompt | Explanation appears first |
| Permission refusal | Deny every permission | App still works |
| ATT refusal | Deny ATT, watch traffic | No IDFA sent |
| Log leakage | Log in, scan adb logcat
|
No PII |
| URL PII | Scan all traffic | No PII in query strings |
| Backup | Run adb backup
|
Empty or non-sensitive only |
| App switcher | Background from a sensitive screen | Screen is masked |
| Account deletion | Delete account, try logging back in | Data actually gone |
| Declaration accuracy | Data Safety form vs real traffic | They match |
Tools and Resources
Privacy testing tools
| Tool | Use Case |
|---|---|
| mitmproxy + custom script | Scanning traffic for PII |
| MobSF | Static analysis of permissions and data flows |
| Exodus Privacy | Detects trackers in an APK |
| App Privacy Report (iOS 15+) | Shows what each app accessed on-device |
| Privacy Dashboard (Android 12+) | Permission usage history |
React Native libraries
| Library | Purpose |
|---|---|
react-native-permissions |
Unified permission management |
react-native-tracking-transparency |
iOS ATT |
babel-plugin-transform-remove-console |
Production log hygiene |
react-native-screenguard |
Blocking screenshots/recording |
@react-native-clipboard/clipboard |
Clipboard control |
Useful resources
| Resource | URL |
|---|---|
| OWASP M6 | owasp.org/www-project-mobile-top-10 |
| User Privacy Protection Cheat Sheet | cheatsheetseries.owasp.org |
| Testing User Privacy Protection (MASTG) | mas.owasp.org |
| OWASP Top 10 Privacy Risks | owasp.org/www-project-top-10-privacy-risks |
| Apple Privacy Manifest | developer.apple.com |
| Expo Privacy Manifests | docs.expo.dev/guides/apple-privacy |
| GDPR | gdpr.eu |
Conclusion
M6 is the item engineering teams defer most easily, because nothing breaks. Tests pass, the app runs, users don't complain. The bill arrives much later and much larger.
🎯 Security Roadmap
1. Beginner level
- Build a PII inventory (what data, why, where to)
- Strip
console.login production - Remove PII from URL query parameters
- Set
allowBackupandhasFragileUserDataexplicitly - Write a meaningful usage description for every permission
2. Intermediate level
- Just-in-time permission requests + rationale screens
- Crash reporting redaction layer (
beforeSend) - Permission minimization (COARSE, photo picker, WHEN_IN_USE)
- ATT implementation + actually honouring refusal
- Fill Privacy Manifest and Data Safety from the inventory
- Clipboard TTL and masking on sensitive screens
3. Enterprise level
- Centralized consent management + consent audit log
- User rights: data export and deletion flows
- Retention periods and automatic deletion
- PII traffic scanning in CI
- DPAs and regular audits for third-party SDKs
- Privacy-focused threat modeling
Key Takeaways
Technical impact LOW, business impact SEVERE. This inversion explains both why M6 keeps getting deferred and why it shouldn't be.
The best protection is not collecting the data at all. OWASP's six minimization questions come before adding encryption.
"We don't collect PII" is almost never true. IP address, device ID, usage logs, and crash metadata are already PII.
M6 is a lens applied to the other items. M5 + PII, M9 + PII, M3 + PII... but it has a question of its own: was this data ever necessary?
Logs and crash reports are the most common leak point. Writing a
beforeSendfilter takes far less than a week and protects far more.Consent isn't done once you've asked. You have to actually turn things off for those who decline, and record the consent — that's the "ability to show" the lawsuit language refers to.
Declarations must match reality. The Privacy Manifest and Data Safety form should reflect your app's real behaviour. A form filled from memory is a mismatch waiting to be found.
Next Steps
In our next article, we'll examine M7: Insufficient Binary Protections. We'll cover protecting the app binary against reverse engineering, tampering, and code injection, along with Hermes bytecode, obfuscation, and integrity checking strategies in React Native.
References
- OWASP Mobile Top 10 2024 — M6
- OWASP User Privacy Protection Cheat Sheet
- Testing User Privacy Protection (MASTG)
- OWASP Top 10 Privacy Risks
- EU General Data Protection Regulation
- Expo — Privacy Manifests
- React Native Privacy Manifest Discussion
- react-native-permissions
This article is the sixth in the OWASP Mobile Top 10 2024 series. Previous articles covered M1 through M5. Follow along for the rest of the series!
Top comments (0)