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?
A LINK IS A REQUEST, NOT PROOF HTTPS link untrusted input Association domain + signer Route parser path + values App state user + account Server allow / deny Who receives it? What does it mean? Is context valid? May it happen? PASSING ONE DECISION DOES NOT SATISFY THE NEXT
Android can prove which signed application owns a domain. The application and its backend still have three different security decisions to make.

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:

PropertyQuestions to answer
Entry typeVerified HTTPS App Link, unverified web link, or custom scheme?
ReceiverWhich exported activity or navigation handler accepts it?
Route grammarWhich paths exist, and are path segments decoded once or more than once?
ParametersWhich values select an object, account, destination, URL, or action?
PreconditionsMust the user be signed in, recently authenticated, or in a specific account?
Side effectDoes opening navigate, disclose data, stage an action, or commit an action?
Server decisionWhich endpoint rechecks ownership, role, state, and replay?
Failure behaviorDoes 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.

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.

UNSAFE FORWARDING External caller controls an extra Public link activity unwraps nested Intent Internal component launched with app authority SAFE FORWARDING External caller still untrusted Sanitizer + allowlist component, data, flags Approved destination otherwise reject
The exported activity is not necessarily the final target. Follow any nested Intent until the application stops, sanitizes, or launches it.

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.

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:

DimensionBaselineNegative controlQuestion
Schemehttpshttp, custom, or unsupportedIs the transport/entry type explicit?
Hostexact approved hostsibling and suffix-like hostIs the parsed host compared exactly?
Pathdocumented routeunknown, encoded, duplicate separatorDoes the router reject ambiguity?
Parameterone expected valuemissing, repeated, malformed, foreignIs there a typed allowlist and ownership check?
Sessioncorrect test accountsigned out or other test accountIs application state re-evaluated?
Actionread-only navigationstaged write in an approved labDoes 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:

EVIDENCE LADDER 01 Inventoryentry exists 02 Reachableroute opens 03 Decisioncheck is missing 04 Effectunauthorized result 05 Findingrepeat + control CONFIGURATION SIGNAL DEFENSIBLE IMPACT
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.

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:

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

SignalWhat it provesWhat it does not proveIndependent check
Domain state is verifiedAndroid associated the tested domain with the installed signed appThe path, parameters, or action are safeCapture package, signer, device, and pm get-app-links output
A second lab app receives a custom-scheme callbackThe scheme can collide on the tested deviceThat a useful secret or action is exposedCompare a harmless route with an approved test callback
An unknown path opens an internal screenThe router accepts more grammar than documentedUnauthorized disclosure or actionTest session, object ownership, network call, and durable state
A nested Intent reaches another componentThe public receiver forwards caller-influenced intent dataImpact on versions protected by Android 16 or by app checksRecord OS/target SDK and repeat with non-allowlisted destination
A foreign test object is returned or changedThe complete chain crossed an authorization decisionProduction-wide exposureRepeat under two controlled accounts and preserve server evidence
Remediated build rejects the same inputThe fix blocks the tested pathAbsence of all related variantsRepeat 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

Sources & freshness

How current is this note?

Sources checkedFebruary 26, 2026

The latest source-review, content-update, or publication date is shown.

ReviewAuthor review complete

The author completed a technical review. This does not, by itself, claim lab reproduction.