skip to Main Content

Why Your Deep Links Might Be a Backdoor

March 9, 20267 minute read

  

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:

  1. Parse the URI
  2. Validate + normalize
  3. Convert to an internal route model
  4. Launch your main flow
  5. 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

  1. Server generates payload (exp, nonce, aud, rid)
  2. Signs payload (HMAC SHA-256)
  3. App sends payload + sig to server
  4. 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:

  1. Deep link opens review UI
  2. Backend validates intentId
  3. User confirms
  4. 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.

 

Web Developer, Web Design, Web Builder, Project Manager, Business Analyst, .Net Developer

No Comments

This Post Has 0 Comments

Leave a Reply

Back To Top