Skip to main content
Security

Sensitive data in a mobile app: the 9 places it ends up on the device

Snapshots, clipboard, keyboard cache, iCloud backups: the OS writes your data for you. Nine things to check before a GDPR or health data audit.

Sensitive data in a mobile app: the 9 places it ends up on the device
Nathan Hallouin12 min read

The security perimeter of a mobile app does not stop at the API. That is where most reviews stop, though: transport is TLS, the database is encrypted at rest, the host is certified, case closed.

The phone is an environment you only partly control. You decide what your code writes. You do not decide what the operating system writes on your behalf, and it writes a lot: an image of the last screen you displayed, a disk copy of your HTTP responses, a dictionary fed by everything the user typed, a backup of your local database in a consumer cloud account.

None of these is a bug. They are features, documented, on by default, and designed for a recipe app. The day your app handles a patient record, a bank statement or an ID document, each one becomes a data leak, and not a single one will show up in a server-side vulnerability scan.

Here are the nine places data ends up without you deciding, what each one exposes, and the fix. The full checklist is repeated at the end, reusable as is.

The three zones data lands in

Before the list, a map. On a phone, data spreads across three zones, and the second one is the one code review misses every time, because no line in your repository produces it.

Zone What gets written
What your code writes Key-value storage (AsyncStorage, MMKV, SQLite), secure storage (Keychain, Keystore), logs (console, telemetry)
What the OS writes for you A task switcher snapshot every time you background, the HTTP and image caches, the keyboard dictionary fed by unmarked fields, the system clipboard shared across apps
What leaves the phone iCloud and Drive backups into a personal account, payloads transiting APNs and FCM, crash reports and session replay to a vendor often outside the EU

1. Local storage is not encrypted by default

The mechanism. AsyncStorage is a file. On Android, a SQLite database in the app’s private directory; on iOS, files in the app container. The library encrypts nothing, and never claimed to. The protection you get is the OS sandbox, and it is worth exactly what the device is worth: nothing at all on a rooted or jailbroken phone, not much on an Android where automatic backup is on, and on iOS the default protection class makes files readable after the first unlock following boot, including while the screen is locked.

What it exposes. Everything convenience puts there: the session token, the refresh token, the user profile, and the domain cache someone added on a Friday so the home screen would render faster.

The fix. Secrets belong in the iOS Keychain and the Android Keystore, through expo-secure-store or react-native-keychain. Two details separate a real fix from a ticked box. First, secure storage is built for short secrets: expo-secure-store documents that a value beyond 2,048 bytes may fail on Android, so do not dump a whole record in there. Second, pick the accessibility class explicitly: without it, the entry can end up synced to the iCloud Keychain and restored onto a device the user never authenticated on.

lib/session-store.ts
import * as SecureStore from 'expo-secure-store';

const OPTIONS: SecureStore.SecureStoreOptions = {
// The entry stays on this device: no iCloud Keychain, no restore onto a new
// phone. Without this line, the default follows the user's backup.
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
};

export function saveSession(token: string) {
return SecureStore.setItemAsync('session', token, OPTIONS);
}

export function clearSession() {
return SecureStore.deleteItemAsync('session', OPTIONS);
}

For domain data too large for the keychain, the pattern is: a random key in secure storage, and a local database encrypted with that key (encrypted MMKV, SQLCipher). The keychain holds the secret, not the data.

2. The task switcher snapshot

The mechanism. When your app goes to the background, the OS photographs its interface to show it in the task switcher, and writes that image to disk. iOS does it in a directory inside the container, Android generates a task thumbnail. You did not ask for it, you cannot decline it, and the image is exactly the last screen the user looked at.

What it exposes. The record that was open when the user got a phone call. An amount, a name, a test result, a one-time code. The same image ends up in the device backup, and shows to anyone who double-taps the home button.

The fix. It differs per platform, which is precisely why this one slips through so often. On Android, FLAG_SECURE on the activity settles it in one line: no recents thumbnail, no screenshot, no screen recording. On iOS there is no equivalent: you cannot prevent a screenshot, you can only cover the interface before the system photographs it. Timing matters, because AppState goes through inactive before background and the snapshot is taken on the transition.

components/privacy-shield.tsx
import { useEffect, useState } from 'react';
import { AppState, StyleSheet, View } from 'react-native';

/**
* Covers the interface as soon as the app leaves the foreground, before the
* system takes its snapshot. The trigger is 'inactive', not 'background': on
* iOS the picture is already taken by the time 'background' fires.
*/
export function PrivacyShield() {
const [hidden, setHidden] = useState(false);

useEffect(() => {
	const subscription = AppState.addEventListener('change', (state) => {
		setHidden(state !== 'active');
	});

	return () => subscription.remove();
}, []);

return hidden ? <View style={StyleSheet.absoluteFill} pointerEvents="none" /> : null;
}

FLAG_SECURE has a cost you have to own: the user can no longer send a screenshot to support, and screen sharing stops working. It is a trade-off, not an obvious win, and it is decided screen by screen.

3. The clipboard is shared space

The mechanism. The clipboard does not belong to your app, it is a system buffer other apps can reach. Both platforms have tightened the rules without removing the problem: since Android 10, only the foreground app or the default keyboard can read the clipboard, and iOS 16 asks the user to consent to a cross-app paste. But your value still sits in the buffer, and on iOS the Universal Clipboard copies it to the user’s Mac and iPad when Handoff is on.

What it exposes. The “copy” button next to a one-time code, a record number or a patient identifier. And everything the user copies themselves out of a text field you did not lock down.

The fix. The best fix is not to offer copy on a sensitive value. When that is impossible, qualify the write: on iOS, localOnly blocks cross-device sync and expirationDate sets an expiry; on Android 13 and above, the ClipDescription.EXTRA_IS_SENSITIVE flag at least stops the system paste preview from rendering the value in the clear. And on the relevant inputs, contextMenuHidden removes the copy-paste menu.

4. The keyboard learns whatever you do not mark

The mechanism. Autocorrect and predictive text rely on a personal dictionary the keyboard builds from what the user types. That dictionary lives on the device, syncs on iOS to the other devices on the same iCloud account, and is not scoped to your app. A third-party keyboard granted full access can additionally send keystrokes to its own servers.

What it exposes. Proper nouns, conditions, identifiers, and all the free text in a clinical notes field. The symptom is unmistakable: a patient’s name showing up as a suggestion in another app, on another device.

The fix. secureTextEntry covers real passwords and is enough for them. For sensitive fields that are not passwords, which is most of them, learning has to be turned off explicitly. On Android, autoCorrect={false} does set the “no suggestions” flag, but some keyboards keep feeding their personalization: the reliable lever is keyboardType="visible-password", at the cost of a different keyboard layout. So you have to decide whether a field is sensitive enough to degrade its ergonomics.

components/clinical-note-input.tsx
import { Platform, TextInput } from 'react-native';

export function ClinicalNoteInput(props: React.ComponentProps<typeof TextInput>) {
return (
	<TextInput
		{...props}
		autoCorrect={false}
		spellCheck={false}
		autoComplete="off"
		// iOS: no autofill category associated with the field.
		textContentType="none"
		// Android: the safest flag against keyboard learning.
		keyboardType={Platform.OS === 'android' ? 'visible-password' : 'default'}
		contextMenuHidden
	/>
);
}

5. Push notifications render on the lock screen

The mechanism. A notification takes a path you do not control end to end. The payload goes to Apple or Google, the system renders it on the lock screen, and it is mirrored to the watch, the paired Mac and the car display. None of that goes through your code.

What it exposes. Two things, and usually only one is noticed. The first is broadcast to the room: “Reminder: your rehab session for chronic low back pain” on a phone lying on an open-plan desk is a health data disclosure to everyone walking past. The second is quieter: that text is health data deposited on a third party’s infrastructure, outside your certified perimeter.

The fix. The payload carries nothing. The push says there is something to look at, the app fetches it after authentication. If rich content is truly required, iOS lets you decrypt it locally in a notification service extension, from a payload you encrypted yourself. What you must not do is lean on the “hide previews” setting: that is a user preference, not a technical measure you can defend in an audit.

6. Crash reports know more than you think

The mechanism. A monitoring SDK does not just send a stack trace. It sends breadcrumbs: screen navigation, network requests with their URLs, console output, touch interactions, device context, and, if session replay is on, a reconstruction of the interface. Every one of those channels is open by default or one config line away.

What it exposes. A URL like /api/v1/patients/8421/assessments is re-identifiable personal data, even without a response body. A state snapshot attached to a crash contains the record being viewed. And it all goes to a SaaS often hosted outside the EU, which turns a security problem into a transfer and processing problem.

The fix. Scrubbing happens at the source, in the app, before the send, not through a filtering rule in the vendor’s dashboard: a server-side rule assumes the data already got there. Concretely: a hook that cleans events and breadcrumbs, the console integration disabled in production, request body capture turned off, and an opaque identifier instead of the user’s email. For session replay, the only tenable policy is mask everything by default with an allowlist of what you accept seeing. A denylist of fields to mask is a losing race: the next screen someone adds will not be on it.

lib/telemetry.ts
import * as Sentry from '@sentry/react-native';

const ID_IN_PATH = /\/(patients|records|users)\/[^/]+/g;

Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
// Session replay masks everything by default. We open it up case by case,
// never the other way round.
integrations: [Sentry.mobileReplayIntegration({ maskAllText: true, maskAllImages: true })],

beforeBreadcrumb(breadcrumb) {
	// Console output ends up as breadcrumbs: drop it.
	if (breadcrumb.category === 'console') return null;

	if (typeof breadcrumb.data?.url === 'string') {
		breadcrumb.data.url = breadcrumb.data.url.replace(ID_IN_PATH, '/$1/[id]');
	}

	return breadcrumb;
},

beforeSend(event) {
	// An opaque id is enough to group one user's errors.
	if (event.user) event.user = { id: event.user.id };
	delete event.contexts?.state;
	return event;
},
});

7. The HTTP and image caches outlive logout

The mechanism. On iOS, fetch goes through NSURLSession and its shared disk cache: any response your headers declare cacheable is written into the app container. On Android, OkHttp does the same as soon as a cache is configured. Image components always cache the file on disk, keyed by URL, with no relation to the session lifetime.

What it exposes. JSON bodies carrying clinical content, and documents or imaging fetched by signed URL, sitting on disk after logout, included in the device backup, and surviving token revocation. The next person to use that phone does not need to authenticate for those files to be there.

The fix. The only lever that works on both sides is server-side: Cache-Control: no-store on every response carrying personal data. Add an explicit purge on logout, for the URL cache and the image cache alike, and put whatever must persist in a directory excluded from backup. And a signed URL valid for a week is not a signed URL, it is a URL.

8. Automatic backups exfiltrate your local database

The mechanism. This is the least intuitive one on the list, because it is on by default and works exactly as designed. On Android, automatic backup is active until you write otherwise in the manifest: the app’s data directory is uploaded to the user’s Drive, and Android 12 added direct device-to-device transfer. On iOS, the app container goes to iCloud except the caches directory and files explicitly marked as excluded.

What it exposes. Your local database, your preferences, your cached files, copied into a consumer cloud account, outside your certified perimeter, and restored onto a new device without fresh authentication. You picked a certified health data host, and an OS feature put a copy with a vendor you never audited.

The fix. On Android, disable backup, or restrict it with data extraction rules if the product needs it for something else. On iOS, mark the database and documents as excluded from backup, and use a ThisDeviceOnly keychain accessibility class, as in point 1. In Expo the manifest is not hand-edited: this goes through build properties.

app.json
{
"expo": {
	"plugins": [
		[
			"expo-build-properties",
			{
				"android": {
					"manifestQueries": [],
					"allowBackup": false
				}
			}
		]
	]
}
}

9. Development logs shipped to production

The mechanism. A console.log in React Native is not a no-op in a release build. On Android it lands in logcat, readable over ADB from any machine the user plugs the phone into. And it is rarely alone: a store logger, a GraphQL client’s debug output, an interceptor logging the response to debug something last week.

What it exposes. Full API responses and tokens in the clear in a system log. And, a side effect people routinely miss, those same lines get picked up as breadcrumbs by the monitoring SDK from point 6: a stray console.log is not a local problem, it feeds an exfiltration chain.

The fix. Removal happens at build time, not through discipline. babel-plugin-transform-remove-console strips the calls from the production build, a lint rule forbids the direct call, and whatever genuinely needs logging goes through a logger that redacts by field name. Above all: verify on a real release build, not on an assumption.

# What anyone with a USB cable sees, on a production build.
adb logcat --pid=$(adb shell pidof -s com.example.app)

Physio Xpert: what it cost in practice

These nine points did not come from reading Apple’s documentation. They came from Physio Xpert, a rehabilitation platform in production that I have been building alone since September 2025: a Java API, a practitioner back-office and a React Native patient app, on Scalingo hosting certified HDS, the French standard for health data hosting.

The hosting was compliant well before the mobile app existed. That is exactly what makes the exercise instructive: the hard part was not the certification, it was realising that compliance stopped at the load balancer. Three decisions, with their price.

The session token, and the cache that came with it. The token lived in AsyncStorage, as in roughly every React Native tutorial. Moving it to secure storage is half a day’s work. The real issue was next to it: a local record cache, added so the app stayed usable in a rehab room with poor reception. Encrypting that cache was doable; it then meant managing a key, its rotation and its erasure on account revocation, on a product maintained by one person. I cut the other way: remove the cache, keep the current session in memory, and make the call again. The app lost its offline mode. That is a functional regression I owned and defended to the client, and it is the kind of trade-off no audit will suggest to you.

The session reminder notifications. The first version of the reminder said what there was to do, including the body area involved. It was the right product message and the wrong notification: the content renders on a lock screen, and it transits APNs and FCM. The reminder went silent on anything clinical. It announces that a session is ready, and the app shows the rest once opened. The cost is real and measurable: a generic push gets opened less than a specific one. That is a conversion point traded against an exposure no server-side control would have caught.

Telemetry, scrubbed before the send. A monitoring tool will happily report a record identifier inside a URL, or a response inside an error context. The temptation is to filter on the vendor’s side, in the dashboard: it is faster, and it misses the point, because the rule only applies to data that has already left. So the scrubbing happens in the app, before the send, with the same hooks shown above. The counterpart is poorer debugging: when a practitioner reports an error, the event does not contain enough to replay their case. It has to be reconstructed some other way, and that takes longer.

The checklist, in nine points

Reusable as is, before an audit or before a release.

# What to check The fix
1 What gets written to key-value storage Secrets in the Keychain or Keystore, ThisDeviceOnly; local database encrypted with a key that lives in the keychain
2 What is still on screen when backgrounding FLAG_SECURE on Android, a covering view on inactive on iOS
3 Copyable sensitive values No copy button; otherwise localOnly, expiry, sensitivity flag, contextMenuHidden
4 Unmarked text inputs autoCorrect={false}, spellCheck={false}, textContentType="none", visible-password keyboard on Android
5 Push notification content Payload with no personal data, content fetched after authentication
6 Breadcrumbs and session replay Scrub at the source, console off, opaque id, replay on an allowlist
7 HTTP and image caches Cache-Control: no-store server-side, purge on logout, short-lived signed URLs
8 Automatic backups Backup disabled or restricted on Android, files excluded from backup on iOS
9 Logs in production builds Stripped at build time, lint rule, verified on a release build

The principle

Technically these nine points have little in common. They share one property: in every case, the data is written by someone other than you, with a perfectly legitimate intent, onto a surface your architecture never described.

That is why they escape reviews. A code review looks at what the code does. A dependency scan looks at what your libraries do. Neither looks at what the operating system does on your behalf, and that is exactly where half the findings in a mobile app audit come from.

The principle fits in one line, and it holds for GDPR as much as for health data certification: compliance goes all the way down to the screen, or it stops at the load balancer.

Share

Comments

Comments will be available once Giscus is configured.

Configure Giscus →

Navigation

Articles

Projects

Actions