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:[email protected]
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 amailto:email address, but it can also be anhttps:URL for a web page or form, or atel:phone number. You can list severalContactlines, 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/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:
@login_not_requiredmarks the view as public, for projects using Django’sLoginRequiredMiddleware, 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_saferestricts the view to the “safe” HTTP methods: GET and HEAD.@cache_controlsets theCache-Controlheader 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_PATHpoints to the file, relative to the views module, using pathlib. If you put the file elsewhere, adjust the path.- Django’s
FileResponsestreams the file as the response body. - The explicit
content_typeadds the RFC-compliant content type, rather thanFileResponse’s default guess (justtext/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/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:[email protected]"]
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:
FileResponseis a streaming response, sotest_success()needs to read the whole body withgetvalue(), instead of thetextattribute.test_success()checks the headers set by the view, including thecache-controlheader from@cache_control.test_success()parses the file into a dictionary mapping field names to lists of values, skipping comments. It then checks theContactvalue, so swap in your own.- The
Expireschecks ensure there’s exactly one value, which parses withdatetime.fromisoformat()and includes a timezone, as the RFC requires. test_head()andtest_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/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:
- The check function starts with a mini parser to find the
Expiresentry, 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
Expiresunder 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
Read my book Boost Your Django DX, freshly updated in November 2024.
One summary email a week, no spam, I pinky promise.
Related posts:
Tags: django