Why universal links and Android App Links break (10 fixes)
· 14 min read · Amrith Vengalath
A universal link that opens Safari instead of your app. An Android App Link that shows the app chooser, or worse, quietly opens the browser. If you have shipped deep links on mobile you have debugged both, and you have debugged them with zero error messages to work from, because Apple and Google treat a failed link association the same way: silently fall back to the browser and tell you nothing.
Having set these up across production apps and then built a validator (rndl) that checks them in CI, I can tell you that almost every broken deep link traces back to one of ten configuration mistakes. This post is the deep dive: for each one, the symptom you see, the platform behavior that actually causes it, and the fix. If you want the condensed reference instead, the troubleshooting page in the rndl docs maps each footgun to the diagnostic code that catches it.
Why do deep links fail silently?
Deep links fail silently because link association is verified out of band, by systems you cannot attach a debugger to.
On iOS, your app never proves domain ownership at tap time. Instead, when the app is installed (and periodically after), the device downloads your apple-app-site-association file, and since iOS 14 it downloads it through Apple's CDN rather than straight from your server. If that fetch fails, redirects, or returns something Apple does not like, the device simply never learns your app handles the domain. Tapping the link opens Safari, and no log anywhere says why.
Android is the same idea with different plumbing. At install time, the system's Digital Asset Links verifier fetches https://<domain>/.well-known/assetlinks.json for every host your manifest declares with android:autoVerify="true". If verification fails, links to that domain go to the browser (on Android 12 and later) or to the app chooser (on older versions), again without a visible error.
So there are three layers that must agree: the association files on your server, the native configuration baked into your app binary, and the JavaScript routing that turns a URL into a screen. Any one of them disagreeing sends the user to the browser. I covered the JS layer and the end-to-end setup in an earlier post; this one stays on the server and native layers, which is where all ten footguns live.
1. Why does the link open Safari when the AASA file looks correct?
The most common cause is a redirect. Apple fetches apple-app-site-association with redirects disabled, so a 301 or 302 at that URL means Apple sees no file at all, even though the same URL works perfectly in your browser.
This one is vicious precisely because browsers hide it. Your browser happily follows the apex-to-www canonicalization your CDN adds, the trailing-slash rewrite, or the bounce through an auth or bot-protection wall. Apple's fetcher follows none of them. You stare at valid JSON in a browser tab while Apple's CDN has been receiving a redirect for weeks.
Check it in one line:
curl -sI https://example.com/.well-known/apple-app-site-association | head -n 1
# HTTP/2 301 <- Apple sees nothing at allFix: serve the file with a 200 at the exact URL, on every host you advertise in your entitlements (the apex and www are separate hosts with separate files as far as Apple is concerned). Google's verifier has the same rule for assetlinks.json: no redirects.
Catch it in CI: rndl validate reports AASA_REDIRECTED as an error, and ASSETLINKS_REDIRECTED for the Android file.
2. Where does the apple-app-site-association file have to live?
At exactly https://<domain>/.well-known/apple-app-site-association: HTTPS, publicly reachable, returning 200, and with no .json extension.
Every part of that sentence is a way to get it wrong. Plain HTTP does not count. A file behind basic auth, a VPN, a geo-block, or an aggressive WAF rule does not count, because the request comes from Apple's infrastructure, not from your users. Naming it apple-app-site-association.json does not count either; the spec-defined path has no extension. (Apple historically also checked the domain root as a fallback, but /.well-known/ is the canonical location and the one to rely on.)
Fix: put the extension-less file under /.well-known/, over HTTPS, with no access control in front of it, and confirm with curl from outside your network.
Catch it in CI: rndl validate reports AASA_NOT_HTTPS and AASA_FETCH_FAILED.
3. Does the AASA Content-Type actually matter?
Mostly no for Apple, but a wrong Content-Type is usually the smoke from a much bigger fire.
Apple tolerates a missing or odd Content-Type on the AASA file. The reason to care is what text/html almost always means in practice: your static host or SPA framework is rewriting unknown paths to index.html, so the URL is not serving your association file at all. It is serving your web app shell with a 200, which passes every naive "is it reachable" check while giving Apple HTML instead of JSON.
Fix: serve apple-app-site-association as application/json, and make sure your host treats /.well-known/ as static files, excluded from any SPA fallback rewrite.
Catch it in CI: rndl validate reports AASA_CONTENT_TYPE as a warning (and the JSON parse failure underneath it as an error, if the file really is an HTML page).
4. How big can an apple-app-site-association file be?
128KB. Past that, iOS drops your associations without telling you.
Files usually blow past the limit for one reason: something generates a components entry per route, per product, or per marketing page. I have seen AASA files with thousands of entries that collapse to three patterns.
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.app"],
"components": [
{ "/": "/products/*" },
{ "/": "/orders/*" },
{ "/": "/invite/*", "comment": "referral links" }
]
}
]
}
}Fix: describe route families with wildcards instead of enumerating routes. If you need to carve out exceptions, exclude: true components exist for exactly that.
Catch it in CI: rndl validate reports AASA_TOO_LARGE as an error.
5. What is the correct appID format in the AASA file?
<TeamID>.<BundleID>, for example ABCDE12345.com.example.app, in applinks.details[].appIDs (or the legacy singular appID).
The two halves come from different places, which is where the typos creep in. The bundle ID is the one you know from Xcode. The Team ID is the ten-character identifier from your Apple Developer membership page, and it is not necessarily the prefix you see elsewhere. Wildcards do not work here, and an appID signed by a different team than the installed build breaks the association just as thoroughly as a typo.
Fix: copy the Team ID from your developer account, join it to the exact bundle identifier, and keep one entry per app variant you ship (a .dev bundle ID is a separate appID).
Catch it in CI: rndl validate reports AASA_MISSING_APPID and AASA_INVALID_APPID.
6. Why do some links open the app but land on the wrong screen?
Because the AASA components and the routes your app actually declares have drifted apart, and nothing in your toolchain compares the two.
This is the footgun that grows back after you fix it. The AASA file lives with your web infrastructure; the route table lives in your app code (a filesystem of screens with Expo Router, a linking config with React Navigation). Every release moves the app side, nobody updates the server side, and one day the marketing path in a campaign email opens the website while an old pattern in the file matches nothing at all.
Fix: treat the comparison as a check, not a memory exercise. rndl validate run from your app directory (or with --app-dir / --config pointing at it) parses your actual route table, the same one rndl routes prints, and cross-checks it against every non-excluded component in the file.
Catch it in CI: a route no component covers is AASA_MISSING_ROUTE (error); a component matching no route is AASA_ORPHAN_PATTERN (warning).
7. Why do universal links still open Safari when the server is perfect?
Check the Associated Domains entitlement: the entry must be applinks:example.com, and the applinks: prefix is the part everyone forgets.
You can validate the server side forever and never find this one, because it lives inside the app binary. The entitlement is set in Xcode under Signing and Capabilities, or for Expo apps in app.json:
{
"expo": {
"ios": {
"associatedDomains": ["applinks:example.com", "applinks:www.example.com"]
}
}
}Two details worth knowing. First, entitlements are baked in at signing time, so fixing this means a rebuild and a reinstall; no over-the-air update can touch it. Second, while testing you can append ?mode=developer to the entitlement entry to bypass Apple's CDN cache and fetch your AASA file directly.
No server-side validator can see inside your binary, so rndl flags this one as a manual check: read your entitlements (or app.json) with your own eyes, once per host.
8. Why do Android App Links work in debug but fail in release?
Because debug and release builds are signed with different certificates, and assetlinks.json only vouches for the fingerprints it lists.
Your debug builds use the auto-generated debug keystore. Your release builds use your upload key. And if you use Play App Signing (almost everyone does now), Google re-signs the binary users actually install with a third certificate. Each has its own SHA-256 fingerprint, and the one users run with is the one most often missing from the file.
Find your fingerprints:
cd android && ./gradlew signingReport
# or, for any built APK:
keytool -printcert -jarfile app-release.apkFor the Play-distributed build, the authoritative source is Play Console under Setup, App signing: copy the app signing key certificate, not just the upload key.
"sha256_cert_fingerprints": [
"14:6D:E9:...:44:E5",
"5B:12:00:...:9A:CC"
]Fix: list every certificate that signs a build you ship, including Google's.
Catch it in CI: rndl validate reports ASSETLINKS_FINGERPRINT_MISMATCH, ASSETLINKS_INVALID_FINGERPRINT, and ASSETLINKS_NO_FINGERPRINTS, and --sha256 <fingerprint> asserts that one specific fingerprint is present.
9. Why does Android never verify my domain?
Either the package_name in assetlinks.json does not match the installed app, or the relation string is not exactly right. A complete, correct file looks like this:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["14:6D:E9:...:44:E5"]
}
}
]The relation must be the exact string delegate_permission/common.handle_all_urls; anything else and the verifier ignores the statement. The package_name must match the applicationId of the build that is actually installed, and that is a sneakier condition than it sounds: if your debug builds append an applicationIdSuffix like .debug, they have a different package name that the file probably does not list, which is the mirror image of footgun 8 (verification failing in debug while release works).
Fix: set package_name to each applicationId you ship and copy the relation string verbatim.
Catch it in CI: rndl validate reports ASSETLINKS_PACKAGE_MISMATCH, ASSETLINKS_PACKAGE_MISSING, ASSETLINKS_NO_HANDLE_ALL_URLS, and ASSETLINKS_INVALID_RELATION, with --package <name> (or your app.json) supplying the expected value.
10. Why does Android show the app chooser instead of opening my app?
Because the manifest side of the handshake is missing: the intent filter for your domain needs android:autoVerify="true", and without it Android never even attempts verification.
A working filter needs the VIEW action, the DEFAULT and BROWSABLE categories, and data entries matching your scheme and host:
<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" />
</intent-filter>Expo apps declare the same thing in app.json under android.intentFilters (with "autoVerify": true). After rebuilding, ask the device what it actually verified:
adb shell pm get-app-links com.example.app
# look for: example.com: verifiedLike the iOS entitlement, this lives in the binary, so rndl lists it as a manual check rather than pretending a server-side fetch could detect it.
How long do AASA and assetlinks changes take to propagate?
Longer than your patience, which is why a correct fix can look like it did not work.
Apple serves AASA files through its CDN, and a change can take 24 hours or more to reach devices (a first-time fetch can take days). While iterating, append ?mode=developer to the Associated Domains entitlement to bypass the CDN, and reinstall the app to force a fresh fetch, since devices pull the file on install. Android verifies at install and update time; on Android 12 and later you can force the question again with:
adb shell pm verify-app-links --re-verify com.example.appThis is also why rndl validate prints the caching caveat in its output and never treats a just-deployed change as proof of failure.
How do you catch all of this in CI?
Run the same checks the platforms run, on every pull request, against the live files. Eight of the ten footguns above are server-side and mechanically detectable; only the two binary-side checks (the entitlement and the manifest) stay on a human checklist.
Locally, it is one dev dependency and one command:
npm install --save-dev react-native-deeplink-devtools
rndl validate --domain example.com # AASA + assetlinks checks
rndl validate --domain example.com --app-dir app # plus the route cross-check
rndl validate --domain example.com --sarif # machine-readable for CIErrors exit 1, so the bare command already works as a CI gate. On GitHub there is a ready-made Action that annotates the PR inline with each diagnostic code and uploads SARIF to the Security tab:
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: deeplink-devtools/react-native-deeplink-devtools/packages/action@main
with:
domain: example.com
app-dir: src/app # Expo Router; use `config:` for React Navigation
package: com.example.appThe point is not the tool, it is the shape of the solution: these files break through drift, and drift is caught by checks, not by care. Whatever validates your domain, run it on every change to the app's routes and every change to the website's infrastructure, because both sides can break the association.
Quick reference: symptom to cause
| Symptom | Most likely footgun | rndl code |
|---|---|---|
| Link opens Safari, AASA looks fine in a browser | 1: redirect on the AASA URL | AASA_REDIRECTED |
| Validator cannot fetch the file | 2: wrong location, HTTP, or access wall | AASA_FETCH_FAILED |
| iOS ignores a file that parses in your editor | 3: SPA fallback serving HTML | AASA_CONTENT_TYPE |
| Associations silently stopped working at scale | 4: file grew past 128KB | AASA_TOO_LARGE |
| Domain reachable, links never open the app | 5: appID typo or wrong Team ID | AASA_INVALID_APPID |
| App opens but lands on the wrong screen | 6: components vs route table drift | AASA_MISSING_ROUTE |
| Server checks all pass, still opens Safari | 7: missing applinks: entitlement prefix | manual check (app-side) |
| Works in debug, breaks in release (or reversed) | 8: signing fingerprint not listed | ASSETLINKS_FINGERPRINT_MISMATCH |
| Android never verifies the domain | 9: package name or relation string | ASSETLINKS_PACKAGE_MISMATCH |
| Chooser (or browser) instead of the app | 10: missing android:autoVerify | manual check (app-side) |
Frequently asked questions
Why does my universal link not work when I type it into Safari?
Because typing or pasting a URL into the address bar counts as browsing, and universal links only trigger from taps in other contexts (Messages, Mail, other apps, web pages). Safari shows a banner offering to open the app instead. Test by tapping a real link, for example one sent to yourself in Notes or Messages.
Do I still need a custom URL scheme?
Usually yes, as a fallback and for internal tooling, but not for anything user-facing. Any app can claim a custom scheme, there is no verification step, and scheme links do nothing useful when the app is not installed. Use https universal links and App Links for links you share with users, and keep myapp:// for development and trusted internal flows.
How do I test universal links on the iOS simulator?
Fire the URL at a booted simulator with xcrun simctl openurl booted https://example.com/products/42, and on Android with adb shell am start -a android.intent.action.VIEW -d "<url>". If you would rather not memorize either, rndl open wraps both and can build the URL from a route name and params.
What happens when someone taps the link and the app is not installed?
The link opens your website, and that is the design: universal links and App Links are standard https URLs that degrade gracefully. If you want the person to install the app and then land on the linked content afterwards, that is deferred deep linking, which the platforms do not provide out of the box; it is the territory of attribution SDKs.
The mental model that makes this debuggable
Three layers, verified at different times, by systems that do not talk to you: association files on the server (fetched by Apple's CDN and Google's verifier), native declarations in the binary (entitlements and the manifest, baked in at build time), and JS routing at tap time. When a link misbehaves, find the layer that disagrees, and let a validator do the finding: rndl validate --domain yourdomain.com covers the server layer and the route cross-check in one command, and the troubleshooting reference maps every diagnostic code back to the fix.