Le périmètre de sécurité d’une application mobile ne s’arrête pas à l’API. C’est pourtant là que s’arrêtent la plupart des revues : le transport est en TLS, la base est chiffrée au repos, l’hébergeur est certifié, dossier clos.
Le téléphone, lui, est un environnement que vous ne contrôlez qu’en partie. Vous décidez de ce que votre code écrit. Vous ne décidez pas de ce que le système d’exploitation écrit à votre place, et il en écrit beaucoup : une image de votre dernier écran affiché, une copie sur disque de vos réponses HTTP, un dictionnaire alimenté par tout ce que l’utilisateur a saisi, une sauvegarde de votre base locale dans un compte cloud grand public.
Aucun de ces mécanismes n’est un bug. Ce sont des fonctionnalités, documentées, activées par défaut, et conçues pour une application de recettes de cuisine. Le jour où votre application manipule un dossier patient, un relevé bancaire ou une pièce d’identité, elles deviennent chacune une fuite de données, et pas une seule n’apparaîtra dans un scan de vulnérabilités côté serveur.
Voici les neuf endroits où la donnée atterrit sans que vous l’ayez décidé, ce que chacun expose, et le correctif. La checklist complète est reprise en fin d’article, réutilisable telle quelle.
Les trois zones où la donnée atterrit
Avant la liste, une carte. Sur un téléphone, la donnée se répartit en trois zones, et la deuxième est celle qu’on oublie systématiquement en revue de code, parce qu’aucune ligne de votre dépôt ne la produit.
| Zone | Ce qui s’y écrit |
|---|---|
| Ce que votre code écrit | Stockage clé-valeur (AsyncStorage, MMKV, SQLite), stockage sécurisé (Keychain, Keystore), journaux (console, télémétrie) |
| Ce que l’OS écrit à votre place | Snapshot du multitâche à chaque passage en arrière-plan, cache HTTP et cache d’images, dictionnaire du clavier alimenté par les champs non marqués, presse-papier système partagé entre applications |
| Ce qui quitte le téléphone | Sauvegardes iCloud et Drive vers un compte personnel, charges utiles transitant par APNs et FCM, journaux de crash et rejeu de session vers un prestataire souvent hors UE |
1. Le stockage local n’est pas chiffré par défaut
Le mécanisme. AsyncStorage est un fichier. Sur Android, une base SQLite
posée dans le répertoire privé de l’application ; sur iOS, des fichiers dans le
conteneur applicatif. La bibliothèque ne chiffre rien, elle ne l’a jamais
prétendu. La protection dont vous bénéficiez est celle du bac à sable de l’OS,
et elle vaut ce que vaut l’appareil : rien du tout sur un téléphone rooté ou
jailbreaké, pas grand-chose sur un Android où la sauvegarde automatique est
active, et sur iOS, la classe de protection par défaut rend les fichiers lisibles
dès le premier déverrouillage après démarrage, y compris pendant que l’écran est
verrouillé.
Ce que ça expose. Tout ce qu’on y met par facilité : le jeton de session, le jeton de rafraîchissement, le profil utilisateur, et le cache métier que quelqu’un a ajouté un vendredi pour que l’écran d’accueil s’affiche plus vite.
Le correctif. Les secrets vont dans le Keychain iOS et l’Android Keystore, via
expo-secure-store ou react-native-keychain. Deux précisions qui font la
différence entre un correctif et une case cochée. D’une part, le stockage
sécurisé est fait pour des secrets courts : expo-secure-store documente qu’une
valeur au-delà de 2 048 octets peut échouer sur Android, donc n’y déversez pas un
dossier complet. D’autre part, choisissez explicitement la classe
d’accessibilité : sans elle, l’entrée peut se retrouver synchronisée dans le
trousseau iCloud et restaurée sur un appareil que l’utilisateur n’a jamais
authentifié.
import * as SecureStore from 'expo-secure-store';
const OPTIONS: SecureStore.SecureStoreOptions = {
// L'entrée reste sur cet appareil : ni trousseau iCloud, ni restauration
// sur un nouveau telephone. Sans cette ligne, la valeur par defaut suit
// la sauvegarde de l'utilisateur.
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);
}Pour un volume de données métier qui ne tient pas dans le trousseau, le schéma est : une clé aléatoire dans le stockage sécurisé, et une base locale chiffrée avec cette clé (MMKV chiffré, SQLCipher). Le trousseau garde le secret, pas les données.
2. Le snapshot du sélecteur d’applications
Le mécanisme. Quand votre application passe en arrière-plan, l’OS photographie son interface pour l’afficher dans le sélecteur d’applications, et il écrit cette image sur le disque. iOS le fait dans un répertoire du conteneur, Android génère une vignette de tâche. Vous n’avez rien demandé, vous ne pouvez pas refuser, et l’image représente exactement le dernier écran consulté.
Ce que ça expose. Le dossier ouvert au moment où l’utilisateur a reçu un appel. Un montant, un nom, un résultat d’examen, un code à usage unique. La même image se retrouve dans la sauvegarde de l’appareil, et s’affiche à quiconque double-clique sur le bouton d’accueil.
Le correctif. Ils diffèrent par plateforme, et c’est la raison pour laquelle
ce point passe si souvent à travers les mailles. Sur Android, FLAG_SECURE sur
l’activité règle le sujet d’un coup : plus de vignette dans les récents, plus de
capture d’écran, plus d’enregistrement vidéo. Sur iOS, il n’existe aucun
équivalent : on ne peut pas empêcher une capture d’écran, on peut seulement
recouvrir l’interface avant que le système ne la photographie. Le moment compte,
parce que AppState passe par inactive avant background et que le snapshot
est pris à la bascule.
import { useEffect, useState } from 'react';
import { AppState, StyleSheet, View } from 'react-native';
/**
* Recouvre l'interface des que l'application quitte le premier plan, avant que
* le systeme n'en prenne l'instantane. Le declencheur est 'inactive' et non
* 'background' : sur iOS, la photo est deja prise quand 'background' arrive.
*/
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 a un coût qu’il faut assumer : l’utilisateur ne peut plus envoyer
de capture d’écran au support, et le partage d’écran cesse de fonctionner. C’est
un arbitrage, pas une évidence, et il se prend écran par écran.
3. Le presse-papier est un espace partagé
Le mécanisme. Le presse-papier n’appartient pas à votre application, c’est un tampon système accessible aux autres. Les deux plateformes ont resserré les règles, sans supprimer le problème : depuis Android 10, seule l’application au premier plan ou le clavier par défaut peuvent lire le presse-papier, et iOS 16 demande le consentement de l’utilisateur pour un collage entre applications. Mais votre valeur reste dans le tampon, et sur iOS le presse-papier universel la recopie sur le Mac et l’iPad de l’utilisateur si Handoff est actif.
Ce que ça expose. Le bouton « copier » posé à côté d’un code à usage unique, d’un numéro de dossier ou d’un identifiant patient. Et tout ce que l’utilisateur copie lui-même depuis un champ de texte que vous n’avez pas verrouillé.
Le correctif. Le meilleur correctif est de ne pas proposer la copie sur une
valeur sensible. Quand c’est impossible, l’écriture se qualifie : sur iOS,
localOnly empêche la synchronisation entre appareils et expirationDate fixe
une péremption ; sur Android 13 et au-delà, le drapeau
ClipDescription.EXTRA_IS_SENSITIVE empêche au minimum l’aperçu système
d’afficher la valeur en clair. Et sur les champs de saisie concernés,
contextMenuHidden supprime le menu copier-coller.
4. Le clavier apprend ce que vous ne marquez pas
Le mécanisme. La correction automatique et les suggestions de texte s’appuient sur un dictionnaire personnel que le clavier construit à partir de ce que l’utilisateur tape. Ce dictionnaire vit sur l’appareil, se synchronise sur iOS avec les autres appareils du même compte iCloud, et n’est pas rattaché à votre application. Un clavier tiers auquel l’utilisateur a accordé l’accès complet peut en plus transmettre la frappe à ses propres serveurs.
Ce que ça expose. Les noms propres, les pathologies, les identifiants, et tout le texte libre d’un champ de commentaire clinique. Le symptôme est caractéristique : un nom de patient qui apparaît en suggestion dans une autre application, sur un autre appareil.
Le correctif. secureTextEntry couvre les vrais mots de passe et suffit pour
eux. Pour les champs sensibles qui ne sont pas des mots de passe, et ce sont les
plus nombreux, il faut désactiver l’apprentissage explicitement. Sur Android,
autoCorrect={false} positionne bien le drapeau « pas de suggestions », mais
certains claviers continuent d’alimenter leur personnalisation : le levier fiable
est keyboardType="visible-password", au prix d’une disposition de clavier
différente. Il faut donc décider si un champ est assez sensible pour dégrader son
ergonomie.
import { Platform, TextInput } from 'react-native';
export function ClinicalNoteInput(props: React.ComponentProps<typeof TextInput>) {
return (
<TextInput
{...props}
autoCorrect={false}
spellCheck={false}
autoComplete="off"
// iOS : aucune categorie de remplissage automatique associee au champ.
textContentType="none"
// Android : le drapeau le plus sur contre l'apprentissage du clavier.
keyboardType={Platform.OS === 'android' ? 'visible-password' : 'default'}
contextMenuHidden
/>
);
}5. Les notifications push s’affichent sur l’écran verrouillé
Le mécanisme. Une notification suit un chemin que vous ne maîtrisez pas de bout en bout. La charge utile part chez Apple ou chez Google, elle est rendue par le système sur l’écran verrouillé, et elle est recopiée sur la montre, sur le Mac apparié et sur l’écran de la voiture. Rien de tout cela ne passe par votre code.
Ce que ça expose. Deux choses, et on n’en voit généralement qu’une. La première est la diffusion à la cantonade : « Rappel : votre séance de rééducation pour lombalgie chronique » sur un téléphone posé sur une table en open space est une divulgation de donnée de santé, à tous ceux qui passent. La seconde est plus discrète : ce texte est une donnée de santé déposée sur l’infrastructure d’un tiers, hors de votre périmètre certifié.
Le correctif. La charge utile ne contient rien. Le push dit qu’il y a une information à consulter, l’application la récupère après authentification. Si le contenu riche est indispensable, iOS permet de le déchiffrer localement dans une extension de service de notification, à partir d’une charge chiffrée par vos soins. Ce qu’il ne faut surtout pas faire, c’est se reposer sur le réglage « masquer les aperçus » : c’est une préférence utilisateur, elle ne constitue pas une mesure technique opposable en audit.
6. Les journaux de crash en savent plus que vous ne croyez
Le mécanisme. Un SDK de supervision ne remonte pas qu’une pile d’appels. Il remonte des fils d’Ariane : navigation entre écrans, requêtes réseau avec leur URL, sorties console, interactions tactiles, contexte de l’appareil, et, si le rejeu de session est activé, une reconstitution de l’interface. Chacun de ces canaux est ouvert par défaut ou en une ligne de configuration.
Ce que ça expose. Une URL comme /api/v1/patients/8421/assessments est une
donnée à caractère personnel réidentifiante, même sans corps de réponse. Un
instantané d’état applicatif joint à un crash contient le dossier en cours. Et le
tout part vers un SaaS souvent hébergé hors UE, ce qui transforme un problème de
sécurité en problème de transfert et de sous-traitance.
Le correctif. Le masquage se fait à la source, dans l’application, avant l’envoi, et pas par une règle de filtrage dans le tableau de bord du prestataire : une règle côté serveur suppose que la donnée y est déjà arrivée. Concrètement : un crochet qui nettoie les événements et les fils d’Ariane, l’intégration console désactivée en production, la capture des corps de requête coupée, et un identifiant opaque plutôt que l’adresse e-mail de l’utilisateur. Pour le rejeu de session, la seule politique tenable est le masquage total par défaut, avec une liste blanche de ce qu’on accepte de voir. Une liste noire de champs à masquer est une course perdue d’avance : le prochain écran ajouté n’y sera pas.
import * as Sentry from '@sentry/react-native';
const ID_IN_PATH = /\/(patients|dossiers|users)\/[^/]+/g;
Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
// Le rejeu de session masque tout par defaut. On ouvre ensuite au cas par
// cas, jamais l'inverse.
integrations: [Sentry.mobileReplayIntegration({ maskAllText: true, maskAllImages: true })],
beforeBreadcrumb(breadcrumb) {
// Les sorties console partent en fil d'Ariane : on les jette.
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) {
// Un identifiant opaque suffit a regrouper les erreurs d'un utilisateur.
if (event.user) event.user = { id: event.user.id };
delete event.contexts?.state;
return event;
},
});7. Le cache HTTP et le cache d’images survivent à la déconnexion
Le mécanisme. Sur iOS, fetch passe par NSURLSession et son cache disque
partagé : toute réponse que vos en-têtes déclarent cacheable est écrite dans le
conteneur de l’application. Sur Android, OkHttp fait de même dès qu’un cache est
configuré. Les composants d’image, eux, cachent systématiquement le fichier sur
disque, indexé par URL, sans aucun rapport avec la durée de vie de la session.
Ce que ça expose. Des corps JSON contenant des données cliniques, et des documents ou imageries récupérés par URL signée, qui restent sur le disque après la déconnexion, sont inclus dans la sauvegarde de l’appareil, et survivent à la révocation du jeton. L’utilisateur suivant du même téléphone n’a pas besoin de s’authentifier pour que ces fichiers soient là.
Le correctif. Le seul levier qui fonctionne des deux côtés est côté serveur :
Cache-Control: no-store sur toute réponse qui porte de la donnée personnelle.
Ajoutez-y une purge explicite à la déconnexion, du cache d’URL comme du cache
d’images, et rangez ce qui doit persister dans un répertoire exclu de la
sauvegarde. Enfin, une URL signée valable une semaine n’est pas une URL signée,
c’est une URL.
8. Les sauvegardes automatiques exfiltrent votre base locale
Le mécanisme. C’est le point le plus contre-intuitif de la liste, parce que le mécanisme est activé par défaut et qu’il fonctionne exactement comme prévu. Sur Android, la sauvegarde automatique est active tant que vous n’écrivez pas le contraire dans le manifeste : le répertoire de données de l’application est téléversé dans le Drive de l’utilisateur, et Android 12 y a ajouté le transfert direct d’appareil à appareil. Sur iOS, le conteneur applicatif part dans iCloud à l’exception du répertoire de caches et des fichiers explicitement marqués comme exclus.
Ce que ça expose. Votre base locale, vos préférences, vos fichiers cachés, recopiés dans un compte cloud grand public, hors de votre périmètre certifié, et restaurés sur un appareil neuf sans nouvelle authentification. Vous avez choisi un hébergeur agréé pour les données de santé, et une fonctionnalité système en place une copie chez un fournisseur que vous n’avez jamais audité.
Le correctif. Sur Android, désactiver la sauvegarde, ou la restreindre par
règles d’extraction si le produit en a besoin pour autre chose. Sur iOS, marquer
la base et les documents comme exclus de la sauvegarde, et utiliser une classe
d’accessibilité de trousseau en ThisDeviceOnly, comme au point 1. En Expo, le
manifeste ne s’édite pas à la main : cela passe par les propriétés de build.
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"android": {
"manifestQueries": [],
"allowBackup": false
}
}
]
]
}
}9. Les logs de développement partis en production
Le mécanisme. Un console.log en React Native n’est pas un no-op dans une
build de release. Sur Android, il atterrit dans logcat, lisible par ADB depuis
n’importe quelle machine sur laquelle l’utilisateur branche son téléphone. Et il
n’est presque jamais seul : un logger de store, une sortie de debug de client
GraphQL, un intercepteur qui journalise la réponse pour dépanner un bug de la
semaine dernière.
Ce que ça expose. Des réponses d’API complètes et des jetons en clair dans un
journal système. Et, effet de bord souvent ignoré, ces mêmes lignes sont
récupérées comme fils d’Ariane par le SDK de supervision du point 6 : un
console.log mal placé n’est pas un problème local, il alimente une chaîne
d’exfiltration.
Le correctif. Le retrait se fait à la compilation, pas par discipline.
babel-plugin-transform-remove-console supprime les appels de la build de
production, une règle de lint interdit l’appel direct, et ce qu’il faut vraiment
journaliser passe par un logger qui expurge par nom de champ. Surtout : la
vérification se fait sur une build de release réelle, pas sur une supposition.
# Ce que voit n'importe qui avec un cable USB, sur une build de production.
adb logcat --pid=$(adb shell pidof -s com.exemple.app)Physio Xpert : ce que ça a coûté en pratique
Ces neuf points ne viennent pas d’une lecture de la documentation Apple. Ils viennent de Physio Xpert, une plateforme de rééducation en production que je développe seul depuis septembre 2025 : une API Java, un back-office praticien et une application patient React Native, sur un hébergement Scalingo certifié HDS.
L’hébergement était en règle bien avant l’application mobile. C’est précisément ce qui rend l’exercice instructif : la partie difficile n’était pas la certification, c’était de constater que la conformité s’arrêtait au load balancer. Trois décisions, avec leur prix.
Le jeton de session, et le cache qui l’accompagnait. Le jeton vivait dans
AsyncStorage, comme dans à peu près tous les tutoriels React Native. Le
déplacer vers le stockage sécurisé est l’affaire d’une demi-journée. Le vrai
sujet était à côté : un cache local de dossiers, ajouté pour que l’application
reste utilisable dans une salle de rééducation où le réseau est mauvais. Chiffrer
ce cache était faisable ; il fallait alors gérer une clé, sa rotation et son
effacement à la révocation du compte, sur un produit maintenu par une seule
personne. J’ai tranché dans l’autre sens : supprimer le cache, garder en mémoire
la séance en cours, et refaire l’appel. L’application a perdu son mode hors
ligne. C’est une régression fonctionnelle assumée, défendue devant le client, et
c’est le genre d’arbitrage qu’un audit ne vous soufflera pas.
Les notifications de rappel de séance. La première version du rappel disait ce qu’il y avait à faire, avec la zone du corps concernée. C’était le bon message produit et la mauvaise notification : le contenu s’affiche sur un écran verrouillé, et il transite par APNs et FCM. Le rappel est devenu muet sur le clinique. Il annonce qu’une séance est prête, l’application affiche le reste une fois ouverte. Le coût est réel, et il se mesure : un push générique fait moins ouvrir qu’un push spécifique. C’est un point de conversion échangé contre une exposition qu’aucune mesure côté serveur n’aurait rattrapée.
La télémétrie, masquée avant l’envoi. Un outil de supervision remonte volontiers un identifiant de dossier dans une URL, ou une réponse dans un contexte d’erreur. La tentation est de filtrer côté prestataire, dans le tableau de bord : c’est plus rapide, et c’est un contresens, parce que la règle ne s’applique qu’à une donnée déjà partie. Le masquage est donc fait dans l’application, avant l’envoi, avec les mêmes crochets que ceux montrés plus haut. La contrepartie est un débogage plus pauvre : quand un praticien signale une erreur, l’événement ne contient pas de quoi rejouer son cas. Il faut le reconstituer autrement, et ça prend plus de temps.
La checklist, en neuf points
Réutilisable telle quelle, avant un audit ou avant une mise en production.
| # | Ce qu’on vérifie | Le correctif |
|---|---|---|
| 1 | Ce qui est écrit dans le stockage clé-valeur | Secrets dans le Keychain ou le Keystore, en ThisDeviceOnly ; base locale chiffrée par une clé qui vit dans le trousseau |
| 2 | Ce qui reste affiché au passage en arrière-plan | FLAG_SECURE sur Android, écran de recouvrement sur inactive sur iOS |
| 3 | Les valeurs sensibles copiables | Pas de bouton copier ; sinon localOnly, péremption, drapeau de sensibilité, contextMenuHidden |
| 4 | Les champs de texte non marqués | autoCorrect={false}, spellCheck={false}, textContentType="none", clavier visible-password sur Android |
| 5 | Le contenu des notifications push | Charge utile sans donnée personnelle, contenu récupéré après authentification |
| 6 | Les fils d’Ariane et le rejeu de session | Masquage à la source, console coupée, identifiant opaque, rejeu en liste blanche |
| 7 | Le cache HTTP et le cache d’images | Cache-Control: no-store côté serveur, purge à la déconnexion, URL signées à durée courte |
| 8 | Les sauvegardes automatiques | Sauvegarde désactivée ou restreinte sur Android, fichiers exclus de la sauvegarde sur iOS |
| 9 | Les journaux en build de production | Retrait à la compilation, règle de lint, vérification sur une build de release |
Le principe
Ces neuf points n’ont pas grand-chose en commun techniquement. Ils partagent une seule propriété : dans chaque cas, la donnée est écrite par quelqu’un d’autre que vous, avec une intention parfaitement légitime, sur un support que votre architecture n’a jamais décrit.
C’est pour ça qu’ils échappent aux revues. Une revue de code regarde ce que le code fait. Un scan de dépendances regarde ce que vos bibliothèques font. Ni l’un ni l’autre ne regarde ce que le système d’exploitation fait à votre place, et c’est précisément là que se logent la moitié des écarts relevés en audit sur une application mobile.
Le principe tient en une ligne, et il vaut pour le RGPD comme pour l’HDS : la conformité descend jusqu’à l’écran, sinon elle s’arrête au load balancer.
