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. appIDsvalues use<Application Identifier Prefix>.<Bundle Identifier>, which is your team ID, a dot, and the bundle ID from Xcode.componentsmatch on path (/), query (?), and fragment (#), and an"exclude": trueentry 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.
How Flutter receives the link
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
| Symptom | Likely cause | Fix |
|---|---|---|
| Link always opens Safari | AASA missing, redirecting, or still CDN-cached | Serve the extensionless file over https with no redirects, then wait or use alternate mode |
| Nothing works in Xcode preview builds | Signed in with a personal development team | Use an organization team with the Associated Domains capability |
| Entitlement added but no effect | Domain entry contains a path or trailing slash | Keep entries as applinks:host only |
| Double handling or dropped links | app_links installed while Flutter handler is enabled | Set FlutterDeepLinkingEnabled to NO in Info.plist |
| Works on Simulator, fails on device | Old build or stale association | Reinstall the app so the check reruns at install time |
| App opens on a URL meant for the browser | AASA components broader than intended | Add an exclude component for that path pattern |
| Only one subdomain opens the app | Each host needs its own AASA file | Publish 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
- https://docs.flutter.dev/cookbook/navigation/set-up-universal-links
- https://developer.apple.com/documentation/xcode/supporting-associated-domains
- https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app
- https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content
- https://pub.dev/packages/app_links
- https://docs.flutter.dev/tools/devtools/deep-links