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:
- Association: Is this domain allowed to open this signed application?
- Parsing: Does the application accept only the intended scheme, host, path, and parameters?
- Application state: Is the user signed in, in the expected account, and eligible for this route?
- Authorization: Will the server permit the resulting read or write for this exact user and object?
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:
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.
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:
<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:
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:
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:
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:
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:
This prevents three common overstatements:
- “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.
- “Pinning was bypassed, therefore the app has a vulnerability.” Instrumentation may enable observation in an authorized lab; it does not establish impact by itself.
- “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:
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
- Android Developers: Verify App Links
- Android Developers: Unsafe use of deep links
- Android Developers: Intent redirection
- Android Developers: Unsafe URI loading in WebViews
- OWASP MASTG: Use of Unverified App Links
- OWASP MASTG: Missing Input Validation in Custom URL Scheme Handlers
How current is this note?
The latest source-review, content-update, or publication date is shown.
The author completed a technical review. This does not, by itself, claim lab reproduction.
