Django: serve apple-app-site-association and assetlinks.json

If your site has companion Apple or Android apps, you probably want links to your site to open in those apps, when installed. Both platforms support this, with Apple calling the feature Universal Links and Android calling it App Links.
But an app can’t just claim to handle your links, or any malicious app could hijack them. Instead, both platforms require two-way association: the app declares which domains it handles, and each domain confirms which apps may handle its links. The domain’s side of that handshake is a JSON file served under the reserved /.well-known/ path, one per platform:
/.well-known/apple-app-site-associationfor iOS, iPadOS, and macOS./.well-known/assetlinks.jsonfor Android.
These files can do more than link handling. Both can also associate apps with your site for password autofill and passkeys, so users can sign in to your apps with credentials saved from your site.
In this post, we’ll look at serving these two files from Django, with tests. It follows the same pattern as my recent post on serving a security.txt file, but with JSON and a few gotchas covered below.
Write the Apple file
Here’s an example apple-app-site-association file, following Apple’s documentation:
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.app"],
"components": [
{
"/": "/admin/*",
"exclude": true,
"comment": "Keep the admin in the browser"
},
{
"/": "/*"
}
]
}
]
},
"webcredentials": {
"apps": ["ABCDE12345.com.example.app"]
}
}
This declares two services:
applinksfor Universal Links.appIDslists your app IDs, each your Apple developer team ID, a dot, and the app’s bundle ID.componentslists URL patterns, checked in order until the first match, soexcludepatterns come first. In the example, the first pattern keeps URLs under/admin/opening in the browser, while the second sends all other URLs to the app.webcredentialsfor password autofill and passkeys.
Replace the example app ID with your own, which your iOS developers can provide.
Apple specifies the filename with no .json extension, but save your copy as apple-app-site-association.json. That extension lets Django’s FileResponse detect the right content type, as we’ll see below, and makes your editor recognize the file as JSON. The URL will still use Apple’s name, without the extension. Put it in one of your Django apps, next to its views.py. Like the security.txt post, I’ll use a “core” app within a project package called example, so example/core/apple-app-site-association.json.
Write the Android file
Here’s an example assetlinks.json file, following Android’s documentation:
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}
]
This grants the app identified by target two permissions: handle_all_urls for App Links, and get_login_creds for password autofill and passkeys. The sha256_cert_fingerprints identify your app’s signing certificates. If you use Play App Signing, copy the fingerprint from the Play Console’s app signing page. Replace the example package name and fingerprint with your own, which your Android developers can provide.
Save this file as assetlinks.json in the same Django app, Like example/core/assetlinks.json.
Serve the files
Both platforms have similar serving requirements:
- The file must be served over HTTPS.
- The response must be
200 OK, with no redirects. - The
content-typeheader must beapplication/json. - Each domain must serve its own file. Associating
example.comdoesn’t coverwww.example.com, and vice versa.
Here are views that meet these requirements:
from pathlib import Path
from django.contrib.auth.decorators import login_not_required
from django.http import FileResponse, HttpRequest, HttpResponse
from django.views.decorators.cache import cache_control
from django.views.decorators.http import require_safe
APPLE_APP_SITE_ASSOCIATION_PATH = (
Path(__file__).parent / "apple-app-site-association.json"
)
@login_not_required
@require_safe
@cache_control(max_age=60 * 5, public=True) # 5 minutes
def apple_app_site_association(request: HttpRequest) -> HttpResponse:
"""
Serve the apple-app-site-association file, per:
https://adamj.eu/tech/2026/10/01/django-app-links/
"""
return FileResponse(APPLE_APP_SITE_ASSOCIATION_PATH.open("rb"))
ASSETLINKS_JSON_PATH = Path(__file__).parent / "assetlinks.json"
@login_not_required
@require_safe
@cache_control(max_age=60 * 5, public=True) # 5 minutes
def assetlinks_json(request: HttpRequest) -> HttpResponse:
"""
Serve the assetlinks.json file, per:
https://adamj.eu/tech/2026/10/01/django-app-links/
"""
return FileResponse(ASSETLINKS_JSON_PATH.open("rb"))
…with these corresponding entries in your root URLconf:
from django.urls import path
from example.core import views as core_views
urlpatterns = [
# ...
path(
".well-known/apple-app-site-association",
core_views.apple_app_site_association,
),
path(
".well-known/assetlinks.json",
core_views.assetlinks_json,
),
# ...
]
Notes:
@login_not_requiredmarks the views as public, for projects using Django’sLoginRequiredMiddleware. I recommend you use this feature from Django 5.1 to secure your site by default!@require_saferestricts the views to GET and HEAD requests.@cache_controlsets acache-controlheader allowing clients and intermediate caches, such as a content delivery network (CDN), to cache the files for five minutes. That saves repeat requests from the various verifiers, while still letting changes roll out quickly.FileResponsestreams each file as the response body, byte-for-byte. It also sets thecontent-typeheader based on the file extension, so both files getapplication/json.
Sprinkle on some friendly tests
Tests are your friends, and these ones are extra friendly, since mistakes in these files fail silently. A broken file means links open in the browser, with no error message anywhere. Here are tests covering both views, which you could put in your Django app’s tests.py:
import json
from http import HTTPStatus
from django.test import SimpleTestCase
class AppleAppSiteAssociationTests(SimpleTestCase):
"""
Test the apple-app-site-association file, per:
https://adamj.eu/tech/2026/10/01/django-app-links/
"""
url = "/.well-known/apple-app-site-association"
def test_success(self):
response = self.client.get(self.url)
assert response.status_code == HTTPStatus.OK
assert response["content-type"] == "application/json"
assert response["cache-control"] == "max-age=300, public"
data = json.loads(response.getvalue())
app_id = "ABCDE12345.com.example.app"
assert data["applinks"]["details"][0]["appIDs"] == [app_id]
assert data["webcredentials"]["apps"] == [app_id]
def test_head(self):
response = self.client.head(self.url)
assert response.status_code == HTTPStatus.OK
def test_post_disallowed(self):
response = self.client.post(self.url)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
class AssetlinksJsonTests(SimpleTestCase):
"""
Test the assetlinks.json file, per:
https://adamj.eu/tech/2026/10/01/django-app-links/
"""
url = "/.well-known/assetlinks.json"
def test_success(self):
response = self.client.get(self.url)
assert response.status_code == HTTPStatus.OK
assert response["content-type"] == "application/json"
assert response["cache-control"] == "max-age=300, public"
data = json.loads(response.getvalue())
assert data[0]["target"]["package_name"] == "com.example.app"
def test_head(self):
response = self.client.head(self.url)
assert response.status_code == HTTPStatus.OK
def test_post_disallowed(self):
response = self.client.post(self.url)
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
Notes:
- The
test_success()methods check for a200 OKstatus code, which also means there’s no redirect, plus thecontent-typeandcache-controlheaders. - They then parse the body with
json.loads(), which fails on invalid JSON, such as from a trailing comma.FileResponseis a streaming response, with nocontentattribute, so they read the body withgetvalue(). - Finally, they check for the app identifiers, so swap in your own. You could extend these checks to cover more of the structure, such as URL patterns that should or shouldn’t open your app.
test_head()andtest_post_disallowed()check the effect of@require_safe.
Check in production
Tests confirm your views work, but you can go a step further to check the platforms are properly integrated. After deploying, verify each file through the platforms’ own infrastructure.
For Apple, since iOS 14 and macOS 11, devices don’t fetch the file from your site directly. Instead, they fetch it from Apple’s CDN, which periodically downloads it from your site. You can see the CDN’s current copy with curl:
$ curl https://app-site-association.cdn-apple.com/a/v1/example.com
Replace example.com with your domain. If this returns an outdated version, wait, as the CDN takes a while to pick up changes. During development, your iOS developers can bypass the CDN with an alternate mode in the app’s associated domains entitlement.
For Android, Google provides the Digital Asset Links API to check the statements it sees for your site:
$ curl -G https://digitalassetlinks.googleapis.com/v1/statements:list \
--data-urlencode source.web.site=https://example.com \
--data-urlencode relation=delegate_permission/common.handle_all_urls
Again, replace example.com with your domain. Alternatively, Google’s Statement List Generator and Tester lets you check your file against your app’s package name and fingerprint, through a web form. On a device with your app installed, your Android developers can also check and re-run verification with commands like:
$ adb shell pm get-app-links com.example.app
$ adb shell pm verify-app-links --re-verify com.example.app
Fin
Two small JSON files to let your links open where your users want them to. Serve them, test them, and check what Apple and Google see.
May your apps be interlinked and may your heart be interlinked,
—Adam
Check out my new book Boost Your GitHub DX.
One summary email a week, no spam, I pinky promise.
Related posts:
Tags: django