To detect unauthorised contacts access, decide which reads your app is allowed to make, then attribute every actual read to the code that made it. On Android, data access auditing reports each contacts operation with a stack trace, so you can see whether your feature or an embedded SDK did it. On iOS, the App Privacy Report shows when the app accessed contacts, and the system contact picker removes the need for broad access. Prove the result in a lab with synthetic contacts and a controlled server. This guide covers what your own app does; Android and iOS are handled separately.
Define what an authorised read looks like
Start with the user journey, not the permission. Someone might pick one recipient, import an address book on purpose or refuse access altogether. Each journey has a different expected footprint.
For every contacts feature, write down three things:
- Trigger: the user action that should cause the read.
- Scope: one selected contact, a filtered query or the full address book.
- Destination: whether contact data stays on the device, is hashed for matching, or is sent to a named backend endpoint.
Anything outside that record is unauthorised, even when the operating system allowed it. A granted permission covers every component in your process, including analytics and advertising SDKs. That is why attribution matters more than the permission state.
Prefer pickers over broad access
The cheapest way to stop unauthorised reads is to never hold broad access.
- Android: launching the system contact picker with
Intent.ACTION_PICKand theContactsContract.Contacts.CONTENT_URIreturns a URI for the selected contact. The picking app receives temporary read access to that contact without holdingREAD_CONTACTS. RequestREAD_CONTACTSonly for features that genuinely need the whole provider, and never requestWRITE_CONTACTSfor a read-only journey. - iOS:
CNContactPickerViewController(ContactsUI) shows the picker without asking for contacts authorisation. The app only receives what the person selects. From iOS 18, people can also grant limited access to a chosen subset;CNContactStore.authorizationStatus(for: .contacts)then returns.limited, andContactAccessButtonlets them add contacts to that set from your interface.
When a feature uses a picker, any broad read in that build is a finding by definition.
Attribute access on Android
Android 11 (API level 30) added data access auditing. Register an AppOpsManager.OnOpNotedCallback with setOnOpNotedCallback() early in the app’s lifecycle. The system calls it when your process accesses private data:
onNoted()for a synchronous access made through an API call from your code.onSelfNoted()in the rare case where the app passes its own UID intonoteOp().onAsyncNoted()when access happens outside the original call, for example through a listener; itsgetMessage()adds context.
For contacts, the operation is OPSTR_READ_CONTACTS. Capture a stack trace in onNoted() and you can see which package made the call. That is how you catch an SDK that queries the Contacts Provider on start-up while your feature only needed a picker.
Add attribution tags to make the record clearer. Declare an <attribution> tag per feature in the manifest and create a context for it with Context.createAttributionContext(). Each noted operation then carries the tag of the feature that ran it, so “recipient picker” and “contact sync” appear as separate lines in your audit.
Keep these checks on the operation itself, not only at first launch. Check the permission each time the feature needs it, because the user can revoke it in Settings at any time. Handle a refused or revoked permission as an ordinary case and offer manual entry.
What the Android user sees
The privacy dashboard (Android 12 onwards) gives users a timeline of permission use built around location, camera and microphone. For contacts, your own auditing is the more precise evidence because it names the code path. Users can still review and revoke the contacts permission per app in Settings.
Attribute access on iOS
Every contacts request needs an NSContactsUsageDescription that explains the purpose in plain words. Check CNContactStore.authorizationStatus(for: .contacts) before showing contact-dependent features and handle all states: not determined, denied, restricted, limited and authorised. Fewer results under limited access reflect the person’s choice, not data loss.
iOS has no in-app callback like Android’s audit API, so attribution comes from three sources:
- App Privacy Report. From iOS 15.2, users and testers can turn on App Privacy Report in Settings. It shows when each app accessed data and sensors such as contacts, microphone and location over the past seven days, plus the domains the app contacted. On a test device, run one journey and confirm that contacts access appears only when the feature ran.
- Privacy manifests. Third-party SDKs that collect data declare it in their
PrivacyInfo.xcprivacyfile. Compare each SDK’s declared collection with what your journey map allows, and treat an undeclared contacts read in testing as a finding. - Code review of the
CNContactStorecall sites. Search your code and dependencies for fetch requests and enumerations, and confirm each sits behind the intended user action.
Test with synthetic contacts
Run every test on dedicated physical devices with synthetic contact entries. Give each entry a recognisable marker so your harness can spot it in a network capture or server log without involving real people.
- Record the OS version, app build, framework and every component on the contacts path.
- Run the journey with access granted, denied, limited (iOS 18 onwards) and revoked between sessions.
- Capture the audit record on Android and the App Privacy Report entry on iOS.
- Send traffic to a controlled backend and search for the synthetic markers.
- Repeat on a clean device without the feature in use. No contacts access should appear.
| Observation | What it shows | What it does not show |
|---|---|---|
| Permission denied | The person chose not to share | That the device has spyware |
| Read outside the expected trigger | A component in your process accessed contacts | Whether data left the device |
| Synthetic marker at an unapproved endpoint | Contact data was transferred | That the permission was misused by the user |
| Contact changed by another app | The address book changed | What that other app did otherwise |
A read caused by injected code looks different from a read by your own SDK: the stack trace includes code that is not in your build. Otukan detects hooking and tampering in the app process and reports it to your backend, so you can line that evidence up with your own audit record. For the wider control, see anti-spyware.
Respond in proportion
Tie the response to the data path that misbehaved.
- A denied permission leads to manual entry or a short explanation. It is never a malware warning.
- An SDK reading contacts without a purpose is a release blocker: remove it, configure it or replace it.
- An unexpected transfer by your own code is stopped at the source, and the backend rejects contact payloads the feature does not need.
- Hooking evidence during a contacts flow is handled by your runtime policy for that flow, with support access preserved.
Release checklist
- Each contacts feature has a recorded trigger, scope and destination.
- Features that need one contact use the system picker.
- Android builds register an audit callback in test builds and review it per release.
- iOS builds are checked against App Privacy Report and SDK privacy manifests.
- Diagnostics exclude names, phone numbers, email addresses and full contact payloads.
- People who decline access can still use unrelated features.
- Audit records name the app build, OS version, tester and result.
The same method applies to other protected data. For microphone access, see how to detect unauthorised microphone access.
Further reading
- Audit access to data — Android Developers
- Contacts Provider — Android Developers
- Request runtime permissions — Android Developers
- CNContactPickerViewController — Apple Developer Documentation
- CNAuthorizationStatus.limited — Apple Developer Documentation
- About App Privacy Report — Apple Support