Technical guide

How to detect unauthorised contacts access in mobile apps

Find out which code in your app reads contacts: Android data access auditing, iOS App Privacy Report, pickers instead of broad access, and lab tests.

  • Attribute every read

    Know which feature, module or SDK touched the address book, and whether that read matched a purpose you approved.

  • Use platform evidence

    Android data access auditing and the iOS App Privacy Report show access from the operating system's side. Use both in the lab.

  • Preserve legitimate use

    Pickers, limited access and manual entry keep the feature working for people who share little or nothing.

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_PICK and the ContactsContract.Contacts.CONTENT_URI returns a URI for the selected contact. The picking app receives temporary read access to that contact without holding READ_CONTACTS. Request READ_CONTACTS only for features that genuinely need the whole provider, and never request WRITE_CONTACTS for 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, and ContactAccessButton lets 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 into noteOp().
  • onAsyncNoted() when access happens outside the original call, for example through a listener; its getMessage() 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:

  1. 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.
  2. Privacy manifests. Third-party SDKs that collect data declare it in their PrivacyInfo.xcprivacy file. Compare each SDK’s declared collection with what your journey map allows, and treat an undeclared contacts read in testing as a finding.
  3. Code review of the CNContactStore call 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.

  1. Record the OS version, app build, framework and every component on the contacts path.
  2. Run the journey with access granted, denied, limited (iOS 18 onwards) and revoked between sessions.
  3. Capture the audit record on Android and the App Privacy Report entry on iOS.
  4. Send traffic to a controlled backend and search for the synthetic markers.
  5. Repeat on a clean device without the feature in use. No contacts access should appear.
ObservationWhat it showsWhat it does not show
Permission deniedThe person chose not to shareThat the device has spyware
Read outside the expected triggerA component in your process accessed contactsWhether data left the device
Synthetic marker at an unapproved endpointContact data was transferredThat the permission was misused by the user
Contact changed by another appThe address book changedWhat 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

Common questions

  • Why is a granted contacts permission not enough to call a read authorised?

    Because a granted permission covers every component in your process, including analytics and advertising SDKs. Record the trigger, scope and destination of each contacts feature. Any read outside that record is unauthorised even when the operating system allowed it, so attributing each read to its code matters more than the permission state.

  • How do I find which code read contacts on Android?

    Use data access auditing, added in Android 11 (API level 30). Register an AppOpsManager.OnOpNotedCallback with setOnOpNotedCallback() early in the lifecycle and capture a stack trace in onNoted() for OPSTR_READ_CONTACTS. Attribution tags created with Context.createAttributionContext() separate each feature in the audit record.

  • Is there an audit callback for contacts access on iOS?

    No. iOS has no in-app callback like Android's audit API, so attribution comes from three sources: App Privacy Report (from iOS 15.2), the PrivacyInfo.xcprivacy manifests of third-party SDKs, and code review of the CNContactStore call sites in your code and dependencies.

  • Can an app use one contact without broad contacts access?

    Yes, on each platform through its own picker. On Android, Intent.ACTION_PICK with ContactsContract.Contacts.CONTENT_URI gives temporary read access to the selected contact without READ_CONTACTS. On iOS, CNContactPickerViewController shows the picker without asking for contacts authorisation, and the app receives only what the person selects.

  • Does a denied contacts permission point to spyware?

    No. A denied permission shows that the person chose not to share, not that the device has spyware. The response is manual entry or a short explanation, never a malware warning, and people who decline access should still be able to use unrelated features.

Test the contact-access boundary

Tell us the platform, framework and contact-access behaviour you need to investigate. We never ask for address books, binaries, credentials or production records.

Or request a technical demo