Mobile
Deep links and exported components: the open doors of Android and iOS apps
Exported activities, services, receivers and providers, plus deep links, are ways into your app from outside. How to check them and close them.
· 10 min read

A mobile app's attack surface is not limited to its server API. On Android, every app can talk to other apps through intents; on iOS, an app is opened through URL schemes and universal links. These mechanisms are convenient: a link in an SMS opens the payment screen directly, a button in the browser jumps into the app. But each such "door" is a place where the app accepts external, untrusted data. When a mobile app goes through the security review, the expert lists exactly these doors and tests each one.
In this article we look at Android exported components, deep links, App Links verification and iOS universal links: what the problem is, how to check it yourself and how to close it.
What the problem is
An Android app is built from four kinds of components:
- Activity — a screen;
- Service — a background service;
- Broadcast receiver — receives messages from the system or other apps;
- Content provider — a layer that exposes data to other apps through URIs.
Each component is declared in AndroidManifest.xml and marked with the android:exported attribute. If exported="true", any other app on the device (including a malicious one) can call it directly. And the code inside the component is often written on the assumption that "only my own app calls me".
Typical mistakes:
- An internal screen is exported. For example,
SettingsActivityorPinChangeActivityshould only open after login, but because it is exported it can be opened from outside, bypassing authentication. - Blind trust in intent data. An activity takes a
urlparameter from the intent and opens it in a WebView, or putsamountandaccountstraight into a payment form. This is called intent spoofing: an external app sends a fake but plausible request. - Unprotected content provider. The provider is exported and requires no permission, so another app reads user data via a
content://URI. Providers that serve files sometimes also allow path traversal with../. - A broadcast receiver accepts any message. For example, it does not check who sent the "refresh session" or "sync now" command.
- Deep link parameters are not validated. A
myapp://open?url=...link opens any website in the in-app WebView, andmyapp://transfer?to=...&sum=...performs an action directly.
Since Android 12 (API 31), every component with an intent filter must declare android:exported explicitly. That is a good change, but it does not solve the problem: developers often just put true everywhere to make the build error go away.
Custom schemes vs App Links
Deep links come in two kinds:
- Custom URL scheme (
myapp://...). Any app can register it. If two apps declare the same scheme, the system asks the user or opens the other one. So passing secrets through such a link (an OAuth code or a confirmation token, for example) is risky — another app can intercept it. - Android App Links / iOS Universal Links (
https://example.uz/...). The domain owner places a file on the server confirming which app may open these links. Another app cannot "claim" that domain.
If OAuth uses a custom scheme, PKCE (RFC 7636) must be mandatory, so that an intercepted code is useless on its own.
Why it matters for the security review
Mobile app reviews are based on the OWASP MASVS and MASTG methodology. The MASVS PLATFORM group requires the app to interact with the platform securely: IPC mechanisms are exposed only when needed, and data from outside is validated. In the OWASP Mobile Top 10 (2024) this falls under M4 (Insufficient Input/Output Validation) and M8 (Security Misconfiguration), and sometimes M3 (Insecure Authentication/Authorization) when an exported screen allows bypassing authentication.
The expert unpacks the APK, extracts every exported component from the manifest and calls each one by hand. Automated tools flag this immediately as well, so closing it before the review is both easy and worthwhile.
How to check it yourself
1. Open the manifest
Unpack the release APK with apktool and look for exported components:
apktool d app-release.apk -o app_src
grep -n 'exported="true"' app_src/AndroidManifest.xml
grep -n '<intent-filter' app_src/AndroidManifest.xml
Note: what you need to check is the final, merged manifest. Libraries add their own components, and those are exposed under your app's name too. Android Studio shows this in the Merged Manifest tab or in APK Analyzer.
For each exported component ask one question: "Should another app call this?" If the answer is no — exported="false".
2. Call the components from outside
On a test device via adb:
# Open an activity directly
adb shell am start -n uz.example.app/.ui.PinChangeActivity
# Test a deep link
adb shell am start -a android.intent.action.VIEW \
-d "myapp://open?url=https://evil.example.com"
# Send a broadcast
adb shell am broadcast -a uz.example.app.SYNC_NOW
# Read from a content provider
adb shell content query --uri content://uz.example.app.provider/users
Expected result: internal screens do not open or redirect to login; the deep link does not open a foreign domain; the broadcast is ignored; the provider returns a permission error.
For broader testing there are tools like drozer, which list and invoke exported components automatically.
3. Check App Links status
adb shell pm get-app-links uz.example.app
The domain should show verified. If it shows none or legacy_failure, the assetlinks.json file is wrong or missing.
curl -i https://example.uz/.well-known/assetlinks.json
The response must be 200, Content-Type: application/json, with no redirect, and contain the SHA-256 fingerprint of the release signing certificate.
4. Check iOS
In the Info.plist inside the IPA, look at CFBundleURLTypes (custom schemes), and in the entitlements at com.apple.developer.associated-domains. The server file:
curl -i https://example.uz/.well-known/apple-app-site-association
Open a link in the simulator:
xcrun simctl openurl booted "myapp://open?url=https://evil.example.com"
How to fix it
Android: the manifest
Internal components must be closed:
<activity
android:name=".ui.PinChangeActivity"
android:exported="false" />
<receiver
android:name=".sync.SyncReceiver"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
If a component really must be open only to your own family of apps, use a signature-level permission:
<permission
android:name="uz.example.permission.INTERNAL"
android:protectionLevel="signature" />
<service
android:name=".api.PartnerService"
android:exported="true"
android:permission="uz.example.permission.INTERNAL" />
Do not write your own provider for file sharing — use FileProvider and list only a specific folder in file_paths.xml (not the whole internal storage).
Android: App Links
Bind the deep link activity only to a verified domain:
<activity android:name=".DeepLinkActivity" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="example.uz"
android:pathPrefix="/app" />
</intent-filter>
</activity>
On the server, /.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "uz.example.app",
"sha256_cert_fingerprints": ["AB:CD:...:EF"]
}
}]
If you use Google Play App Signing, take the fingerprint of the app signing key from Play Console. Make sure nginx serves the file with the right type:
location = /.well-known/assetlinks.json {
default_type application/json;
}
Every input is untrusted input
Even with a correct manifest, deep link parameters must be validated. A Kotlin example:
val uri = intent.data ?: return finish()
if (uri.scheme != "https" || uri.host != "example.uz") return finish()
val target = uri.getQueryParameter("url")
val allowed = setOf("example.uz", "pay.example.uz")
val host = target?.let { Uri.parse(it).host }
if (host == null || host !in allowed) return finish()
Rules:
- A deep link never performs an action directly. It only opens a screen, and the user confirms the action (for example, sees the payment amount and enters a PIN).
- A screen opened via a deep link also checks the session and permissions.
- An external URL goes through a domain allowlist before it is opened in a WebView.
- When creating a
PendingIntent, useFLAG_IMMUTABLEand never pass an empty (implicit) intent. - Calling
startActivity()with a value from an incoming intent (intent redirection) — only for internal, explicitly named components.
Flutter
In Flutter, deep links are usually handled with the app_links package or through go_router. The manifest part is the same as above; on the Dart side, check the route:
GoRoute(
path: '/app/payment/:id',
redirect: (context, state) {
final loggedIn = auth.isLoggedIn;
if (!loggedIn) return '/login?next=${state.uri.path}';
return null;
},
builder: (context, state) =>
PaymentScreen(id: state.pathParameters['id']!),
),
Route parameters should be identifiers only; the amount, account number and other important values are fetched from the server by that identifier.
iOS: universal links
Add Associated Domains in Xcode: applinks:example.uz. The apple-app-site-association file on the server (no extension, JSON, no redirect):
{
"applinks": {
"details": [{
"appIDs": ["TEAMID.uz.example.app"],
"components": [{ "/": "/app/*" }]
}]
}
}
Where possible, replace the custom scheme (myapp://) with a universal link. If you keep it, validate the host, path and parameters in application(_:open:options:) or SwiftUI's onOpenURL exactly as on Android. Never pass secret codes through a custom scheme.
Short checklist
- Every
exported="true"in the merged manifest is justified; the rest arefalse. - Components added by libraries have been reviewed too.
- Content providers are not exported or are protected by a
signaturepermission; files go throughFileProvider. - Broadcast receivers do not react to external messages.
- Deep links are
httpsApp Links / Universal Links;assetlinks.jsonandapple-app-site-associationare correct on the server. -
pm get-app-linksshowsverified. - Deep link parameters are checked against an allowlist; no action is performed directly.
- A screen opened via a deep link checks the session.
- If OAuth uses a custom scheme, PKCE is enabled.
-
PendingIntents useFLAG_IMMUTABLE. - Manual testing with
adb am start,am broadcastandcontent queryhas been done.
Conclusion
Exported components and deep links are the most overlooked part of an app: they are opened by a single attribute at build time and are invisible in code review. The expert, however, lists them in the very first step. Cleaning up the manifest, moving to App Links / Universal Links and treating every external input as untrusted is the right way to close these findings. Related articles: WebView security and what a mobile app security review checks.
If you want to see the list of open doors in your app before the review, request a security scan. The Claude AI agent quickly goes through the manifest and code, and our specialist confirms each finding on a device and helps you close it.


