Skip to main content

Server-Side Preparation for Deep Linking

· 8 min read
Sinan Aktepe
Software Engineer

Deep linking is usually explained through routing code on the mobile side. That part matters, but in the world of verified links there is another quiet, critical piece: the server-side preparation.

iOS Universal Links and Android App Links want to establish a secure association between a domain and an app. That association is not verified from inside the app — it is verified through small JSON files published under the domain. If the file sits at the wrong path, gets redirected, has a wrong MIME type, or is not publicly reachable, the link may never make it into the app, no matter how correct your mobile code is.

This post focuses only on the server-side work:

  • apple-app-site-association for iOS
  • assetlinks.json for Android
  • The correct .well-known path
  • MIME type, redirect, and access checks
  • Quick verification commands to run after deploy

The examples use placeholder values like example.com and com.example.app. You should substitute your own domain, bundle id, package name, and signing details.

Why we publish files on the server

The core idea of verified deep linking is simple: "Does this domain actually grant this app permission to open its links?"

On iOS, that verification is done through the apple-app-site-association file. On Android, the equivalent is assetlinks.json. The operating system downloads these files from a known location under the domain, compares their contents with the app's own identity, and — if everything matches — can route links directly into the app.

This is why server preparation deserves more than a "just upload a JSON file" mindset. A small HTTP detail here can silently break the entire deep linking flow.

General folder layout

For both platforms, the files go under the .well-known directory at the root of the domain:

https://example.com/.well-known/apple-app-site-association
https://example.com/.well-known/assetlinks.json

A few rules apply:

  • The .well-known directory must be publicly accessible.
  • The files must not sit behind authentication.
  • The endpoints must return 200 OK.
  • There must be no 301 or 302 redirects.
  • The response body must be JSON, not HTML.
  • CDN, WAF, bot protection, or geo-blocking must not interfere with these files.

If the app should open links for both example.com and www.example.com, serve the files on both hosts at the correct path. Relying on a redirect from the root domain to www is not a reliable strategy for verification.

iOS: apple-app-site-association

The file name on the iOS side must be exactly:

apple-app-site-association

The file must not have a .json extension. Locally, or while preparing the file, you can keep it as apple-app-site-association.json, but by the time it reaches the server the extension must be dropped.

The file must be reachable at this exact URL:

https://example.com/.well-known/apple-app-site-association

An example payload:

{
"applinks": {
"apps": [],
"details": [
{
"appID": "ABCDE12345.com.example.app",
"paths": [
"*"
]
}
]
}
}

Here appID is the combination of the Apple Team ID (or App ID Prefix) and the iOS bundle identifier:

<TeamID>.<BundleID>

In the example:

ABCDE12345.com.example.app

The paths field specifies which URL paths the app is allowed to open. ["*"] covers everything. For a tighter setup, it is often cleaner to enumerate only the paths you actually need:

"paths": [
"/articles/*",
"/news/*"
]

Apple's more recent documentation uses the appIDs and components format. The appID/paths format shown above is the legacy format and is still common in practice. The important thing is to not mix the two formats within the same file.

Server configuration for AASA

Because the AASA file has no extension, some web servers will serve it as application/octet-stream or try to force it as a download. The behavior we actually want:

  • Opening the URL in a browser shows the file.
  • The file is not forced as a download.
  • Content-Type is application/json.
  • The response is 200 OK.
  • No redirects.

Nginx example:

location = /.well-known/apple-app-site-association {
default_type application/json;
try_files $uri =404;
}

For Apache, using ForceType on the extensionless AASA file is the safer choice:

<Files "apple-app-site-association">
ForceType application/json
</Files>

If your hosting layer exposes metadata — for example S3 or a similar object storage — set the Content-Type metadata on the file to application/json. And if you have an SPA fallback rule, make sure this path does not fall through to index.html.

Android: assetlinks.json

On Android, the file is served with its extension:

assetlinks.json

Exact URL:

https://example.com/.well-known/assetlinks.json

An example payload:

[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]

The fields:

  • relation: declares that the app is allowed to handle URLs for this domain.
  • namespace: android_app for Android app association.
  • package_name: the Android application's package name.
  • sha256_cert_fingerprints: the SHA-256 fingerprint of the certificate that signs the app.

The SHA-256 fingerprint is easy to get wrong. If you use Play App Signing, you usually need the app signing certificate fingerprint shown in the Play Console — not the upload key. Debug builds have their own debug keystore fingerprint, which is different again. Rather than mixing test certificates into your production domain, it is cleaner to use a dedicated flavor or a separate test domain.

Server configuration for assetlinks.json

Because the Android file has a .json extension, most servers will serve it with the right MIME type out of the box. Still, the same checks apply as on iOS:

  • Content-Type is application/json.
  • The file is reachable over HTTPS.
  • No redirects.
  • No authentication.
  • The JSON is valid.

For Apache, if you want to make the .json MIME type explicit:

AddType application/json .json

For Nginx, the default mime.types usually covers JSON. If you prefer a dedicated location block:

location = /.well-known/assetlinks.json {
default_type application/json;
try_files $uri =404;
}

Post-deploy verification

Once the files are uploaded, the first check can be in the browser — but the real check is inspecting the HTTP response from the terminal.

iOS:

curl -i https://example.com/.well-known/apple-app-site-association

Android:

curl -i https://example.com/.well-known/assetlinks.json

What you want to see:

HTTP/2 200
content-type: application/json

What you do not want to see:

HTTP/2 301
HTTP/2 302
content-type: text/html
content-disposition: attachment

To additionally confirm the JSON is valid:

curl -fsS https://example.com/.well-known/assetlinks.json | jq .
curl -fsS https://example.com/.well-known/apple-app-site-association | jq .

Note that I deliberately do not use -L. If there is a redirect, I want to catch it, not hide it.

Most common mistakes

Most of the problems in this setup show up in the server response before they ever reach the app code:

  • Uploading the file as apple-app-site-association.json instead of the extensionless name.
  • Serving the AASA file from somewhere other than .well-known.
  • Letting /.well-known/* fall through to an SPA index.html.
  • Redirecting HTTP to HTTPS, or the root domain to www, on these endpoints.
  • Putting the file behind basic auth, VPN, WAF, or bot protection.
  • Using the wrong SHA-256 fingerprint on Android.
  • Using the wrong Team ID or bundle identifier on iOS.
  • Mixing comments, trailing commas, or an HTML body into the JSON.
  • Expecting changes to propagate instantly without clearing CDN caches.

Caching is another practical detail. On Android, the device may need a re-verification trigger. On iOS, Associated Domains verification goes through Apple's CDN, so do not expect updates to hit every device immediately.

Short release checklist

Before shipping, it is worth walking through this list once:

  • Is https://example.com/.well-known/apple-app-site-association reachable?
  • Is the AASA file extensionless?
  • Is https://example.com/.well-known/assetlinks.json reachable?
  • Do both endpoints return 200 OK?
  • Do both endpoints return application/json?
  • Are there any redirects?
  • Are the files reachable publicly, without authentication?
  • Is the iOS appID built from the correct Team ID and Bundle ID?
  • Is the Android package_name correct?
  • Does the Android SHA-256 fingerprint match the release signing certificate?
  • Are the files published for all variants of the domain?

No matter how carefully the mobile side of deep linking is written, verified link behavior is not reliable until this checklist is done. The good news: once the server side is set up correctly, most of the uncertainty in deep link debugging disappears.

References