Django: serve a security.txt file

Safe as houses!

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:

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/10/01/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:

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/10/01/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:

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/10/01/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:

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


Read my book Boost Your Git DX to Git better.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: