Skip to content
Blog

iOS Universal Links in Flutter: AASA, Entitlements, and Verification

Implement iOS universal links in Flutter end to end: apple-app-site-association JSON, the applinks entitlement, FlutterDeepLinkingEnabled, simctl testing, and failure modes.

Published on • November 12, 2026

AI Assistant

Universal links are the iOS half of the verified deep link story: one https URL that opens your website in the browser when the app is missing and jumps straight into the Flutter app when it is installed, with no chooser and no custom scheme. Deep Linking and App Links in 2026 covered the platform-neutral strategy, and Android App Links in Flutter handled the Google side, so this post is strictly the iOS implementation: hosting the association file, wiring the entitlement, deciding how Flutter receives the URL, and proving it works on a device.

Hosting the association file

Apple’s server-side half is a file named apple-app-site-association, with no file extension, served from the .well-known directory:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["S8QB4VV633.com.example.myapp"],
        "components": [
          { "#": "no_universal_links", "exclude": true },
          { "/": "/p/*" },
          { "/": "/help/*", "?": { "articleNumber": "????" } }
        ]
      }
    ]
  }
}

The rules Apple documents around that file are strict:

  • Serve it over https:// with a valid certificate and no redirects anywhere in the path.
  • appIDs values use <Application Identifier Prefix>.<Bundle Identifier>, which is your team ID, a dot, and the bundle ID from Xcode.
  • components match on path (/), query (?), and fragment (#), and an "exclude": true entry wins for URLs you want left to the browser.
  • Every subdomain in your entitlement needs its own entry and serves its own copy of the file.
  • Since iOS 14 and macOS 11, devices fetch AASA files through an Apple-managed CDN rather than hitting your server directly, so expect up to 24 hours before the CDN picks up a new file and roughly weekly refreshes on installed apps. During development you can append ?mode=<alternate mode> to the entitlement entry to bypass the CDN and reach a private host.

The associated domains entitlement

The app side is one entitlement in ios/Runner/Runner.entitlements, which Xcode edits when you add the Associated Domains capability under Signing & Capabilities:

<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:example.com</string>
</array>

Each entry follows <service>:<fully qualified domain>. Include only the subdomain and top level: no path, no query string, no trailing slash. A *. prefix such as applinks:*.example.com matches all subdomains for this service, which saves you repeating entries, but the matching AASA file still has to exist on each host. Two practical constraints come straight from the docs: personal development teams do not support the Associated Domains capability at all, and multiple flavors are handled by listing several appIDs in one AASA file rather than by juggling entitlements.

With Flutter’s built-in handler, the framework consumes the incoming NSUserActivity whose activity type is NSUserActivityTypeBrowsingWeb and hands the webpageURL to your router. The cookbook notes that Flutter versions earlier than 3.27 require manually opting in with FlutterDeepLinkingEnabled set to YES in Info.plist, while current versions need no such step. If you instead use a third-party plugin such as app_links to catch cold-start links, set FlutterDeepLinkingEnabled to NO, because Flutter’s default handler and the plugin otherwise fight over the same URL. Either way the URL lands in your route table, and Deep Linking with go_router covers mapping those paths onto screens.

Apple is explicit that universal links are an attack surface: validate every query parameter, discard malformed URLs, and never let a link reach destructive actions such as deleting content directly. On the native side the URL arrives through scene(_:willConnectTo:options:) when the app launches cold and scene(_:continue:) when it is already running, which mirrors how Android delivers a second tap through onNewIntent.

Verifying on device

Verification is a checklist rather than a single command:

# 1. Confirm the file is live, extensionless, and redirect-free
curl -I https://example.com/.well-known/apple-app-site-association
curl https://example.com/.well-known/apple-app-site-association

# 2. Install a fresh build, because the association check runs at install time
flutter run

# 3. Fire a link at the Simulator
xcrun simctl openurl booted https://example.com/p/42

On a physical device, type the URL into Notes and tap the resulting link, which exercises the real detection path instead of an intent shortcut. Two expectations keep teams from chasing ghosts: a brand-new domain can wait up to a day for Apple’s CDN to fetch the AASA file, and tapping a link for your own domain while browsing that same site inside Safari deliberately stays in Safari. Links tapped in a different context, another app, Messages, or a different domain, open the app. Flutter DevTools also ships a Validate deep links panel that reads the entitlement and the hosted file together for both platforms.

Failure modes

SymptomLikely causeFix
Link always opens SafariAASA missing, redirecting, or still CDN-cachedServe the extensionless file over https with no redirects, then wait or use alternate mode
Nothing works in Xcode preview buildsSigned in with a personal development teamUse an organization team with the Associated Domains capability
Entitlement added but no effectDomain entry contains a path or trailing slashKeep entries as applinks:host only
Double handling or dropped linksapp_links installed while Flutter handler is enabledSet FlutterDeepLinkingEnabled to NO in Info.plist
Works on Simulator, fails on deviceOld build or stale associationReinstall the app so the check reruns at install time
App opens on a URL meant for the browserAASA components broader than intendedAdd an exclude component for that path pattern
Only one subdomain opens the appEach host needs its own AASA filePublish the file on every host listed in the entitlement

Takeaway: Universal links succeed when the two halves match exactly, so publish an extensionless, redirect-free apple-app-site-association on every host, declare only bare applinks: entries under an organization team, pick either Flutter’s built-in handler or a plugin like app_links but never both, then verify with simctl openurl plus a real tap from Notes before you announce the link.

Sources