What can go wrong with deep links / intent handling, and how do you secure them?
A deep link is an entry point any app on the device can try to trigger — treat the data it carries with the same suspicion as untrusted network input, not as something only your own UI produced.
LEARN BY DOINGPut your intuition to work3 steps
What can go wrong with deep links / intent handling, and how do you secure them?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Two link mechanisms, one big trust difference
There are really two different technologies hiding under the phrase "deep link," and the security story is completely different for each. A custom URI scheme (myapp://reset?token=...) is just a string an app registers with the OS — nothing stops a second, unrelated app from registering the exact same scheme. On Android, if more than one installed app claims myapp://, the user gets a disambiguation sheet (best case) or, on older/misconfigured setups, whichever app the OS picks wins silently. A verified App Link (Android, https:// + android:autoVerify="true" + an assetlinks.json file hosted on your domain) or Universal Link (iOS, https:// + the associated-domains entitlement + an apple-app-site-association file) is cryptographically tied to proof that you control the domain. Once verification succeeds, the OS routes that https:// URL to your app exclusively — no chooser, and no other app can claim it without also controlling your DNS/hosting.
#Where things actually break
Beyond straightforward scheme hijacking, four other classes of bug show up constantly in review:
- Parameter injection into a
WebView. A deep link that hands a rawurlquery parameter straight towebView.loadUrl(...)turns your app into an open redirect, or worse, lets an attacker land ajavascript:URL or a page that runs in an origin your app implicitly trusts. - Exported activities/screens meant to be reached "only after login." If a sensitive screen's
Activityisexported="true"(or implicitly exported by having an intent filter) with no permission or state check, any app on the device can launch it directly with a craftedIntent, skipping whatever navigation guard your UI normally enforces. - Mutable
PendingIntents handed to the OS or other apps (notifications, widgets, app shortcuts). Before Android 12 madeFLAG_IMMUTABLEmandatory, a mutablePendingIntentcould be filled in by a malicious component with attacker-controlled extras — the class of bug popularized as StrandHogg-style task hijacking. - OAuth redirect interception. If your OAuth flow's redirect URI is a custom scheme, another app that registers the same scheme can intercept the authorization code before it reaches you.
<!-- Android manifest: a verified App Link, not a bare custom scheme --><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="app.example.com" android:pathPrefix="/reset" /></intent-filter></activity>
// https://app.example.com/.well-known/assetlinks.json[{"relation": ["delegate_permission/common.handle_all_urls"],"target": {"namespace": "android_app","package_name": "com.example.app","sha256_cert_fingerprints": ["AA:BB:CC:...:FF"]}}]
#Route deep links like untrusted network input, not like internal navigation
The fix isn't just "use verified links" — you also have to treat every query parameter that arrives through one as untrusted, exactly like a request body. That means an explicit allowlist of destinations, never a dynamic/reflection-based router that turns a path string directly into a screen or a URL load.
// ANTI-PATTERN — never load a raw deep-link parameter into a WebViewval target = intent.data?.getQueryParameter("url")webView.loadUrl(target) // open redirect, or worse, arbitrary JS execution// Correct: an explicit routing table with validation per destinationfun handleDeepLink(intent: Intent) {val uri = intent.data ?: returnwhen (uri.path) {"/reset" -> {val token = uri.getQueryParameter("token") ?: returnif (!isValidSingleUseToken(token)) return // checked server-side, short TTLnavigateTo(Screen.ResetPassword(token))}"/product" -> {val id = uri.getQueryParameter("id")?.toIntOrNull() ?: returnnavigateTo(Screen.ProductDetail(id))}else -> navigateTo(Screen.Home) // never fall through to a raw WebView load}}
#Locking down the Android-specific attack surface
Set android:exported="false" on every Activity/Service/Receiver that doesn't genuinely need to be launched by another app, and add a signature-level permission check on the ones that do but shouldn't be public. Every PendingIntent you construct should carry PendingIntent.FLAG_IMMUTABLE unless you have a specific, documented reason to let the receiving component fill in extras. For actions that should only ever be triggered by your own web domain or a trusted partner app, check Activity.getCallingPackage()/getCallingActivity() before acting on the intent, rather than trusting the intent's data alone.
val pendingIntent = PendingIntent.getActivity(context, requestCode, resultIntent,PendingIntent.FLAG_IMMUTABLE // mandatory since Android 12, still worth being explicit)
#iOS Universal Links deserve the same discipline
func application(_ application: UIApplication,continue userActivity: NSUserActivity,restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,let url = userActivity.webpageURL,let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {return false}// Same allowlist-router discipline as the Android side — no dynamic dispatch on raw paths.return router.handle(components)}
Custom myapp:// schemes still get delivered to application(_:open:options:) on iOS with the same hijack risk as Android when another app registers the same scheme (iOS resolves ties by install order, which is not something you want to depend on) — so anything sensitive should go through the apple-app-site-association-verified Universal Link path instead.
#Anything a link "authenticates" has to behave like it's public
A URL travels through SMS gateways, email servers, chat apps' link-preview scrapers, browser history, referrer headers, and screenshots — assume it will leak. A password-reset or magic-login link should carry a single-use, short-TTL token that's meaningless without a server-side check, never a real session token. For OAuth flows launched from mobile, use PKCE (a per-attempt code verifier/challenge pair) so that even if an authorization code is intercepted via a scheme collision, it's useless without the verifier that only your app generated and kept in memory.