---
title: "Android App Links: The Link Was Verified. The Action Was Not."
description: "A field methodology for Android App Links that separates domain ownership, route validation, application state, and server-side authorization before calling a deep link secure."
date: 2026-02-26
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/posts/android-app-links-destination-authorization/
---

## Android App Links in 60 seconds

Android App Links solve a real security problem: they let the operating system verify that an
HTTPS domain and an installed application belong together. That prevents a second application
from silently claiming the same web link under normal verified-link handling.

It is also where many assessments stop too early.

A `verified` result proves which application receives the URL. It does not prove that the URL is
safe, that the requested screen is appropriate for the current user, or that a state-changing
operation has been authorized by the server. The link can arrive at the correct application and
still reach the wrong action.

This field note develops a repeatable way to test that difference. It uses reserved example names
and a controlled lab application; it does not describe a finding in a real product. The objective
is to produce evidence strong enough to distinguish configuration weakness, suspicious behavior,
and demonstrated security impact.

## One URL crosses four independent decisions

Treating “deep-link security” as one check hides the actual failure modes. A useful assessment
separates four decisions:

1. **Association:** Is this domain allowed to open this signed application?
2. **Parsing:** Does the application accept only the intended scheme, host, path, and parameters?
3. **Application state:** Is the user signed in, in the expected account, and eligible for this route?
4. **Authorization:** Will the server permit the resulting read or write for this exact user and object?

<figure class="diagram">
<svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="An HTTPS link passes through four separate decisions: domain association, route parsing, application state, and server authorization. Only the final server decision permits the protected action.">
<defs>
<marker id="mobile-gates-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent" /></marker>
</defs>
<text x="0" y="20" class="dg-accent">A LINK IS A REQUEST, NOT PROOF</text>
<rect x="0" y="48" width="128" height="82" rx="9" class="dg-box" />
<text x="16" y="76" class="dg-label">HTTPS link</text>
<text x="16" y="99" class="dg-muted">untrusted input</text>
<line x1="130" y1="89" x2="164" y2="89" class="dg-line-accent" marker-end="url(#mobile-gates-arrow)" />
<rect x="168" y="48" width="128" height="82" rx="9" class="dg-box-accent" />
<text x="184" y="76" class="dg-label">Association</text>
<text x="184" y="99" class="dg-muted">domain + signer</text>
<line x1="298" y1="89" x2="332" y2="89" class="dg-line-accent" marker-end="url(#mobile-gates-arrow)" />
<rect x="336" y="48" width="128" height="82" rx="9" class="dg-box" />
<text x="352" y="76" class="dg-label">Route parser</text>
<text x="352" y="99" class="dg-muted">path + values</text>
<line x1="466" y1="89" x2="500" y2="89" class="dg-line-accent" marker-end="url(#mobile-gates-arrow)" />
<rect x="504" y="48" width="112" height="82" rx="9" class="dg-box" />
<text x="520" y="76" class="dg-label">App state</text>
<text x="520" y="99" class="dg-muted">user + account</text>
<line x1="618" y1="89" x2="652" y2="89" class="dg-line-accent" marker-end="url(#mobile-gates-arrow)" />
<rect x="656" y="48" width="104" height="82" rx="9" class="dg-box-accent" />
<text x="672" y="76" class="dg-label">Server</text>
<text x="672" y="99" class="dg-muted">allow / deny</text>

<line x1="232" y1="132" x2="232" y2="190" class="dg-line dg-dash" />
<line x1="400" y1="132" x2="400" y2="190" class="dg-line dg-dash" />
<line x1="560" y1="132" x2="560" y2="190" class="dg-line dg-dash" />
<line x1="708" y1="132" x2="708" y2="190" class="dg-line dg-dash" />
<text x="176" y="214" class="dg-mono">Who receives it?</text>
<text x="344" y="214" class="dg-mono">What does it mean?</text>
<text x="504" y="214" class="dg-mono">Is context valid?</text>
<text x="652" y="214" class="dg-mono">May it happen?</text>
<rect x="168" y="242" width="592" height="42" rx="8" class="dg-box" />
<text x="188" y="268" class="dg-muted">PASSING ONE DECISION DOES NOT SATISFY THE NEXT</text>
</svg>
<figcaption>Android can prove which signed application owns a domain. The application and its backend still have three different security decisions to make.</figcaption>
</figure>

This distinction changes both testing and reporting. A missing `assetlinks.json` association can
create interception risk, but it does not automatically prove account takeover. A verified domain
with a dangerous `action` parameter can be more serious even though the platform configuration is
perfect. Severity belongs to the demonstrated outcome, not to the presence of a deep-link handler.

## Verification answers a deliberately narrow question

An Android App Link is an HTTP or HTTPS deep link declared with `android:autoVerify="true"`.
Android retrieves `https://<host>/.well-known/assetlinks.json` and compares its package name and
certificate fingerprint with the installed application. When the association succeeds, the system
can route matching links directly to that application.

That process establishes **domain-to-application ownership**. It does not inspect the business
meaning of `/transfer/confirm`, decide whether an `accountId` belongs to the signed-in user, or
approve a payment. Those are not omissions in App Links; they are decisions at different layers.

Custom schemes such as `casefile://` have a different property. They are not tied to a web domain
through Digital Asset Links, so another installed application can declare the same scheme. A custom
scheme can be acceptable for low-risk navigation, but it should not inherit the security assumptions
of a verified HTTPS link.

The first assessment question is therefore not “does the link open?” It is:

> Which statement does the observed behavior actually prove?

`pm get-app-links` can prove the device's current association state. An application screen appearing
can prove routing. A server response and a durable before-and-after check are needed to prove an
authorized or unauthorized action. Screenshots of a sensitive-looking screen are not substitutes
for that chain.

## Build a route contract before sending payloads

Blindly mutating every character in a URL produces noise. Start by writing down the intended route
contract. For each accepted link, capture:

| Property | Questions to answer |
| --- | --- |
| Entry type | Verified HTTPS App Link, unverified web link, or custom scheme? |
| Receiver | Which exported activity or navigation handler accepts it? |
| Route grammar | Which paths exist, and are path segments decoded once or more than once? |
| Parameters | Which values select an object, account, destination, URL, or action? |
| Preconditions | Must the user be signed in, recently authenticated, or in a specific account? |
| Side effect | Does opening navigate, disclose data, stage an action, or commit an action? |
| Server decision | Which endpoint rechecks ownership, role, state, and replay? |
| Failure behavior | Does invalid input stop safely, fall back, or continue with defaults? |

This is the point where experience matters. Parameter names are less important than what they
control. `next`, `target`, `destination`, and `continue` may all represent the same capability:
choosing where execution goes next. `id`, `account`, and `profile` may all select the subject on
whose behalf the next request is made.

The route contract turns those names into testable hypotheses.

## Five failure patterns worth separating

### 1. The association can be claimed by another application

This is the classic collision problem. It is most relevant to custom schemes and unverified web
links. The decisive evidence is not merely that an intent filter exists. Show that a second lab
application can register the same route and receive a link that carries something security-relevant,
such as a one-time login callback.

The negative control is a properly verified HTTPS App Link on the same device. If Android routes the
verified link only to the intended signed application, the comparison demonstrates exactly what the
association protects.

### 2. A valid link becomes an instruction language

A route starts as navigation and gradually accumulates behavior:

```text
https://mobile.example.test/open
  ?screen=account
  &account_id=LAB-002
  &action=confirm
  &return_to=https://mobile.example.test/done
```

Every parameter expands the number of states the handler can request. The problem is not that query
parameters exist. The problem appears when free-form input selects privileged screens, foreign
objects, state-changing operations, or arbitrary destinations without a strict allowlist and fresh
authorization.

Test each parameter independently before combining them. Otherwise, a final effect may be real while
its cause remains ambiguous. A strong result identifies the smallest value change that crosses from
expected behavior to unauthorized behavior.

### 3. A public handler forwards a nested Intent

Some routers unwrap an `Intent` from an extra and immediately launch it. That can turn a public
activity into a proxy for reaching a component the attacker could not start directly. The critical
question is whether the receiving application validates the destination component, data, MIME type,
and URI permission flags before forwarding.

<figure class="diagram">
<svg viewBox="0 0 740 324" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A public link handler unwraps a nested intent and forwards it toward a private component. The safe path sanitizes and allowlists the destination before launch.">
<defs>
<marker id="mobile-redirect-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker>
<marker id="mobile-redirect-warn" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-crit" /></marker>
<marker id="mobile-redirect-safe" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent" /></marker>
</defs>
<text x="0" y="20" class="dg-crit">UNSAFE FORWARDING</text>
<rect x="0" y="44" width="156" height="76" rx="9" class="dg-box" />
<text x="16" y="72" class="dg-label">External caller</text>
<text x="16" y="96" class="dg-muted">controls an extra</text>
<line x1="158" y1="82" x2="216" y2="82" class="dg-line" marker-end="url(#mobile-redirect-arrow)" />
<rect x="220" y="44" width="190" height="76" rx="9" class="dg-box" />
<text x="236" y="72" class="dg-label">Public link activity</text>
<text x="236" y="96" class="dg-muted">unwraps nested Intent</text>
<line x1="412" y1="82" x2="470" y2="82" class="dg-line-crit" marker-end="url(#mobile-redirect-warn)" />
<rect x="474" y="44" width="206" height="76" rx="9" class="dg-box" />
<text x="490" y="72" class="dg-label">Internal component</text>
<text x="490" y="96" class="dg-crit">launched with app authority</text>

<line x1="0" y1="158" x2="740" y2="158" class="dg-line dg-dash" />
<text x="0" y="194" class="dg-accent">SAFE FORWARDING</text>
<rect x="0" y="218" width="156" height="76" rx="9" class="dg-box" />
<text x="16" y="246" class="dg-label">External caller</text>
<text x="16" y="270" class="dg-muted">still untrusted</text>
<line x1="158" y1="256" x2="216" y2="256" class="dg-line" marker-end="url(#mobile-redirect-arrow)" />
<rect x="220" y="218" width="190" height="76" rx="9" class="dg-box-accent" />
<text x="236" y="246" class="dg-label">Sanitizer + allowlist</text>
<text x="236" y="270" class="dg-accent">component, data, flags</text>
<line x1="412" y1="256" x2="470" y2="256" class="dg-line-accent" marker-end="url(#mobile-redirect-safe)" />
<rect x="474" y="218" width="206" height="76" rx="9" class="dg-box" />
<text x="490" y="246" class="dg-label">Approved destination</text>
<text x="490" y="270" class="dg-muted">otherwise reject</text>
</svg>
<figcaption>The exported activity is not necessarily the final target. Follow any nested Intent until the application stops, sanitizes, or launches it.</figcaption>
</figure>

Android 16 adds default launch hardening for Intent redirection. That is valuable defense in depth,
not a reason to remove application-level validation. The same application may run on older devices,
and Android 16 exposes an explicit opt-out method. Record the OS version and target SDK with every
result instead of treating platform behavior as universal.

### 4. The route is valid in the wrong user or account state

Mobile applications keep more context than a URL shows: current account, cached object, selected
workspace, pending operation, and authentication age. A handler may validate the route yet combine
it with stale or attacker-influenced state.

Useful comparisons include:

- signed out versus signed in;
- account A versus account B;
- newly authenticated versus an old session;
- object selected in the URL versus object already cached in the application;
- cold start versus a warm application with an existing navigation stack.

If a link names account B while the application is displaying account A, the safe behavior is not
to guess. It should resolve the subject explicitly, verify the relationship server-side, and fail
closed when the context is inconsistent.

### 5. A deep link becomes a WebView pivot

A route such as `/help?url=...` may send its parameter to a WebView. At that point the test is no
longer only about navigation. It includes URI parsing, authenticated web content, JavaScript settings,
native bridges, and navigation callbacks.

Do not approve a URL because its raw string starts or ends with a familiar brand name. Parse it, then
compare the complete normalized scheme and host against an allowlist. Scheme and host are separate
decisions; accepting a familiar host with `http`, `content`, or another unintended scheme changes the
security result.

## A controlled assessment workflow

The workflow below is intentionally evidence-first. Use it only for an application you own or are
authorized to assess, on the agreed test accounts and devices.

### Phase 0 — define allowed effects

Before testing, record:

- application package and build hash;
- Android version, target SDK, installation source, and signing channel;
- in-scope domains, accounts, and test objects;
- whether state-changing requests are permitted;
- the cleanup required for created drafts, sessions, or objects.

This prevents a navigation test from silently becoming a transaction test. Where writes are not
authorized, stop at a preview or use a lab endpoint that records intent without completing the action.

### Phase 1 — inventory every receiver

Inspect the final merged manifest, not only source fragments. Enumerate activities with `VIEW`,
`BROWSABLE`, custom schemes, HTTP/HTTPS hosts, `autoVerify`, wildcard paths, and explicit
`android:exported` values. Then trace each receiver into the router and list every value read from
`Intent.data`, extras, fragments, and saved application state.

A typical entry point may look narrow while the router behind it is broad:

```xml
<activity
    android:name=".links.LinkRouterActivity"
    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="mobile.example.test" />
    </intent-filter>
</activity>
```

The broad host declaration may be intentional, especially with Android 15 Dynamic App Links. It
also means the application router must be designed for paths it does not recognize.

### Phase 2 — prove the association state

On a lab device, query what Android currently believes:

```bash
adb shell pm get-app-links com.example.casefile
adb shell pm get-app-links --user cur com.example.casefile
```

Preserve the domain state with the device and build metadata. `verified` and user-selected approval
are different observations. Forced approval is not equivalent to successful Digital Asset Links
verification.

When authorized to reset and retest the lab application's state:

```bash
adb shell pm set-app-links --package com.example.casefile 0 all
adb shell pm verify-app-links --re-verify com.example.casefile
adb shell pm get-app-links com.example.casefile
```

Verification is asynchronous. A transient `none` state is not a finding; wait for the verifier and
record the eventual state. Also capture the served `assetlinks.json` without following redirects
silently, because Android expects it at the well-known HTTPS location and server behavior is part of
the evidence.

### Phase 3 — test the route grammar one variable at a time

Launch a baseline link first:

```bash
adb shell am start -W \
  -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d 'https://mobile.example.test/account/summary'
```

Then build a matrix. Change only one dimension between the baseline and each comparison:

| Dimension | Baseline | Negative control | Question |
| --- | --- | --- | --- |
| Scheme | `https` | `http`, custom, or unsupported | Is the transport/entry type explicit? |
| Host | exact approved host | sibling and suffix-like host | Is the parsed host compared exactly? |
| Path | documented route | unknown, encoded, duplicate separator | Does the router reject ambiguity? |
| Parameter | one expected value | missing, repeated, malformed, foreign | Is there a typed allowlist and ownership check? |
| Session | correct test account | signed out or other test account | Is application state re-evaluated? |
| Action | read-only navigation | staged write in an approved lab | Does the server authorize and prevent replay? |

Do not combine host confusion, encoded paths, account switching, and action parameters into a single
request. A dramatic result with four mutations is difficult to reproduce and harder to remediate.

### Phase 4 — follow the effect beyond the screen

Observe the complete chain:

```text
link delivery
  -> receiving activity
  -> parsed route
  -> selected account or object
  -> network request
  -> server decision
  -> durable state change
```

The key evidence is often outside the UI. A screen may display cached data without a new disclosure.
A confirmation page may still require a protected server call. Conversely, a generic error screen
may appear after the backend already created an object. Correlate application logs available in the
lab, proxy evidence where permitted, server audit records, and a before-and-after state query.

### Phase 5 — repeat the negative controls

A credible finding includes the test that should fail:

- the same link while signed out;
- the same object under a second authorized test account;
- the same route with an unsupported host or scheme;
- the same state-changing request replayed;
- the same nested Intent aimed at a non-allowlisted component;
- the same route after remediation.

Negative controls tell us whether the effect came from the suspected missing decision or from an
unrelated session, cache, device setting, or test artifact.

## Version differences are part of the result

Android link handling is not a timeless property of the APK.

- **Android 12 and later** tightened generic web-intent resolution so approved domain handling matters
  more than it did on older devices.
- **Android 15 and later** can merge Dynamic App Link rules from `assetlinks.json`, including path,
  fragment, query, and exclusion rules. These server-side rules cannot expand beyond the host scope
  declared in the manifest.
- **Android 14 and earlier** do not apply those dynamic path rules. A manifest that broadly declares
  only scheme and host can therefore capture more paths on older devices than the Android 15 policy
  suggests.
- **Android 16** introduces default hardening against common nested-Intent launch patterns, but older
  devices and explicit application opt-outs remain relevant.

An assessment should therefore state the tested OS/build combinations. “Secure on my emulator” is not
a compatibility claim.

## When does behavior become a finding?

Use an evidence ladder:

<figure class="diagram">
<svg viewBox="0 0 720 296" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Five evidence levels progress from entry point inventory through reachable route and missing decision to unauthorized effect and repeatable impact with a negative control.">
<text x="0" y="20" class="dg-accent">EVIDENCE LADDER</text>
<rect x="0" y="224" width="128" height="48" rx="8" class="dg-box" />
<text x="14" y="246" class="dg-label">01 Inventory</text><text x="14" y="262" class="dg-muted dg-compact">entry exists</text>
<rect x="144" y="180" width="128" height="92" rx="8" class="dg-box" />
<text x="158" y="204" class="dg-label">02 Reachable</text><text x="158" y="222" class="dg-muted dg-compact">route opens</text>
<rect x="288" y="136" width="128" height="136" rx="8" class="dg-box" />
<text x="302" y="160" class="dg-label">03 Decision</text><text x="302" y="178" class="dg-muted dg-compact">check is missing</text>
<rect x="432" y="88" width="128" height="184" rx="8" class="dg-box-accent" />
<text x="446" y="112" class="dg-label">04 Effect</text><text x="446" y="130" class="dg-accent dg-compact">unauthorized result</text>
<rect x="576" y="40" width="144" height="232" rx="8" class="dg-box-accent" />
<text x="590" y="64" class="dg-label">05 Finding</text><text x="590" y="82" class="dg-accent dg-compact">repeat + control</text>
<line x1="0" y1="272" x2="720" y2="272" class="dg-axis" />
<text x="0" y="292" class="dg-muted">CONFIGURATION SIGNAL</text>
<text x="540" y="292" class="dg-accent">DEFENSIBLE IMPACT</text>
</svg>
<figcaption>An exported handler or failed association is an assessment signal. A finding requires a security consequence, a repeatable chain, and a comparison that should not succeed.</figcaption>
</figure>

This prevents three common overstatements:

1. **“The activity is exported, therefore it is vulnerable.”** A browsable link receiver normally
   needs to be exported. The question is what an external caller can make it do.
2. **“Pinning was bypassed, therefore the app has a vulnerability.”** Instrumentation may enable
   observation in an authorized lab; it does not establish impact by itself.
3. **“The deep link opened a sensitive screen, therefore authorization is bypassed.”** Confirm what
   data was disclosed or what server-side state changed under the wrong identity.

Severity should follow the strongest reproducible outcome: intercepted secret, cross-account data,
unauthorized action, unsafe code execution context, or another concrete effect. Configuration quality
alone should be reported as such.

## Design the fix as a sequence of refusals

A robust handler narrows authority at every step:

```kotlin
fun handle(incoming: Uri, session: Session): Result {
    if (incoming.scheme != "https") return reject("scheme")
    if (incoming.host != "mobile.example.test") return reject("host")

    val route = routeTable.match(incoming.pathSegments)
        ?: return reject("route")
    val input = route.parseTypedParameters(incoming)
        ?: return reject("parameters")

    if (!session.isAuthenticated) return requireLoginPreservingSafeRoute(route)
    if (!policy.mayOpen(session, route, input)) return reject("application policy")

    return api.executeAuthorized(route.operation, input)
}
```

The final API call is not trusted merely because the application produced it. The backend checks the
current user, object, operation state, and replay independently. The important properties are
architectural, not Kotlin-specific:

- parse the URI once with a platform parser;
- compare normalized scheme and host exactly;
- map paths to a finite route table rather than constructing class names or destinations;
- parse parameters into typed, bounded values;
- never treat a client-supplied account or user identifier as proof of ownership;
- sanitize nested Intents and allowlist the exact destination, data, type, and flags;
- require confirmation or recent authentication for high-risk transitions;
- enforce object ownership, role, operation state, and replay protection on the server;
- reject unknown routes and contradictory application state without guessing.

Android 15 Dynamic App Link exclusions can reduce which links reach the application on supported
devices. They are useful exposure controls, not replacements for validation inside the app or
authorization on the server.

## Evidence matrix

| Signal | What it proves | What it does not prove | Independent check |
| --- | --- | --- | --- |
| Domain state is `verified` | Android associated the tested domain with the installed signed app | The path, parameters, or action are safe | Capture package, signer, device, and `pm get-app-links` output |
| A second lab app receives a custom-scheme callback | The scheme can collide on the tested device | That a useful secret or action is exposed | Compare a harmless route with an approved test callback |
| An unknown path opens an internal screen | The router accepts more grammar than documented | Unauthorized disclosure or action | Test session, object ownership, network call, and durable state |
| A nested Intent reaches another component | The public receiver forwards caller-influenced intent data | Impact on versions protected by Android 16 or by app checks | Record OS/target SDK and repeat with non-allowlisted destination |
| A foreign test object is returned or changed | The complete chain crossed an authorization decision | Production-wide exposure | Repeat under two controlled accounts and preserve server evidence |
| Remediated build rejects the same input | The fix blocks the tested path | Absence of all related variants | Repeat the route matrix and expected positive behavior |

## The same method transfers to iOS

iOS Universal Links use an Apple App Site Association file and application entitlements rather than
Android's Digital Asset Links. The platform details differ, but the assessment questions remain:

- did the platform establish the website-to-app association;
- does the application parse only the routes it intended;
- is the current user and account context valid;
- does the backend independently authorize the final operation?

This is why the method is more durable than a list of Android tools. Platforms change their link
resolvers. The distinction between ownership, interpretation, context, and authorization remains.

## Research outcome

The strongest mobile finding rarely begins with a spectacular payload. It begins with two components
making different assumptions about the same link.

Android may correctly say, “this domain belongs to this application.” The router may then interpret
the path as permission to choose an account, forward an Intent, load a URL, or stage an action. The
server must still answer the final question: “may this user perform this operation on this object,
in this state, now?”

Testing each answer separately produces more than a checklist. It produces a defensible causal chain,
a reliable negative control, and a remediation that removes the actual authority the link never
should have carried.

## Primary references

- [Android Developers: About App Links](https://developer.android.com/training/app-links/about)
- [Android Developers: Verify App Links](https://developer.android.com/training/app-links/verify-applinks)
- [Android Developers: Unsafe use of deep links](https://developer.android.com/privacy-and-security/risks/unsafe-use-of-deeplinks)
- [Android Developers: Intent redirection](https://developer.android.com/privacy-and-security/risks/intent-redirection)
- [Android Developers: Unsafe URI loading in WebViews](https://developer.android.com/privacy-and-security/risks/unsafe-uri-loading)
- [OWASP MASTG: Use of Unverified App Links](https://mas.owasp.org/MASTG/tests/android/MASVS-PLATFORM/MASTG-TEST-0393/)
- [OWASP MASTG: Missing Input Validation in Custom URL Scheme Handlers](https://mas.owasp.org/MASTG/tests/android/MASVS-PLATFORM/MASTG-TEST-0394/)