02 Oct 2026

feedDjango community aggregator: Community blog posts

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:

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:

  • applinks for Universal Links. appIDs lists your app IDs, each your Apple developer team ID, a dot, and the app's bundle ID. components lists URL patterns, checked in order until the first match, so exclude patterns 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.
  • webcredentials for 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-type header must be application/json.
  • Each domain must serve its own file. Associating example.com doesn't cover www.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_required marks the views as public, for projects using Django's LoginRequiredMiddleware. I recommend you use this feature from Django 5.1 to secure your site by default!
  • @require_safe restricts the views to GET and HEAD requests.
  • @cache_control sets a cache-control header 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.
  • FileResponse streams each file as the response body, byte-for-byte. It also sets the content-type header based on the file extension, so both files get application/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 a 200 OK status code, which also means there's no redirect, plus the content-type and cache-control headers.
  • They then parse the body with json.loads(), which fails on invalid JSON, such as from a trailing comma. FileResponse is a streaming response, with no content attribute, so they read the body with getvalue().
  • 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() and test_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

02 Oct 2026 4:00am GMT

01 Oct 2026

feedDjango community aggregator: Community blog posts

Undocumented Django: Generating a SECRET_KEY

Django has some of the best documentation out there, a point of pride from the beginning that continues today thanks to the heroic efforts of many volunteers. But for all …

01 Oct 2026 2:09pm GMT

Django: serve a security.txt file

When a security researcher finds a vulnerability in your site, they need a way to tell you about it. Without a clear contact, they may resort to guessing at addresses like security@<yourdomain>, messaging random folks on social media, or give up. And of course, in the worst case, they might just publish the details, leaving you to find out when attackers do.

security.txt is a web standard to fix this problem. It's a small text file, served at the reserved path /.well-known/security.txt, that says how to report security issues to your organization. It was standardized in April 2022 as RFC 9116.

In this post, we'll look at serving a security.txt file and adding unit tests and a system check to keep it current.

Write the file

A security.txt file contains a series of Field: value lines, plus optional comments starting with #. Here's an example:

# Security contact information for example.com
Contact: mailto:security@example.com
Expires: 2027-09-01T00:00:00Z
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt
Policy: https://example.com/security/

The two required fields are:

  • Contact: Gives a way to reach you, as a URI. That's normally a mailto: email address, but it can also be an https: URL for a web page or form, or a tel: phone number. You can list several Contact lines, in order of preference.
  • Expires: Gives a date and time after which the file should be considered stale, in RFC 3339 format. It must appear exactly once. The RFC recommends setting it less than a year in the future.

Expires exists because contact details rot-folks leave, mailboxes get deleted, and bug bounty programmes close. An expiry date forces you to periodically confirm that the file is still accurate, and it tells researchers not to trust it if you forget.

To write your own security.txt file, use the handy dandy form at securitytxt.org. That beats copy-pasta'ing the above example, and lets you pick from all the possible fields.

Note that a security.txt file only applies to the domain that serves it. If you have several domains or subdomains, such as api.example.com, each needs to serve a file. You can serve the same file on each, with one Canonical line per domain.

Serve the file

The RFC requires that you serve security.txt over HTTPS, with the content type text/plain and a charset of utf-8. HTTPS should come from your production setup, such as your load balancer or the SECURE_SSL_REDIRECT setting, so the view only needs to handle the content type bit.

Below is a view that serves it appropriately, assuming your security.txt file is in the same directory as the views module file.

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

SECURITY_TXT_PATH = Path(__file__).parent / "security.txt"


@login_not_required
@require_safe
@cache_control(max_age=60 * 5, public=True)  # 5 minutes
def security_txt(request: HttpRequest) -> HttpResponse:
    """
    Serve the security.txt file, per:
    https://adamj.eu/tech/2026/09/30/django-security-txt/
    """
    return FileResponse(
        SECURITY_TXT_PATH.open("rb"),
        content_type="text/plain; charset=utf-8",
    )

…with this corresponding entry in your root URLconf:

from django.urls import path

from example.core import views as core_views

urlpatterns = [
    # ...
    path(".well-known/security.txt", core_views.security_txt),
    # ...
]

Deconstructing the view code:

  • @login_not_required marks the view as public, for projects using Django's LoginRequiredMiddleware, added in Django 5.1. I highly recommend using this middleware, as it makes your site more secure by default! If you aren't using it, you can skip this decorator, but it is harmless to leave it in place.
  • @require_safe restricts the view to the "safe" HTTP methods: GET and HEAD.
  • @cache_control sets the Cache-Control header so browsers and CDNs can cache the file for five minutes. That's a small bit of load protection which might help if a researcher hammers your site with a vulnerability scanner.
  • SECURITY_TXT_PATH points to the file, relative to the views module, using pathlib. If you put the file elsewhere, adjust the path.
  • Django's FileResponse streams the file as the response body.
  • The explicit content_type adds the RFC-compliant content type, rather than FileResponse's default guess (just text/plain, no charset).

After adding the URL, you can check it in your browser, for example at http://localhost:8000/.well-known/security.txt.

Add tests

It's test time! Test time is the best time! Here's a test case covering the view, which you could put in your app's tests.py:

import datetime as dt
from http import HTTPStatus

from django.test import SimpleTestCase


class SecurityTxtTests(SimpleTestCase):
    """
    Test the security.txt file, per:
    https://adamj.eu/tech/2026/09/30/django-security-txt/
    """

    def test_success(self):
        response = self.client.get("/.well-known/security.txt")

        assert response.status_code == HTTPStatus.OK
        assert response["content-type"] == "text/plain; charset=utf-8"
        assert response["cache-control"] == "max-age=300, public"

        # Parse the security.txt format
        content = response.getvalue().decode()
        fields: dict[str, list[str]] = {}
        for line in content.splitlines():
            name, sep, value = line.partition(": ")
            if sep and not line.startswith("#"):
                fields.setdefault(name, []).append(value)

        assert fields["Contact"] == ["mailto:security@example.com"]
        assert len(fields["Expires"]) == 1
        expires = dt.datetime.fromisoformat(fields["Expires"][0])
        assert expires.tzinfo is not None

    def test_head(self):
        response = self.client.head("/.well-known/security.txt")

        assert response.status_code == HTTPStatus.OK

    def test_post_disallowed(self):
        response = self.client.post("/.well-known/security.txt")

        assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED

Notes:

  • FileResponse is a streaming response, so test_success() needs to read the whole body with getvalue(), instead of the text attribute.
  • test_success() checks the headers set by the view, including the cache-control header from @cache_control.
  • test_success() parses the file into a dictionary mapping field names to lists of values, skipping comments. It then checks the Contact value, so swap in your own.
  • The Expires checks ensure there's exactly one value, which parses with datetime.fromisoformat() and includes a timezone, as the RFC requires.
  • test_head() and test_post_disallowed() check the effect of @require_safe.

Add a system check for expiry

The tests check that Expires is valid, but not that it's current. You need to keep confirming the data and bumping that field every year or so, to ensure your file still gets used.

To ensure that your file goes near, you need some kind of system. You could add a test that fails when the date is near, but that would start failing on some arbitrary day, blocking unrelated work. Instead, you can add a custom system check that warns when the file needs attention. (Or you could set a calendar alert, if that works for you!)

Django runs system checks at the start of most management commands, including runserver, migrate, and test. Warnings are displayed without stopping the command, so they nag without blocking.

Here's such a check, ready to be pasted in a checks.py module next to the views module:

import datetime as dt

from django.core import checks

from example.core.views import SECURITY_TXT_PATH


@checks.register
def check_security_txt_expires(app_configs, **kwargs):
    """
    Check the security.txt file is current, per:
    https://adamj.eu/tech/2026/09/30/django-security-txt/
    """
    for line in SECURITY_TXT_PATH.read_text().splitlines():
        if line.startswith("Expires: "):
            value = line.removeprefix("Expires: ")
            break
    else:
        return [
            checks.Error(
                "security.txt has no Expires field.",
                id="core.E001",
            )
        ]

    expires = dt.datetime.fromisoformat(value)
    remaining = expires - dt.datetime.now(dt.timezone.utc)
    if remaining < dt.timedelta(days=30):
        return [
            checks.Warning(
                f"security.txt expires on {expires:%Y-%m-%d}.",
                hint="Review security.txt and update its Expires field.",
                id="core.W001",
            )
        ]
    if remaining > dt.timedelta(days=365):
        return [
            checks.Warning(
                "security.txt expires more than a year from now.",
                hint="The RFC recommends an Expires under a year out.",
                id="core.W002",
            )
        ]
    return []

Import the module in your app config's ready() method, so the @checks.register decorator runs:

from django.apps import AppConfig


class CoreConfig(AppConfig):
    name = "example.core"

    def ready(self):
        from example.core import checks  # noqa: F401

Notes:

  • The check function starts with a mini parser to find the Expires entry, or fail if it's missing.
  • The function emits a check warning when your file is within 30 days of expiring, or past.
  • It emits a second warning to enforces the RFC's recommendation to keep Expires under a year out.

With the check in place, when expiry nears, all Django commands will show a warning like:

$ ./manage.py check
System check identified some issues:

WARNINGS:
?: (core.W001) security.txt expires on 2027-09-01.
        HINT: Review security.txt and update its Expires field.

System check identified 1 issue (0 silenced).

Then, hopefully, someone will see the warning and remember to update the file.

Fin

So there we go: put up one small text file and watch the vulnerability reports pour in. Well, hopefully trickle.

May your security reports be few, slop-free, and low impact,

-Adam

01 Oct 2026 4:00am GMT