
Deep links feel like navigation.
Security-wise, they’re public entry points into your app. Android’s own security guidance treats unsafe deep link handling as a real, exploitable vulnerability class.
If your deep link:
- opens privileged screens
- auto-executes actions
- trusts query parameters
- routes into WebViews
- or relies on “user must have come from inside the app” assumptions
…it can quietly become a backdoor.
This guide shows a production-grade deep link architecture across Android + backend, with real controls — not “just validate it” hand-waving.
The Real Threat Model (How Deep Links Get Exploited)
1) Any app can call your deep-link Activity (Intent spoofing)
A deep link Activity must be exported to be reachable externally. That also means other apps can launch it with crafted intents.
Since Android 12+, you must explicitly set android:exported when an Activity has an intent filter. Teams often flip this to true without adding proper guards.
2) Parameter tampering
Query params like:
orderId=
amount=
role=
next=
…are attacker-controlled input. Treat them like untrusted API payloads, not navigation hints.
3) Auth bypass via navigation assumptions
The classic mistake:
“This screen is protected because users only reach it from inside the app.”
Deep links destroy that assumption.
4) Link hijacking with custom schemes
Custom schemes like myapp:// do not prove ownership. Any app can register the same scheme.
The secure option is verified HTTPS App Links using Digital Asset Links.
5) WebView routing
If a deep link accepts a URL and loads it in a WebView, you’ve created a phishing and injection surface unless strict allowlists are enforced.
Secure Architecture Model (What Actually Works)
A safe deep link system has these non-negotiables:
- Prefer verified HTTPS App Links
- One exported entry point (DeepLinkActivity) that only parses + routes
- Deep links never execute actions
- All parameters validated, normalized, and allowlisted
- Backend remains the authority
- Sensitive links are signed, short-lived, anti-replay
- Logging + telemetry for probing and abuse detection
Android Implementation (Frontend)
1) Use Verified App Links (HTTPS + autoVerify)
Keep intent filters tight — narrow host and path.
<activity
android:name=".deeplink.DeepLinkActivity"
android:exported="true"
android:launchMode="singleTop">
<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="/l/" />
</intent-filter>
<!-- Legacy scheme (never treated as trusted) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="open" />
</intent-filter>
</activity>
Why this is correct
- android:exported is mandatory for Activities with intent filters
- autoVerify=”true” triggers domain verification via Digital Asset Links
- Path scoping (/l/) prevents accidental exposure of unrelated URLs
2) DeepLinkActivity = Gatekeeper Only
This Activity should:
- Parse the URI
- Validate + normalize
- Convert to an internal route model
- Launch your main flow
- Finish immediately
class DeepLinkActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent?.data
val result = uri?.let(DeepLinkParser::parse) ?: DeepLinkResult.Reject("no_uri")
val route = when (result) {
is DeepLinkResult.Accept -> result.route
is DeepLinkResult.Reject -> "home"
}
startActivity(MainActivity.intentForRoute(this, route))
finish()
}
}
3) Strict Deep Link Parsing
Key rule: Normalize before trusting.
object DeepLinkParser {
private val allowedHttpsHosts = setOf("app.example.com")
fun parse(uri: Uri): DeepLinkResult {
val scheme = uri.scheme ?: return reject("no_scheme")
if (scheme != "https" && scheme != "myapp") return reject("bad_scheme")
if (scheme == "https") {
val host = uri.host?.lowercase() ?: return reject("no_host")
if (host !in allowedHttpsHosts) return reject("bad_host")
}
val path = uri.path ?: return reject("no_path")
if (!path.startsWith("/l/")) return reject("bad_path")
val target = uri.lastPathSegment ?: return reject("no_target")
return when (target) {
"order" -> acceptOrder(uri)
"invite" -> acceptInvite(uri)
"handoff" -> acceptHandoff(uri)
else -> reject("unknown_target")
}
}
private fun acceptOrder(uri: Uri): DeepLinkResult {
val orderId = uri.getQueryParameter("orderId")?.trim()
if (orderId.isNullOrEmpty() || orderId.length !in 1..32) return reject("bad_orderId")
return DeepLinkResult.Accept("order/$orderId")
}
private fun acceptInvite(uri: Uri): DeepLinkResult {
val payload = uri.getQueryParameter("payload") ?: return reject("missing_payload")
val sig = uri.getQueryParameter("sig") ?: return reject("missing_sig")
if (payload.length > 2048 || sig.length !in 32..128) return reject("invalid_size")
// Never treat on-device verification as authority
return DeepLinkResult.Accept("invite?payload=$payload&sig=$sig")
}
private fun acceptHandoff(uri: Uri): DeepLinkResult {
val token = uri.getQueryParameter("token")?.trim()
if (token.isNullOrEmpty() || token.length !in 16..128) return reject("bad_token")
return DeepLinkResult.Accept("handoff/$token")
}
private fun reject(reason: String) = DeepLinkResult.Reject(reason)
}
Non-Negotiable Design Rules
- Deep link never performs actions
- Params are treated as requests, not commands
- Server decides whether action is allowed
4) Destination Screens Must Re-Authorize
suspend fun loadOrder(orderId: String): OrderUiState {
val order = api.getOrder(orderId) // Backend enforces access
return OrderUiState(order = order)
}
Deep link ≠ permission.
5) WebView Rule: No Arbitrary URLs
Never allow:
https://app.example.com/l/web?url=https://evil.com
If WebView is required:
- Domain allowlist
- Force HTTPS
- Disable file/content schemes
- Remove risky JS bridges
Best practice: Map links to internal routes, not URLs.
Backend Implementation (Authority Layer)
Signed, Short-Lived Links (HMAC)
Use for:
- Invites
- Magic login links
- Sensitive flows
Flow
- Server generates payload (exp, nonce, aud, rid)
- Signs payload (HMAC SHA-256)
- App sends payload + sig to server
- Server validates and issues one-time handoff token
Example (Node / Express)
function safeEq(a, b) {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
✔ Protects against timing attacks
✔ Requires matching lengths
Add:
- Nonce storage (Redis) to prevent replay
- Short expiry (5–10 minutes max)
- Token redemption only after login
Pattern: Intent Objects for Actions
Instead of:
/l/pay?amount=5000
Use:
/l/intent?intentId=abc123
Flow:
- Deep link opens review UI
- Backend validates intentId
- User confirms
- Backend executes
Prevents one-click destructive actions.
Domain Verification (assetlinks.json)
Required for verified App Links.
https://app.example.com/.well-known/assetlinks.json
Must match:
- App package name
- SHA-256 signing certificate fingerprint
Without this, App Links silently fall back to browser.
Operational Hardening
Log Everything That Matters
- Link accepted / rejected + reason
- Signature failures
- Expired / replay attempts
- Redemption failures
- Traffic spikes by version / device
Rate Limit Critical Endpoints
- /deeplink/verify
- Handoff redemption
- Any endpoint reachable via deep link
UX Safety Rules
- Always require user confirmation for sensitive flows
- Avoid putting secrets directly in URLs
Security QA Checklist
Test these deliberately:
- Can another app trigger your DeepLinkActivity?
- Can deep links open privileged content before login?
- Can IDs be swapped (IDOR)?
- Can deep links auto-execute actions?
- Are custom schemes used for sensitive flows?
- Does any link load a URL into WebView?
- Are signature, expiry, and replay protections enforced server-side?
What I’d Ship in a Fintech App
HTTPS App Links only
Trade-off: Must maintain domain verification
Single DeepLinkActivity gatekeeper
Trade-off: Central routing layer required
Server-issued handoff tokens
Trade-off: More backend code, far stronger security
No URL-to-WebView deep links
Trade-off: Less flexibility, prevents phishing class attacks
Deep links never execute actions
Trade-off: One extra tap, eliminates catastrophic bugs
Final Takeaway
Deep links are not navigation.
They are public, attacker-controlled entry points into your app.
Treat them like exposed APIs:
- Parse strictly
- Validate aggressively
- Authorize server-side
- Never execute actions automatically
That’s how you prevent your deep links from becoming a backdoor.
Why Your Deep Links Might Be a Backdoor was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.




This Post Has 0 Comments