Deep Linking in Mobile Applications Explained
A production guide to mobile deep linking: comparing URI schemes, Universal Links, and App Links, domain verification, lifecycle dispatch, and defensive security.

Deep Linking in Mobile Applications Explained
In modern mobile application architecture, deep linking is often treated as an isolated navigation convenience—a simple mapping between a URL path and an application screen. In production systems, however, deep linking operates as a distributed contract across DNS records, web server configurations, mobile operating system kernels, application lifecycles, and security boundaries.
A link clicked in an email client, a social media feed, or a web browser does not merely open an app. It initiates an operating system resolution protocol, passes untrusted data across process boundaries, interrupts or launches native application lifecycles, and interacts with asynchronous authentication and state hydration pipelines.
When deep linking is designed superficially, applications suffer from scheme hijacking, cold-start race conditions, broken web fallbacks, and security vulnerabilities like open redirects. Building a reliable deep linking pipeline requires understanding the underlying platform standards, configuring two-way cryptographic verification, structuring decoupled lifecycle dispatchers, and applying defensive security to every incoming payload.
1. Deep Linking Topologies: Custom URI Schemes vs. Verified Links
Mobile operating systems have evolved two primary mechanisms to route external links into native applications: Custom URI Schemes and Cryptographically Verified HTTP/HTTPS Links (known as Universal Links on iOS and Android App Links on Android).
| Dimension | Custom URI Schemes (scheme://path) | Universal Links (iOS) & App Links (Android) |
| :--- | :--- | :--- |
| Protocol | Proprietary scheme (e.g., myapp://orders/42) | Standard HTTPS protocol (e.g., https://example.com/orders/42) |
| Domain Ownership Verification | None. Any application can register any scheme string. | Two-way cryptographic association between domain and app binary. |
| Fallback on App Absence | Fails with an unhandled browser error dialog. | Gracefully opens the target URL on the responsive website. |
| Disambiguation Prompts | Often triggers OS chooser dialogs on Android. | Opens the native application immediately with zero prompts. |
| Hijacking / Spoofing Risk | High. Vulnerable to scheme collision and intent interception. | Immune to external registration spoofing. |
| Supported Contexts | Native apps, web browsers (with JavaScript fallbacks). | OS-level taps across Mail, Notes, Messages, and external apps. |
The Fatal Flaws of Custom URI Schemes
Custom URI schemes rely on local operating system registration. In an iOS Info.plist file, an application declares CFBundleURLTypes; on Android, an application adds an <intent-filter> with a custom <data android:scheme="myapp" /> inside its AndroidManifest.xml.
While simple to configure, custom schemes present two fundamental failure modes in production:
- Lack of Ownership Verification (Scheme Hijacking): Neither iOS nor Android reserves custom schemes on a global registry. If a malicious application installed on a user device registers the exact same scheme (
myapp://), both applications claim the capability to handle incoming links.- On Android, the system presents an ambiguous disambiguation dialog asking the user to choose an app, or routes the intent to the malicious app if it manipulates intent priorities.
- On iOS, SpringBoard does not provide a deterministic arbitration mechanism when multiple apps claim the same scheme. If an application uses custom schemes for OAuth authentication redirects (e.g.,
myapp://oauth-callback?code=xyz), a rogue app can intercept authorization codes.
- Dead-End Web Fallback: When a user clicks a custom scheme link in a mobile browser without the native application installed, the browser attempts to resolve the protocol natively. Mobile Safari displays a modal error stating "Safari cannot open the page because the address is invalid", while Chromium browsers report
ERR_UNKNOWN_URL_SCHEME. Attempting to mitigate this with JavaScript iframe hacks or timeout redirects introduces brittle, platform-dependent browser warnings.
CUSTOM URI SCHEME (UNVERIFIED):
Link Clicked: myapp://orders/123
│
├── App Installed? ──> YES ──> Opens App (Risk: Multiple apps claim scheme)
│
└── App Missing? ──> NO ──> Browser Error: "Invalid Address"
(No automatic web fallback)
VERIFIED UNIVERSAL / APP LINK (CRYPTOGRAPHIC):
Link Clicked: https://example.com/orders/123
│
├── App Installed & Verified? ──> YES ──> Opens Native App Directly
│
└── App Missing / Unverified? ──> NO ──> Opens Web Page at URL
(Seamless HTTP fallback)
The Architecture of Verified HTTP/HTTPS Links
Verified links resolve these vulnerabilities by anchoring app routing to standard HTTPS domain names. Because domains are governed by DNS infrastructure and secured by TLS certificates, ownership of a web domain is cryptographically verifiable.
When a user taps https://example.com/orders/42:
- If the application is installed and the domain association is verified, the operating system kernel intercepts the link before the browser opens and routes the intent directly into the application process.
- If the application is not installed, uninstalled, or fails verification, the operating system treats the link as a standard web navigation, opening the destination on your web application without interrupting the user.
2. Two-Way Domain Association Architecture
Universal Links and Android App Links establish trust through a two-way cryptographic handshake:
- The Application declares domain ownership: The compiled mobile binary embeds an entitlement or manifest entry explicitly listing the hostnames it wishes to handle.
- The Domain declares application ownership: The web server hosts a signed or publicly accessible JSON configuration file under the
/.well-known/directory explicitly listing the application identifiers permitted to handle its routes. - The Operating System performs the verification: When the application is installed or updated, the mobile OS downloads the association file directly from the domain and verifies that the app identifier in the binary matches an allowed identifier in the hosted file.
Two-Way Domain Association Handshake:
[Mobile Application Binary] [Web Domain Host]
│ │
├── 1. Entitlement / Manifest declares: │
│ "I handle https://example.com" │
│ │
│ [Mobile Operating System] │
│ │ │
│ ├── 2. On App Install: │
│ │ GET /.well-known/ │
│ │ association file ─────>│
│ │ │
│ │<───── Returns JSON file ────┤
│ │ (AASA or assetlinks) │
│ │ │
│ ├── 3. Compares Application │
│ │ Identifier & Certs │
│ │ │
│ └── 4. MATCH CONFIRMED: │
│ Routes enabled! │
Apple Universal Links: AASA Configuration
Apple devices query the Apple App Site Association (AASA) file located at:
https://<domain>/.well-known/apple-app-site-association
Technical Hosting Requirements
- No File Extension: The filename must be strictly
apple-app-site-associationwith no.jsonextension. Servingapple-app-site-association.jsonwill cause verification to fail completely. - MIME Type: The server must return the HTTP header
Content-Type: application/json. - TLS & Status: The server must return an HTTP
200 OKstatus over a valid, trusted TLS connection (self-signed certificates are rejected). - No HTTP Redirects: The endpoint must serve the file directly. HTTP
301,302, or307redirects cause iOS validation to abort immediately. - Size Constraint: The uncompressed file size must remain under 128 KB.
Production AASA Schema (iOS 13+ Components Format)
Modern iOS versions use the structured components array, which supports pattern matching on paths, query parameters, fragments, and exclusions:
{
"applinks": {
"details": [
{
"appIDs": [
"9JA6NRYKNX.com.example.store",
"9JA6NRYKNX.com.example.store.beta"
],
"components": [
{
"/": "/orders/*",
"?": { "orderId": "?*" },
"comment": "Matches order detail paths having an orderId query parameter"
},
{
"/": "/products/*",
"comment": "Matches all product catalog detail routes"
},
{
"/": "/auth/*",
"exclude": true,
"comment": "Excludes web authentication and OAuth flows from triggering the native app"
},
{
"/": "/help/*",
"exclude": true,
"comment": "Routes support content to the mobile browser"
}
]
}
]
}
}
Note: In the appIDs entry, 9JA6NRYKNX represents the 10-character Apple Developer Team ID, followed by a period and the application's unique Bundle Identifier.
iOS 14+ Apple CDN Caching Behavior
Starting with iOS 14, Apple devices do not query your production server directly on every app installation. Instead, Apple routes verification requests through an Apple-managed caching CDN:
https://app-site-association.cdn-apple.com/a/v1/<domain>
When you update your AASA file on production servers, Apple's CDN cache may take between 24 and 48 hours to refresh. For internal testing and staging environments, you can bypass the Apple CDN cache by adding the ?mode=developer flag to your app's Associated Domains entitlement in Xcode:
applinks:staging.example.com?mode=developer
This bypass instructs local development builds to fetch the AASA file directly from the staging domain and ignore certificate transparency requirements, enabling immediate iteration.
Android App Links: Digital Asset Links Configuration
Android App Links rely on Google's Digital Asset Links protocol. Android devices query the JSON file hosted at:
https://<domain>/.well-known/assetlinks.json
Production assetlinks.json Schema
The file contains a JSON array defining permissions and identifying target Android applications by package name and signing certificate fingerprints:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.store",
"sha256_cert_fingerprints": [
"14:6D:E9:73:96:D4:56:87:65:6B:48:02:A0:72:DE:0F:7B:3A:C4:DE:28:B6:77:86:14:0E:5B:3C:99:99:99:99",
"A1:B2:C3:D4:E5:F6:07:18:29:3A:4B:5C:6D:7E:8F:90:12:34:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE:F0"
]
}
}
]
AndroidManifest.xml Configuration
To register your domain with the Android package manager and request automatic domain verification, configure an <intent-filter> containing android:autoVerify="true" inside your target Activity:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<!-- Deep Link Intent Filter -->
<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" />
<!-- Domain Configuration -->
<data android:scheme="https" />
<data android:host="example.com" />
<!-- Path Prefix Routing -->
<data android:pathPrefix="/products" />
<data android:pathPrefix="/orders" />
</intent-filter>
</activity>
The Google Play App Signing Keystore Trap
A frequent source of verification failure in production Android deployments stems from certificate fingerprint mismatches:
- During local development, the application is signed with a debug or local release keystore. Running
keytool -list -v -keystore release.keystoreproduces a specific SHA-256 fingerprint. - If your application uses Google Play App Signing (which is mandatory for modern applications distributed as Android App Bundles / AABs), Google strips the local upload key during store ingestion and re-signs the delivered APK with an App Signing Key managed in Google Cloud Key Management.
- If your production
assetlinks.jsononly contains the fingerprint of your local upload keystore, verification will pass on local test builds but silently fail for all users downloading the app from the Play Store.
Always copy the production SHA-256 fingerprint directly from the Google Play Console under Release > Setup > App Integrity > App Signing Certificate.
3. Application Lifecycle Management: Cold Starts vs. Warm Starts
Opening an application via a deep link introduces complex lifecycle challenges depending on the application's runtime state at the moment the link is activated.
| Lifecycle State | Process Status | Delivery Mechanism | Architectural Challenge |
| :--- | :--- | :--- | :--- |
| Cold Start | Terminated / Not in memory. OS creates a new process. | iOS: didFinishLaunchingWithOptions
Android: Activity.onCreate(savedInstanceState) | Asynchronous bootstrap dependencies (auth tokens, remote config, local DB) are not yet ready when the intent arrives. |
| Warm / Hot Start | Process running in foreground or suspended in background. | iOS: scene(_:continue:)
Android: Activity.onNewIntent(Intent) | Router must safely interrupt or push onto the existing navigation stack without corrupting user state. |
The Cold Start Race Condition
In a cold start, the operating system launches your application process and passes the incoming URL in the startup arguments. Naive implementations attempt to route to the destination screen immediately upon receiving the launch event:
The Naive Cold Start Failure:
Incoming URL ──> Launch App Process
│
├── App launches router immediately:
│ Attempts navigation: /orders/742
│
├── CRITICAL RACE CONDITION:
│ - Keychain token read in progress... (PENDING)
│ - User profile query running... (PENDING)
│ - SQLite migrations executing... (PENDING)
│
└── Router evaluates auth guard:
"Auth token is null! Redirecting to /login..."
(Deep link intent is permanently lost!)
If the router evaluates navigation guards before the application completes its asynchronous bootstrap sequence, one of three failures occurs:
- Premature Authentication Redirect: The router inspects user session state before local Keychain/Keystore tokens have been read, treats the user as unauthenticated, and permanently diverts to the Login screen.
- Missing Dependency Crashes: The target screen attempts to query a local database, dependency injection container, or feature flag registry that has not finished initializing.
- Double Navigation Strobe: The app renders the deep-linked screen, and milliseconds later, the completed bootstrap routine resets the navigation stack back to the default home dashboard.
The Intent Buffering Pattern
To prevent cold-start race conditions, mobile architectures should decouple link reception from navigation execution using an Intent Buffer:
Cold-Start Intent Buffering Pattern:
[OS Link Intent]
│
▼
[Deep Link Coordinator] ──> Store in Pending Intent Queue (In-Memory)
│
▼
[Splash / Bootstrap UI] ──> Concurrently executes startup pipeline:
• Read secure storage tokens
• Initialize local database
• Fetch mandatory remote flags
• Validate active session
│
▼
[Bootstrap Complete] ──> Emits `AuthState` and `EngineReady` signals
│
▼
[Deep Link Coordinator] ──> Drains Pending Intent Queue:
• Resolves target route
• Verifies authorization requirements
• Executes atomic navigation transition
During a warm start, the bootstrap pipeline is already complete. The coordinator bypasses the pending queue and dispatches the route immediately through the active navigation hierarchy.
4. Deferred Deep Linking: Bridging the App Store Gap
Standard deep linking assumes the native application is already installed on the user's device. When the application is absent, verified links gracefully fall back to the responsive website. However, growth and marketing flows frequently require Deferred Deep Linking: preserving the user's contextual destination across the App Store or Google Play installation boundary so the user lands on the intended content immediately upon their first app launch.
Deferred Deep Linking Workflow:
[User clicks promo link on Web]
│
▼
[Native App Not Installed] ──> Route to App Store / Google Play
│
▼
[User Installs and Launches App for First Time]
│
▼
[App Queries Deferred Attribution Context]
│
├── Android: Google Play Install Referrer API (Deterministic)
└── iOS: Web Fingerprinting / Account Sync (Probabilistic / Explicit)
│
▼
[App Navigates Directly to Original Promotional Offer]
Android: Google Play Install Referrer API
Android provides an official, deterministic mechanism to pass link metadata through the Play Store installation process.
- When redirecting an uninstalled user to the Google Play Store, the web landing page appends a custom
referrerquery parameter:https://play.google.com/store/apps/details?id=com.example.store&referrer=campaign%3Dsummer_sale%26target%3D%252Fpromo%252Fsummer - During the first launch after installation, the application queries Google's
InstallReferrerClientlibrary over local IPC:- The Play Store client returns the unparsed referrer string.
- The application parses the embedded
targetparameter and hands it to the Deep Link Dispatcher.
- The Play Store guarantees that the referrer string remains attached to that specific download token, providing reliable attribution and deep linking without requiring external network fingerprinting.
iOS: Constraints, ATT, and Probabilistic Fingerprinting
Apple does not provide a native install referrer API for arbitrary navigation parameters. Historical workarounds and their modern limitations include:
- Clipboard / Pasteboard Fallbacks: Early SDKs copied link parameters to the system clipboard (
UIPasteboard) in mobile Safari, then read the clipboard upon first app launch.- Current Constraint: Starting with iOS 14 and strengthened in iOS 16, iOS displays a prominent system banner (
"AppName pasted from Safari") whenever an app reads clipboard data without explicit user interaction. Relying on background clipboard inspection degrades user trust and can trigger privacy review scrutiny during App Store ingestion.
- Current Constraint: Starting with iOS 14 and strengthened in iOS 16, iOS displays a prominent system banner (
- Probabilistic Fingerprinting: Third-party attribution services capture device attributes (IP address, user-agent string, screen resolution, locale, carrier) during the web link tap, and attempt to match those attributes when an application connects to their servers for the first time within a short time window (e.g., 2 hours).
- Current Constraint: Cellular Carrier-Grade NAT (CGNAT) and Apple's iCloud Private Relay mask user IP addresses, making fingerprint matching unreliable. Furthermore, Apple's App Tracking Transparency (ATT) framework strictly classifies cross-domain identity matching for tracking purposes as requiring explicit user opt-in.
Production Recommendation for Cross-Platform Systems
Treat deferred deep linking as a progressive enhancement, not a critical infrastructure dependency:
- For low-friction, non-sensitive flows (e.g., viewing a shared public catalog item), use platform-appropriate attribution mechanisms.
- For high-value, sensitive workflows (e.g., team invitations, document collaboration, checkout continuation), use Account-Level Server Synchronization. Require the user to begin on the mobile web landing page, authenticate or create their account, and persist the pending context in your database. When the user subsequently installs the app and signs in, the backend delivers the pending action deterministically as part of user session initialization.
5. Deep Link Security and Threat Modeling
Because deep links originate from outside the mobile operating system's sandbox, they must be treated as untrusted external network input. Failing to implement strict validation boundaries exposes applications to severe vulnerabilities.
Threat 1: Open Redirect Vulnerabilities
An application that accepts dynamic redirect URLs via deep link parameters can be weaponized as an open redirector:
VULNERABLE PATTERN:
myapp://navigate?target_url=https://evil-phishing-site.com/login
Or inside Universal Links:
https://example.com/open?redirect=https://evil-phishing-site.com
If the mobile application forwards target_url directly to an in-app WebView or an external browser intent, malicious actors can exploit your verified domain reputation in phishing campaigns. Furthermore, if your in-app WebView injects JavaScript bridges that expose native device credentials, loading an unvetted external URL can result in complete account takeover.
Defensive Rule: Never allow external deep links to pass arbitrary URL targets. Restrict deep link routing to an explicit whitelist of internal route identifiers.
Threat 2: Parameter Injection and Type Confusion
Consider an e-commerce deep link pattern:
https://example.com/checkout/confirm?orderId=9821&amount=0.00
If the client application uses URL parameters directly to configure transactional payloads or populate database queries:
- Attackers can manipulate query parameters to trigger unauthorized state mutations.
- Special characters in parameters can corrupt navigation stacks or induce SQL/NoSQL injection in local SQLite caches.
Defensive Rule: Deep links must represent intent to view, never authorization to mutate. Parameters such as orderId must be strictly sanitized and cast to expected types (e.g., UUIDs or positive integers). The mobile application must always query the authoritative backend API to fetch current state, prices, and permissions before rendering actionable UI.
Threat 3: Credential and Token Leakage in URLs
A dangerous anti-pattern is passing sensitive credentials, session tokens, or API keys in deep link query parameters:
DANGEROUS ANTI-PATTERN:
https://example.com/auth/callback?access_token=eyJhbGciOi...
URLs are not confidential channels:
- URLs are recorded in plain text in browser histories, operating system system logs, crash analytics breadcrumbs, and web server access logs.
- Anyone looking over a user's shoulder or inspecting proxy logs can capture long-lived bearer tokens.
Defensive Rule: Use the OAuth 2.0 PKCE (Proof Key for Code Exchange) pattern for all mobile authentication handshakes. Deep links should only ever receive a short-lived, single-use authorization code. The native application then exchanges this code for an access token over a secure TLS POST request using its private, dynamically generated PKCE code verifier.
6. Production Implementation: Resilient Deep Link Dispatcher
Below is a production-grade TypeScript implementation of an isolated Deep Link Dispatcher. It provides:
- Dynamic Path Pattern Matching: Extracts named parameters (e.g.,
/orders/:orderId). - Intent Buffering: Queues incoming links during application cold boot and flushes them once asynchronous bootstrap is complete.
- Authentication Gating: Intercepts protected routes when unauthenticated, routing to the login flow while preserving the return target.
- Input Sanitization: Parses and validates query and path parameters safely, rejecting malicious payloads.
/**
* Production-Grade Mobile Deep Link Dispatcher
* Provides cold-start buffering, route pattern matching,
* authentication guards, and safe parameter extraction.
*/
export interface RouteParams {
[key: string]: string;
}
export interface DeepLinkContext {
rawUrl: string;
path: string;
queryParams: Record<string, string>;
pathParams: RouteParams;
}
export type RouteHandler = (context: DeepLinkContext) => Promise<boolean> | boolean;
export interface RouteDefinition {
pattern: string;
regex: RegExp;
paramNames: string[];
requiresAuth: boolean;
handler: RouteHandler;
}
export interface AuthState {
isAuthenticated: boolean;
userId?: string;
}
export class DeepLinkDispatcher {
private readonly routes: RouteDefinition[] = [];
private pendingIntent: string | null = null;
private isBootstrapped = false;
private authState: AuthState = { isAuthenticated: false };
constructor(
private readonly navigateToLogin: (returnUrl?: string) => void,
private readonly navigateToNotFound: (unmatchedUrl: string) => void,
private readonly logger: (message: string, context?: unknown) => void = () => {}
) {}
/**
* Registers a route pattern.
* Example: registerRoute('/orders/:orderId', { requiresAuth: true }, handler)
*/
public registerRoute(
pattern: string,
options: { requiresAuth: boolean },
handler: RouteHandler
): void {
const paramNames: string[] = [];
// Convert path pattern into a strictly anchored regular expression
const sanitizedPattern = pattern.replace(/\/+$/, '') || '/';
const regexSource = sanitizedPattern
.replace(/:([a-zA-Z0-9_]+)/g, (_, paramName) => {
paramNames.push(paramName);
return '([a-zA-Z0-9_-]+)'; // Strict whitelist for parameter characters
})
.replace(/\//g, '\\/');
this.routes.push({
pattern: sanitizedPattern,
regex: new RegExp(`^${regexSource}$`),
paramNames,
requiresAuth: options.requiresAuth,
handler,
});
}
/**
* Receives incoming URLs from operating system lifecycle hooks
* (e.g., cold start launch args or runtime event listeners).
*/
public handleIncomingUrl(rawUrl: string): void {
this.logger('Incoming deep link received', { rawUrl, isBootstrapped: this.isBootstrapped });
if (!this.isBootstrapped) {
this.logger('Application bootstrap in progress; buffering deep link intent', { rawUrl });
this.pendingIntent = rawUrl;
return;
}
this.dispatch(rawUrl);
}
/**
* Invoked when the application completes async startup
* (e.g., token reading, local DB initialization, remote config fetch).
*/
public completeBootstrap(authState: AuthState): void {
this.isBootstrapped = true;
this.authState = authState;
this.logger('Application bootstrap completed', { authState });
if (this.pendingIntent) {
const intentToProcess = this.pendingIntent;
this.pendingIntent = null;
this.logger('Draining buffered cold-start intent', { intentToProcess });
this.dispatch(intentToProcess);
}
}
/**
* Internal routing and dispatch execution pipeline.
*/
private dispatch(rawUrl: string): boolean {
let parsedUrl: URL;
try {
parsedUrl = new URL(rawUrl);
} catch {
this.logger('Malformed deep link URL rejected', { rawUrl });
this.navigateToNotFound(rawUrl);
return false;
}
// Normalize path by stripping trailing slashes
const path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
// Extract and sanitize query parameters
const queryParams: Record<string, string> = {};
parsedUrl.searchParams.forEach((value, key) => {
// Prevent prototype pollution and strip oversized parameters
if (key !== '__proto__' && key !== 'constructor' && value.length < 2048) {
queryParams[key] = value;
}
});
for (const route of this.routes) {
const match = path.match(route.regex);
if (!match) continue;
// Evaluate authentication guard
if (route.requiresAuth && !this.authState.isAuthenticated) {
this.logger('Protected route accessed without active session. Redirecting to login.', {
targetPath: path,
});
this.navigateToLogin(rawUrl);
return false;
}
// Extract named path parameters
const pathParams: RouteParams = {};
route.paramNames.forEach((name, index) => {
pathParams[name] = decodeURIComponent(match[index + 1]);
});
const context: DeepLinkContext = {
rawUrl,
path,
queryParams,
pathParams,
};
try {
this.logger('Executing route handler for match', { pattern: route.pattern });
const result = route.handler(context);
return result instanceof Promise ? true : result;
} catch (error) {
this.logger('Unhandled exception during route execution', { error, path });
this.navigateToNotFound(rawUrl);
return false;
}
}
this.logger('No registered route matched deep link path', { path });
this.navigateToNotFound(rawUrl);
return false;
}
}
7. Operational Verification & Troubleshooting Guide
Debugging deep linking issues across platforms requires disciplined terminal diagnostics. Because verification occurs at the operating system level, debugging UI consoles alone is insufficient.
Verifying Two-Way Association Hosting
Run these checks against your production web server to verify header and redirect compliance:
# 1. Verify Apple App Site Association Hosting
curl -v -i https://example.com/.well-known/apple-app-site-association
# Expected Verification Criteria:
# - HTTP Status must be 200 OK (Strictly NO 301/302 redirects)
# - Content-Type header must be application/json
# - Response size must be < 128 KB
# - TLS certificate chain must be valid without warnings
# 2. Verify Android Asset Links Hosting
curl -v -i https://example.com/.well-known/assetlinks.json
# Expected Verification Criteria:
# - HTTP Status must be 200 OK
# - Content-Type header must be application/json
# - JSON output contains valid target.package_name and sha256_cert_fingerprints
Testing on Android Devices via ADB
Use the Android Debug Bridge (adb) to simulate incoming intents and inspect domain verification states:
# 1. Trigger an App Link Intent directly into the target application
adb shell am start \
-a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://example.com/orders/78912" \
com.example.store
# 2. Inspect Android Domain Verification Status (Android 12+)
adb shell pm get-app-links com.example.store
# Expected Diagnostic Output:
# com.example.store:
# ID: 01234567-89ab-cdef-0123-456789abcdef
# Signatures: [14:6D:E9:...]
# Domain verification state:
# example.com: verified
# 3. Force Immediate Domain Re-Verification on Device
adb shell pm verify-app-links --re-verify com.example.store
If the domain verification state reports 1024 or legacy_failure, inspect device logcat while re-verifying (adb logcat -s IntentFilterVerifierDomain IntentFilterIntentVerifier) to locate specific certificate or network failures.
Testing on iOS Simulators via Simctl
Use the Xcode command-line tool simctl to dispatch Universal Links into active simulators:
# 1. Simulate a Universal Link click on a booted simulator
xcrun simctl openurl booted "https://example.com/orders/78912"
# 2. Stream Shared Web Credentials and Universal Link verification daemon logs
log stream --predicate 'process == "swcd"' --level debug
# Watch for entries confirming domain download status:
# "SWCManager: updating domain example.com for app 9JA6NRYKNX.com.example.store"
# "SWCDatabase: updated entry: status=approved"
Common Production Failure Modes and Remediations
| Symptom | Root Cause | Production Remediation |
| :--- | :--- | :--- |
| Android link opens in Chrome instead of app | Missing android:autoVerify="true" or SHA-256 fingerprint mismatch. | Verify <intent-filter> attributes; verify Google Play App Signing key fingerprint is listed in assetlinks.json. |
| iOS Universal Link opens in Safari instead of app | AASA file has .json extension, redirects, or Apple CDN cache is stale. | Remove extension; ensure HTTP 200 without redirects; test with ?mode=developer in entitlements. |
| User lands on Login screen on cold start | Router evaluated navigation guards before auth tokens were decrypted. | Implement the Intent Buffering Pattern to defer navigation until async bootstrap completes. |
| App links stop working after app update | Android package renamed, or signing key rotated without updating assetlinks.json. | Add both old and new certificate SHA-256 fingerprints to assetlinks.json during rotation transitions. |
| In-app link tap opens browser instead of staying in app | User previously tapped the top-right breadcrumb domain button in iOS. | On iOS, tapping the domain header in the status bar sets a user preference to default to Safari. The user must long-press the link in Safari and select "Open in [App]" to reset the association. |
8. Architectural Summary and Production Checklist
Mobile deep linking succeeds when treated as a production engineering system with explicit boundaries, resilient lifecycle management, and defensive input processing.
Production Verification Checklist
Before shipping deep link changes to production, confirm that:
- [ ] Cryptographic Verification: Both Apple Universal Links and Android App Links are configured as the primary routing mechanism. Custom URI schemes are restricted strictly to isolated legacy fallbacks.
- [ ] Hosting Compliance:
apple-app-site-associationandassetlinks.jsonare served over HTTPS directly from/.well-known/with HTTP200 OK,application/jsonMIME types, and zero HTTP redirects. - [ ] Certificate Accuracy: Production
assetlinks.jsonincludes the SHA-256 fingerprint of the Google Play App Signing Key, not just local developer keystores. - [ ] Lifecycle Intent Buffering: The mobile router stages incoming cold-start intents in memory, withholding navigation dispatch until asynchronous authentication and database initialization are complete.
- [ ] Defensive Validation: All route parameters (IDs, query values) are validated against strict type schemas. No deep link parameter can trigger state-mutating actions without authenticated server verification.
- [ ] Credential Protection: No authorization tokens, session keys, or user credentials appear in deep link query parameters. OAuth integrations strictly employ PKCE.
- [ ] Open Redirect Prevention: In-app WebViews and navigation coordinators reject arbitrary external URL targets passed via link parameters.
- [ ] Automated Diagnostics: Device verification states are tested using
adb shell pm get-app-linksandsimctl openurlin continuous integration workflows.


