Deep Linking in Flutter:What Actually Works

Flutter 2026

Deep Linking in Flutter:
The Complete Breakdown

Flutter does not handle deep links automatically. Here is the complete setup for getting users from a confirmation email back into your app, including the parts that break without any error to trace.

We were building a session booking app. The auth flow looked straightforward: user signs up, gets a confirmation email, clicks the link, lands inside the app ready to book. In practice, clicking the link opened a browser, the user saw a generic redirect page, the session token was gone, and they had no idea what to do next.

The problem was not a bug in our code. Flutter gives you the UI layer and nothing else. Deep linking requires platform configuration on both iOS and Android, a small piece of web infrastructure on your domain, and Dart code to handle the incoming URL. Most tutorials cover one or two of these pieces. This post covers all of them.

01 / the problem

Two types of links, one right choice

There are two kinds of links that can open a mobile app, and they behave very differently.

URI schemes like myapp://auth/callback work between apps on the same device but email clients do not trust them. They are useful for local development and nothing else.

Universal Links on iOS and App Links on Android are real https:// URLs on a domain you control. The OS checks whether an app is registered for that domain and opens it directly. If the app is not installed, the browser handles the URL normally. These are what email clients send, and what Supabase auth emails use.

The setup requires three layers to work together: platform configuration in the app, a verification file on your domain, and a redirect page that completes the flow when the direct link cannot fire.

02 / auth provider

Configure Supabase and enable PKCE

In the Supabase dashboard under Authentication, set your redirect URL to a real domain you control. Keep the custom scheme as a secondary option for local development only.

Supabase Dashboard / Authentication / URL Configuration
1Site URL: https://yourdomain.com 2Redirect URLs: https://yourdomain.com/auth/callback 3 com.yourapp://auth/callback (dev only)

Initialize Supabase with the PKCE auth flow. Without PKCE, tokens arrive in the URL fragment after a # character. Gmail on Android strips URL fragments before opening links. PKCE puts the auth code in a query parameter instead, which survives every email client we have tested.

main.dart
1await Supabase.initialize( 2 url: ‘https://your-project.supabase.co’, 3 anonKey: ‘your-anon-key’, 4 authOptions: FlutterAuthClientOptions( 5 authFlowType: AuthFlowType.pkce, 6 ), 7);
03 / android

Android App Links setup

Intent filter

Add an intent filter inside the MainActivity activity block in AndroidManifest.xml. The android:autoVerify=”true” attribute tells Android to verify your domain ownership so it opens the app directly without showing a disambiguation dialog.

android/app/src/main/AndroidManifest.xml
1<intent-filter android:autoVerify=“true”> 2 <action android:name=“android.intent.action.VIEW” /> 3 <category android:name=“android.intent.category.DEFAULT” /> 4 <category android:name=“android.intent.category.BROWSABLE” /> 5 <data android:scheme=“https” 6 android:host=“yourdomain.com” 7 android:pathPrefix=“/auth/callback” /> 8</intent-filter>

Digital Asset Links file

Android will not open the app from https:// links without this verification file hosted at your domain. If the file is missing or incorrect, Android falls back to the browser with no error shown anywhere.

https://yourdomain.com/.well-known/assetlinks.json
1[{ 2 “relation”: [“delegate_permission/common.handle_all_urls”], 3 “target”: { 4 “namespace”: “android_app”, 5 “package_name”: “com.yourapp.id”, 6 “sha256_cert_fingerprints”: [“AA:BB:CC:…”] 7 } 8}]
Watch out Use the release keystore fingerprint, not the debug one. They are different values and App Links will verify correctly in debug builds but silently fall back to the browser in every production install. Run keytool -list -v -keystore your-release.keystore to get the correct SHA-256 value. You can add both fingerprints to the array to cover both environments.
04 / ios

iOS Universal Links setup

Entitlements

In Xcode, select your target, go to Signing and Capabilities, and add the Associated Domains capability. Register your domain with the applinks: prefix:

ios/Runner/Runner.entitlements
1<key>com.apple.developer.associated-domains</key> 2<array> 3 <string>applinks:yourdomain.com</string> 4</array>

Apple App Site Association

Host this JSON file at your domain. Note there is no .json extension in the filename. Apple fetches this file when the app is installed, not at runtime, so any changes you make take effect on the next install.

https://yourdomain.com/.well-known/apple-app-site-association
1{ 2 “applinks”: { 3 “details”: [{ 4 “appIDs”: [“TEAMID.com.yourapp.id”], 5 “components”: [{ “/”: “/auth/callback*” }] 6 }] 7 } 8}
iOS Simulator limitation Universal Links do not work on the iOS Simulator at all. This is a platform limitation with no workaround. You need a physical device or a TestFlight build to test the complete flow. Budget time for this during development.
05 / web bridge

The redirect page nobody mentions

When the user clicks the email link, the OS fires the Universal or App Link and opens the app directly if it is installed and the domain has been verified. If not, the browser opens instead.

You need a small HTML page at /auth/callback that reads the incoming parameters and redirects to the app via its URI scheme. This is the piece most tutorials skip, and without it the flow breaks the moment a user clicks the link on a device where verification has not completed yet.

public/auth/callback/index.html
1<script> 2 // With PKCE the auth code arrives as a query param, not a fragment 3 const params = window.location.search; 4 5 if (params) { 6 window.location.href = ‘com.yourapp://auth/callback’ + params; 7 } else { 8 window.location.href = ‘/login?error=invalid_link’; 9 } 10</script>

The complete flow with this page in place:

  1. 01User clicks link in confirmation email
  2. 02OS checks if an app is registered for yourdomain.com
  3. YApp opens directly with the URI and code parameter
  4. NBrowser opens /auth/callback?code=…
  5. HTML page redirects to com.yourapp://auth/callback?code=…
  6. OS opens app via URI scheme
  7. 03Flutter app_links catches the incoming URI
  8. 04Supabase.auth.getSessionFromUrl(uri) stores the session
  9. 05Auth state changes, router navigates to home screen
06 / dart code

Handling the link in Flutter

Add app_links to your pubspec.yaml dependencies. Starting from app_links v6, uriLinkStream covers both cold start and warm start cases, so you only need one listener. Call it as early as possible in your app lifecycle, before any navigation happens.

auth_deep_link_handler.dart
1class AuthDeepLinkHandler { 2 final _appLinks = AppLinks(); 3 StreamSubscription<Uri>? _sub; 4 5 void init() { 6 // uriLinkStream covers cold start and warm start in app_links v6+ 7 _sub = _appLinks.uriLinkStream.listen(_handle); 8 } 9 10 Future<void> _handle(Uri uri) async { 11 if (uri.path.contains(‘/auth/callback’)) { 12 await Supabase.instance.client.auth.getSessionFromUrl(uri); 13 } 14 } 15 16 void dispose() => _sub?.cancel(); 17}

Connect the router to Supabase auth state so navigation reacts automatically when a session is established. The example below uses go_router with a refresh stream wrapper:

router.dart
1final router = GoRouter( 2 refreshListenable: GoRouterRefreshStream( 3 Supabase.instance.client.auth.onAuthStateChange, 4 ), 5 redirect: (context, state) { 6 final session = Supabase.instance.client.auth.currentSession; 7 if (session == null) return ‘/login’; 8 if (state.matchedLocation.startsWith(‘/auth’)) return ‘/home’; 9 return null; 10 }, 11 routes: [ /* your routes */ ], 12);
Initialization order matters Subscribe to uriLinkStream before calling runApp(). If you wait until after the widget tree builds, you risk missing the cold start link on Android. Also make sure await Supabase.initialize() completes first, otherwise getSessionFromUrl will be called on an unready client and fail silently.
07 / bugs

The things that break silently

These are the failures we hit during testing. None of them produce a useful error message.

bug 01 Deep link handler initialized too late on cold start
If you initialize AppLinks after your first frame renders, the stream subscription can miss the initial link entirely on some Android devices. The app opens normally with no deep link handled.
Initialize AppLinks and subscribe to uriLinkStream before runApp(), or as early as possible in main()
bug 02 Android works in debug, breaks in production
The assetlinks.json was set up using the debug keystore fingerprint. Release builds use a different signing key. Android App Links verifies correctly in debug and silently falls back to the browser in every production install.
Add both debug and release SHA-256 fingerprints to assetlinks.json
bug 03 iOS Universal Links only work on a real device
The iOS Simulator does not support Universal Links at all. This is a platform limitation, not a configuration problem. No entitlement change or AASA file adjustment will make it work in the Simulator. You need a physical device or a TestFlight build.
Test the complete auth flow on a physical device from the start
bug 04 Gmail on Android strips URL fragments
Without PKCE, Supabase sends the session tokens in the URL fragment after a # character. Gmail on some Android versions removes the fragment before opening the link. The redirect page receives a URL with no tokens and the user sees a generic error with no context.
Use AuthFlowType.pkce so the auth code travels as a query parameter instead

After this setup the flow worked cleanly across iOS and Android, cold start and warm start, Gmail, Apple Mail, and Outlook. The testers who had been landing on blank redirect pages were opening the app directly to the booking screen, authenticated and ready to go.

None of the individual pieces are complicated. The challenge is that they all have to be right at the same time, and when something is wrong the failure is usually silent.