Flutter App Link Verification: Android App Links and iOS Universal Links Setup

Step-by-step guide to verified app links in Flutter for Android and iOS with assetlinks.json, AASA, routing, testing, and troubleshooting.

ASOasis
7 min read
Flutter App Link Verification: Android App Links and iOS Universal Links Setup

Image used for representation purposes only.

When a user taps a link to your domain, you want your Flutter app to open directly—without “Open with…” pickers or fragile custom schemes. Verified app links do exactly that:

  • Android App Links: The system auto-routes https links to your app after verifying ownership with Digital Asset Links.
  • iOS Universal Links: Safari and the OS route eligible https links into your app after validating an Apple App Site Association (AASA) file.

This guide walks you through end-to-end verification for both platforms and shows how to handle incoming links inside Flutter.

What “verification” actually is

  • On Android, the OS downloads your domain’s assetlinks.json and checks that it authorizes your app’s package name and signing certificate. Once verified, the OS bypasses the chooser.
  • On iOS, the OS downloads the AASA file and validates that your Team ID + bundle ID are authorized for specific URL paths. Eligible links then open your app directly.

Verification is per domain and per installed build (debug/release have different signing certs). It persists on device until the app is uninstalled or the user overrides behavior.

Prerequisites

  • A public https domain with a valid TLS certificate (no self-signed, no mixed content).
  • Control over the web server or CDN to host files at well-known paths.
  • Flutter project with distinct Android package name and iOS bundle identifier.
  • For iOS: your Apple Developer Team ID and access to enable Associated Domains capability.

Plan your URL structure

Decide which paths should open in-app versus remain on the web. Common patterns:

  • Open in app: product pages, user profiles, cart, deeplink promotions.
  • Stay on web: legal pages, marketing landers, sitemap, robots, static docs.

Having this map will drive your Android and iOS allowlists.

1) Add intent filters to AndroidManifest

In android/app/src/main/AndroidManifest.xml, add an intent filter with autoVerify to your main activity. Consider singleTask so new links deliver via onNewIntent.

<activity
    android:name=".MainActivity"
    android:launchMode="singleTask"
    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="example.com" />
        <!-- Optional additional hosts or paths -->
        <!-- <data android:scheme="https" android:host="m.example.com" /> -->
    </intent-filter>
</activity>

Notes:

  • Do not include a pathPrefix here unless you need to constrain delivery. Prefer authorizing paths in your server files.
  • If you support multiple domains, add additional elements or separate intent filters.

2) Publish .well-known/assetlinks.json

Host this file at: https://example.com/.well-known/assetlinks.json

Minimal example authorizing all URLs for a single app:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "AB:CD:12:34:...:EF" 
      ]
    }
  }
]

Tips:

  • Use the exact SHA-256 fingerprint for the certificate used to sign the installed build.
  • You can include multiple target blocks (e.g., debug and release, or staging and prod package names).

Get fingerprints:

# Release keystore
keytool -list -v -keystore /path/to/release.keystore -alias your_alias -storepass ***** -keypass ***** | grep SHA256

# Or via Gradle task in Android Studio
./gradlew signingReport

Server requirements:

  • Must be served over HTTPS without redirects.
  • Content-Type: application/json
  • No HTML, no BOM, valid JSON only.

3) Verify on device/emulator

Install your app, then:

# Trigger verification (Android 12+ may auto-run on install)
adb shell pm verify-app-links com.example.app

# Inspect status
adb shell pm get-app-links com.example.app

# Manual open test
adb shell am start -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d "https://example.com/products/42"

Expected: the OS opens your app without a chooser. In Settings > Apps > Default apps > Opening links, ensure your domain is “verified.”

1) Enable Associated Domains

In Xcode (Runner target) > Signing & Capabilities > + Capability > Associated Domains. Add entries for every host you support:

applinks:example.com
applinks:m.example.com
# Optional wildcard subdomains
applinks:*.example.org

This writes an entitlement to Runner/Runner.entitlements. Ensure your provisioning profile covers Associated Domains.

2) Publish the AASA file

Host at either location (do both for safety):

Content must be raw JSON (no .json extension), served with Content-Type: application/json, without redirects.

Simple paths-style AASA:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.example.app",
        "paths": [
          "/products/*",
          "/u/*",
          "NOT /legal/*"
        ]
      }
    ]
  }
}
  • TEAMID is your 10-character Apple Team ID.
  • Use NOT to explicitly exclude paths that must remain on the web.

Components-style (more granular matching):

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.example.app",
        "components": [
          { "/": "/products/*" },
          { "/": "/u/*", "comment": "User profiles" }
        ]
      }
    ]
  }
}

3) Verify on device/simulator

  • Install a build signed for your Team ID.
  • On Simulator:
xcrun simctl openurl booted https://example.com/products/42
  • On a device, just tap a qualifying link (Mail, Notes, Safari). If Safari opens instead of your app, long-press the URL and choose “Open in App.” You can reset preferences in Settings > your app > Open Links.

Debugging: Connect the device to Console.app and filter for “swcd” and “applinks” to see AASA fetch and parse logs.

Once the OS routes a verified link, you need to parse the URI and navigate to the right screen.

go_router automatically processes incoming deep links on iOS/Android when using MaterialApp.router.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final _router = GoRouter(
  routes: [
    GoRoute(path: '/', builder: (c, s) => const HomeScreen()),
    GoRoute(
      path: '/products/:id',
      builder: (c, s) => ProductScreen(id: s.pathParameters['id']!),
    ),
    GoRoute(path: '/u/:handle', builder: (c, s) => ProfileScreen(handle: s.pathParameters['handle']!)),
  ],
  errorBuilder: (c, s) => const NotFoundScreen(),
);

void main() {
  runApp(MaterialApp.router(routerConfig: _router));
}

If a user taps https://example.com/products/42 , the router will match /products/42. Ensure your routes mirror your web paths.

Use a plugin to access the initial link and a stream of subsequent links.

import 'package:flutter/material.dart';
import 'package:uni_links/uni_links.dart';
import 'dart:async';

class LinkHandler extends StatefulWidget {
  final Widget child;
  const LinkHandler({super.key, required this.child});
  @override
  State<LinkHandler> createState() => _LinkHandlerState();
}

class _LinkHandlerState extends State<LinkHandler> {
  StreamSubscription? _sub;

  @override
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    final initialUri = await getInitialUri();
    if (initialUri != null) _handleUri(initialUri);

    _sub = uriLinkStream.listen(_handleUri, onError: (e) {
      // TODO: log
    });
  }

  void _handleUri(Uri uri) {
    if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'products') {
      final id = uri.pathSegments.elementAtOrNull(1);
      if (id != null) {
        // Navigate to your product screen
        // context.go('/products/$id');
      }
    }
  }

  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

Place LinkHandler above your app’s Navigator to centralize link handling.

Staging, debug, and multiple domains

  • Android: assetlinks.json must list every package+fingerprint combination you plan to install (debug and release). Many teams host a staging domain (staging.example.com) with a separate assetlinks.json authorizing the staging package.
  • iOS: AASA can authorize multiple apps by adding more items in details. For staging, add a second details entry with the staging bundle ID.

Testing matrix

  • Android
    • Fresh install on Android 13/14: verify status with adb and Settings.
    • Old device or WebView-based browsers: ensure https links still resolve.
    • Multiple browsers installed: verified links should bypass the chooser.
  • iOS
    • First-run vs subsequent runs (Universal Links open existing task without relaunch).
    • User preference toggles (Open in App vs Safari).
    • iOS 16+ vs 17+ behaviors with SceneDelegate lifecycle.

Troubleshooting checklist

  • Android

    • assetlinks.json is reachable at /.well-known/assetlinks.json without redirects.
    • Content-Type is application/json; JSON validates; no trailing commas.
    • SHA-256 fingerprint matches the installed build’s signing certificate.
    • Package name matches manifest; autoVerify is set; app reinstalled after changes.
    • Use: adb shell pm get-app-links com.example.app to inspect per-domain verdicts.
  • iOS

    • AASA hosted at both root and .well-known; no redirects; correct content-type.
    • TEAMID and bundle ID are correct; paths/components match your URLs.
    • Associated Domains entitlement includes every domain (applinks:…) and is in the provisioning profile.
    • Check Console.app logs for swcd/applinks parsing errors.
  • Flutter

    • If using uni_links, ensure android:launchMode=“singleTask” so onNewIntent delivers links to the running instance.
    • For go_router, verify your route paths mirror the website’s paths.

Security and privacy considerations

  • Limit the authorized paths to only what you need. On Android, you can narrow at the server layer using path-specific redirects; on iOS, use NOT rules.
  • Keep release keystores secure; changing signing certs breaks verification until the new fingerprint is deployed.
  • Avoid redirect chains that leak query parameters before your app opens. Prefer direct, canonical URLs.

Quick reference: files to host

Final checklist

  • Domain over HTTPS with valid cert
  • AndroidManifest intent filter with autoVerify
  • assetlinks.json with correct package + SHA-256
  • iOS Associated Domains enabled with applinks: entries
  • AASA published with correct appID and paths
  • Flutter routing wired (go_router or uni_links/app_links)
  • Verified on real devices with adb and Console logs

Conclusion

Once your domain files and app entitlements line up, verified app links feel instant and native. Implement them early, mirror your web information architecture in Flutter routing, and maintain separate configurations for staging vs production. The result: fewer broken journeys, higher conversion, and a deep-link foundation that scales across platforms and campaigns.

Related Posts