On this page
scope
The complete reference for the Apple App Site Association file. Every field documented, every gotcha enumerated. This is the format spec — for the setup walkthrough, see /guides/ios-universal-links-setup. For the Android equivalent, see /guides/assetlinks-json-explained.
Cluster C hub: /guides/firebase-dynamic-links-replacement.
the file
A JSON file served at:
https://yourdomain.com/.well-known/apple-app-site-association
The file declares which iOS apps are authorized to handle URLs on your domain. iOS fetches it once at app install time (and periodically afterward via Apple's CDN proxy) and uses the declared associations to route Universal Link taps.
Hosting requirements:
| Property | Required value |
|---|---|
| Scheme | https:// |
| Path | /.well-known/apple-app-site-association |
| Extension | None — do not append .json |
| Content-Type | application/json (case-insensitive, charset suffix OK) |
| Redirects | None — 301/302 cause iOS to reject the file |
| File size | Under 128 KB (Apple cap) |
| Authentication | None — public anonymous GET |
| TLS | Publicly trusted certificate (no self-signed) |
the modern format (iOS 13+)
{
"applinks": {
"details": [
{
"appIDs": ["ABCD1234EF.com.yourcompany.yourapp"],
"components": [
{
"/": "/product/*",
"comment": "Product detail pages"
},
{
"/": "/profile/*",
"exclude": true,
"comment": "Profile pages excluded from Universal Links"
},
{
"/": "/article/*",
"?": { "preview": "true" }
}
]
}
]
}
}
The structure:
applinks.details[]— array of app associations. Multiple entries allow different apps to claim different paths on the same domain.appIDs[]— Team ID + bundle ID. Multiple app IDs supported per details entry.components[]— path matching rules. Evaluated in order; first match wins.
Each component entry can have:
| Key | Type | Meaning |
|---|---|---|
/ |
string | URL path pattern. * matches one or more path components; ? matches a single character |
? |
object | Query parameter filter — link must match these key-value pairs |
# |
string | Fragment filter |
exclude |
boolean | If true, this match REMOVES the URL from Universal Link handling |
comment |
string | Documentation, ignored by iOS |
caseSensitive |
boolean | Default true. Set false for case-insensitive matching |
percentEncoded |
boolean | Default true. Set false to compare the unencoded path |
The components[] array gives you fine-grained control over which URLs route to the app vs which fall through to Safari.
a complete example
A real-world AASA for a media site:
{
"applinks": {
"details": [
{
"appIDs": ["ABCD1234EF.com.example.reader"],
"components": [
{
"/": "/article/*",
"comment": "All articles open in the app"
},
{
"/": "/article/preview/*",
"exclude": true,
"comment": "But preview links stay on web"
},
{
"/": "/podcast/*",
"comment": "Podcast episodes open in the app"
},
{
"/": "/author/*",
"?": { "share": "true" },
"comment": "Author pages open in app ONLY when share=true is present"
},
{
"/": "/admin/*",
"exclude": true,
"comment": "Admin paths never open in app"
}
]
},
{
"appIDs": ["ABCD1234EF.com.example.podcast"],
"components": [
{
"/": "/podcast/*",
"comment": "Podcast app ALSO claims podcast routes"
}
]
}
]
}
}
Order matters in components. The exclude: true entry for /article/preview/* must come AFTER the broader /article/* entry — otherwise the broader rule wins and previews open in the app. iOS evaluates top-to-bottom and stops at the first match.
When two apps claim overlapping paths (as the reader and podcast apps do above for /podcast/*), iOS surfaces the system app-picker on first tap and remembers the user's choice.
the legacy format (pre-iOS 13)
Older Apple documentation showed a simpler format:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "ABCD1234EF.com.yourcompany.yourapp",
"paths": ["/product/*", "NOT /admin/*"]
}
]
}
}
Differences:
appID(singular) instead ofappIDs[].paths[]— array of strings.NOT /pathsyntax for exclusions.apps[]— vestigial empty array, ignored.
Apple still honors this format for backwards compatibility, but new files should use the modern components syntax. iOS 13+ provides richer matching (query filters, fragment filters, case sensitivity) via components that paths cannot express.
You can include both formats in a single file for transitional support, but iOS 13+ ignores the legacy paths if components is present.
defaults inheritance
In the modern format, the defaults object lets you avoid repeating the same flag on every component:
{
"applinks": {
"details": [
{
"appIDs": ["ABCD1234EF.com.yourcompany.yourapp"],
"defaults": {
"caseSensitive": false,
"percentEncoded": false
},
"components": [
{ "/": "/product/*" },
{ "/": "/profile/*" }
]
}
]
}
}
Per-component values override defaults.
hosting the file
Some platform-specific notes on serving the file with the correct Content-Type:
Nginx
location = /.well-known/apple-app-site-association {
default_type application/json;
add_header X-Content-Type-Options nosniff;
try_files /.well-known/apple-app-site-association.json =404;
}
Note the try_files redirects to the .json extension on disk, but serves under the extension-less URL.
Apache (.htaccess)
<Files "apple-app-site-association">
ForceType application/json
</Files>
Cloudflare Workers
addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname === '/.well-known/apple-app-site-association') {
event.respondWith(new Response(JSON.stringify(aasa), {
headers: { 'content-type': 'application/json; charset=utf-8' }
}));
}
});
Next.js / Vercel
Place a file at public/.well-known/apple-app-site-association and configure rewrites in vercel.json:
{
"headers": [{
"source": "/.well-known/apple-app-site-association",
"headers": [{ "key": "Content-Type", "value": "application/json" }]
}]
}
Apple CDN cache behavior
iOS doesn't fetch the AASA from your domain on every link tap. The flow:
- App is installed.
- iOS asks Apple's CDN: "give me the AASA for
yourdomain.com." - Apple's CDN fetches from your origin if not cached; otherwise serves the cached copy.
- iOS stores the result locally and consults it on every Universal Link tap.
Two cache layers: Apple's CDN, and the device's local cache. Both invalidate on:
- App reinstall.
- App update where the version string changes.
- Manual
swcutilinvalidation on a development device. - Approximately 24 hours of natural CDN TTL.
You cannot force a refresh on a user's device. Plan AASA changes to be additive.
During development, append ?mode=developer to your Associated Domains entitlement to bypass Apple's CDN:
applinks:yourdomain.com?mode=developer
Remove this for production builds.
debugging
Check the file is reachable
curl -sI https://yourdomain.com/.well-known/apple-app-site-association
Expected HTTP/2 200, content-type: application/json. Anything else (302, 404, text/plain) breaks Universal Links.
Check the JSON is valid
curl -s https://yourdomain.com/.well-known/apple-app-site-association | python3 -m json.tool
If python3 -m json.tool errors, iOS will reject the file. Common JSON errors: trailing commas, single quotes, comments using // (only the "comment" field is supported).
Check Apple sees the file
Use Apple's AASA validator (Branch hosts a free tool that mirrors what iOS does) or swcutil on a Mac connected to a development iPhone:
sudo /usr/bin/swcutil show -d yourdomain.com
The output shows whether iOS fetched the file successfully, what was returned, and which app IDs/paths were registered.
Check the user-side state
On the device, Settings → Apps → Your App → Universal Links should show "Allow All Domains" or list the configured domains. If the user previously chose "Open in Safari" from a long-press, this setting is what controls the override.
common AASA failures
| Symptom | Cause | Fix |
|---|---|---|
| Universal Link opens Safari | AASA not reachable | Check curl 200 OK |
| Universal Link opens Safari, AASA reachable | Wrong Content-Type | Set application/json |
| Universal Link opens Safari, JSON valid | Wrong appID | Check Team ID + bundle ID match exactly |
| Universal Link opens Safari, appID correct | Path pattern mismatch | Check components[].'/' matches URL path |
| Universal Link works on dev device, fails TestFlight | AASA cached at Apple CDN | Wait 24h, increment version string |
| Universal Link works first install, fails after app update | AASA fetched once and not refreshed | Plan additive changes; use developer mode for testing |
| Two apps claim the same path | First-tap user picks, then sticky | Resolve via app picker UI |
related dev reading
- iOS Universal Links setup: /guides/ios-universal-links-setup
- Android assetlinks.json (the equivalent): /guides/assetlinks-json-explained
- Android setup: /guides/android-app-links-setup
- Cluster C hub: /guides/firebase-dynamic-links-replacement
- Bridge to consumer-side: /guides/in-app-browser-logged-out
For non-app linking where AASA doesn't apply, the no-SDK contract is at link.boo/api.
references
- Apple Supporting Associated Domains documentation
- Apple App Site Association format specification (iOS 13+)
- Apple
swcutildebugging tool - Apple Universal Links technical reference